title: 模块化
categories: Javascript
tag:

  • 模块化
    date: 2021-12-02 07:16:34

模块化

链接 🔗

在网页开发的早期,Brendan Eich开发 JavaScript 仅仅作为一种脚本语言,做一些简单的表单验证或动画实现等,那个时候代码还是很少的:

  • 这个时候我们只需要讲 JavaScript 代码写到<script>标签中即可;
  • 并没有必要放到多个文件中来编写;甚至流行:通常来说 JavaScript 程序的长度只有一行。

但是随着前端和 JavaScript 的快速发展,JavaScript 代码变得越来越复杂了:

  • ajax 的出现,前后端开发分离,意味着后端返回数据后,我们需要通过 JavaScript 进行前端页面的渲染;
  • SPA 的出现,前端页面变得更加复杂:包括前端路由、状态管理等等一系列复杂的需求需要通过 JavaScript 来实现;
  • 包括 Node 的实现,JavaScript 编写复杂的后端程序,没有模块化是致命的硬伤;

所以,模块化已经是 JavaScript 一个非常迫切的需求:

  • 但是 JavaScript 本身,直到 ES6(2015)才推出了自己的模块化方案;
  • 在此之前,为了让 JavaScript 支持模块化,涌现出了很多不同的模块化规范:AMD、CMD、CommonJS 等;

没有模块化带来的问题

当然,我们有办法可以解决上面的问题:立即函数调用表达式(IIFE)

  • IIFE (Immediately Invoked Function Expression)

但是,我们其实带来了新的问题:

  • 第一,我必须记得每一个模块中返回对象的命名,才能在其他模块使用过程中正确的使用;
  • 第二,代码写起来混乱不堪,每个文件中的代码都需要包裹在一个匿名函数中来编写;
  • 第三,在没有合适的规范情况下,每个人、每个公司都可能会任意命名、甚至出现模块名称相同的情况;

所以,我们会发现,虽然实现了模块化,但是我们的实现过于简单,并且是没有规范的。

  • 我们需要制定一定的规范来约束每个人都按照这个规范去编写模块化的代码;
  • 这个规范中应该包括核心功能:模块本身可以导出暴露的属性,模块又可以导入自己需要的属性;
  • JavaScript 社区为了解决上面的问题,涌现出一系列好用的规范,接下来我们就学习具有代表性的一些规范。

CommonJS 规范和 Node 关系

我们需要知道 CommonJS 是一个规范,最初提出来是在浏览器以外的地方使用,并且当时被命名为ServerJS,后来为了

体现它的广泛性,修改为CommonJS,平时我们也会简称为 CJS。

  • Node 是 CommonJS 在服务器端一个具有代表性的实现;
  • Browserify 是 CommonJS 在浏览器中的一种实现;
  • webpack 打包工具具备对 CommonJS 的支持和转换;

所以,Node 中对 CommonJS 进行了支持和实现,让我们在开发 node 的过程中可以方便的进行模块化开发:

  • 在 Node 中每一个 js 文件都是一个单独的模块;
  • 这个模块中包括 CommonJS 规范的核心变量:exports、module.exports、require;
  • 我们可以使用这些变量来方便的进行模块化开发;

前面我们提到过模块化的核心是导出和导入,Node 中对其进行了实现:

  • exports 和 module.exports可以负责对模块中的内容进行导出;
  • require 函数可以帮助我们导入其他模块(自定义模块、系统模块、第三方库模块)中的内容;

导出

  1. const name = 'why'
  2. const age = 18
  3. function sum(num1, num2) {
  4. return num1 + num2
  5. }
  6. module.exports = {
  7. name,
  8. age,
  9. sum
  10. }

导入

  1. const { name, age, sum } = require('./why')
  2. console.log(name)
  3. console.log(age)
  4. console.log(sum(20, 30))

exports 导出

注意:exports 是一个对象,我们可以在这个对象中添加很多个属性,添加的属性会导出;

  1. //这个要放在前面
  2. module.exports = {}
  3. module.exports.name = name
  4. module.exports.age = age
  5. module.exports.sum = sum
  1. module.exports.name = name
  2. module.exports.age = age
  3. module.exports.sum = sum
  4. //如果这个在后面,也不会导出。相当于又重新了一个新对象
  5. module.exports = {}

注意

  1. //这个方法不可以导出
  2. exports = {
  3. name,
  4. age,
  5. sum
  6. }

module.exports

但是 Node 中我们经常导出东西的时候,又是通过 module.exports 导出的:

  • module.exports 和 exports 有什么关系或者区别呢?

我们追根溯源,通过维基百科中对 CommonJS 规范的解析:

  • CommonJS 中是没有 module.exports 的概念的;
  • 但是为了实现模块的导出,Node 中使用的是 Module 的类,每一个模块都是 Module 的一个实例,也就是 module;
  • 所以在 Node 中真正用于导出的其实根本不是 exports,而是 module.exports;
  • 因为 module 才是导出的真正实现者;

但是,为什么 exports 也可以导出呢?

  • 这是因为 module 对象的 exports 属性是 exports 对象的一个引用;
  • 也就是说 module.exports = exports = main 中的 bar;

require 细节

我们已经知道,require 是一个函数,可以帮助我们引入文件(模块)中导入的对象

导入格式如下:require(X)

情况一:X 是一个核心模块,比如 path,http

  • 直接返回核心模块,并且停止查找

情况二:X 是以./或者../(根目录)开头的

第一步:将 X 当作一个文件在对应的目录下查找

  1. 如果有后缀名,按照后缀名的格式查找对应的文件
  2. 如果没有后缀名,会按照如下顺序
    1. 直接查找文件 X
    2. 查找 X.js 文件
    3. 查找 X.json 文件
    4. 查找 X.node 文件

第二步:没有找到对应的文件,将 X 作为一个目录

  • 直接查找目录下的 index 文件
    1. 查找 X/index.js 文件
    2. 查找 X/index.json 文件
    3. 查找 X/index.node 文件

情况三:直接是一个 X,没有路径,并且 X 也不是一个核心模块

是在 node-modules 中查

模块的加载过程

结论一:模块在被第一次引入时,模块中的 js 代码会被运行一次

结论二:模块被多次引入时,会缓存,最终只加载(运行)一次

  1. 为什么只会加载运行一次呢?
  2. 这是因为每个模块对象 module 都有一个属性:loaded。
  3. 为 false 表示还没有加载,为 true 表示已经加载;
    25_模块化 - 图1

结论三:如果有循环引入,那么加载顺序是什么?

  1. 如果出现下图模块的引用关系,那么加载顺序是什么呢?
    25_模块化 - 图2
  2. 这个其实是一种数据结构:图结构;
  3. 图结构在遍历的过程中,有深度优先搜索(DFS, depth first search)和广度优先搜索(BFS, breadth first search);
  4. Node 采用的是深度优先算法:main -> aaa -> ccc -> ddd -> eee ->bbb

CommonJS 规范缺点

CommonJS 加载模块是同步的:

  • 同步的意味着只有等到对应的模块加载完毕,当前模块中的内容才能被运行;
  • 这个在服务器不会有什么问题,因为服务器加载的 js 文件都是本地文件,加载速度非常快;

如果将它应用于浏览器呢?

  • 浏览器加载 js 文件需要先从服务器将文件下载下来,之后再加载运行;
  • 那么采用同步的就意味着后续的 js 代码都无法正常运行,即使是一些简单的 DOM 操作;
  • 服务器端一般采用同步加载文件,也就是说需要某个模块,服务器端便停下来,等待它加载再执行。而浏览器端要保证效率,需要采用异步加载,这就需要一个预处理,提前将所需要的模块文件并行加载好。

所以在浏览器中,我们通常不使用 CommonJS 规范:

  • 当然在 webpack 中使用 CommonJS 是另外一回事;
  • 因为它会将我们的代码转成浏览器可以直接执行的代码;

在早期为了可以在浏览器中使用模块化,通常会采用 AMD 或 CMD

  • 但是目前一方面现代的浏览器已经支持 ES Modules,另一方面借助于 webpack 等工具可以实现对 CommonJS 或者 ES

Module 代码的转换;

  • AMD 和 CMD 已经使用非常少了,所以这里我们进行简单的演练;

AMD 规范

AMD 主要是应用于浏览器的一种模块化规范:

  • AMD 是 Asynchronous Module Definition(异步模块定义)的缩写;
  • 它采用的是异步加载模块;
  • 事实上 AMD 的规范还要早于 CommonJS,但是 CommonJS 目前依然在被使用,而 AMD 使用的较少了;
  • AMD 推崇依赖前置,在定义模块的时候就要声明其依赖的模块
    同样都是异步加载模块,AMD 在加载模块完成后就会执行改模块,所有模块都加载执行完后会进入 require 的回调函数,执行主逻辑,这样的效果就是依赖模块的执行顺序和书写顺序不一定一致,看网络速度,哪个先下载下来,哪个先执行,但是主逻辑一定在所有依赖加载完成后才执行。参考链接

我们提到过,规范只是定义代码的应该如何去编写,只有有了具体的实现才能被应用:

  • AMD 实现的比较常用的库是 require.js 和 curl.js;

require.js 使用

第一步:下载 require.js

第二步:定义 HTML 的 script 标签引入 require.js 和定义入口文件:

  • data-main 属性的作用是在加载完 src 的文件后会加载执行该文件

require.js 的使用

  1. <script src="./lib/require.js" data-main="./index.js"></script>

requireJS 主要解决两个问题

1、多个 js 文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
2、js 加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长

CMD 函数

CMD 规范也是应用于浏览器的一种模块化规范:

  • CMD 是 Common Module Definition(通用模块定义)的缩写;
  • 它也采用了异步加载模块,但是它将 CommonJS 的优点吸收了过来;
  • 但是目前 CMD 使用也非常少了;

CMD 也有自己比较优秀的实现方案:

  • SeaJS

CMD 加载完某个依赖模块后并不执行,只是下载而已,在所有依赖模块加载完成后进入主逻辑,遇到 require 语句的时候才执行对应的模块,这样模块的执行顺序和书写顺序是完全一致的

ESModule

ES Module 和 CommonJS 的模块化有一些不同之处:

  • 一方面它使用了 import 和 export 关键字;
  • 另一方面它采用编译期的静态分析,并且也加入了动态引用的方式;

ES Module 模块采用 export 和 import 关键字来实现模块化:

  • export 负责将模块内的内容导出;
  • import 负责从其他模块导入内容;

了解:采用 ES Module 将自动采用严格模式:use strict

当我们使用普通的 js 引入时,

25_模块化 - 图3

我们必须写名这个 js 文件是一个模块。如果我们是在本地直接打开 index.html。没有使用 live Server

25_模块化 - 图4

这个在 MDN 上面有给出解释:

链接

  • 你需要注意本地测试 — 如果你通过本地加载 Html 文件 (比如一个 file:// 路径的文件), 你将会遇到 CORS 错误,因为 Javascript 模块安全性需要。
  • 你需要通过一个服务器来测试。 VSCode 中有一个插件:Live Server

exports 关键字

export 关键字将一个模块中的变量、函数、类等导出;

我们希望将其他中内容全部导出,它可以有如下的方式:

  1. 方式一:在语句声明的前面直接加上 export 关键字
  1. export const name = 'why'
  2. export const age = 18
  1. 方式二:将所有需要导出的标识符,放到 export 后面的 {}中
    • 注意:这里的 {}里面不是 ES6 的对象字面量的增强写法,{}也不是表示一个对象的;
    • 所以: export {name: name},是错误的写法;
      25_模块化 - 图5
  1. export { name, age, foo }
  1. 方式三:导出时给标识符起一个别名
  1. export { name as fName, age as fAge, foo }

导出

导入的方式

第一种

  1. import { name, Age, sayHello } from './foo.js'

第二种

  1. import { name as iName, Age as iAge, sayHello } from './foo.js'

第三种

  1. import * as foo from './foo.js'
  2. console.log(foo.fName)

export 和 import 结合

比如 foo 文件和 index 文件中间通过 bar 文件进行交流。

  1. //foo文件
  2. const name = 'dh'
  3. const age = 12
  4. const sayHello = function () {
  5. console.log('n你好')
  6. }
  7. // 是大括号,但是不是一个对象
  8. export { name, age, sayHello }

如果我们分开。

  1. import { name, age, sayHello } from './foo.js'
  2. export { name, age, sayHello }

在 bar 里就是导入和导出结合使用。

  1. //bar文件
  2. export { name, age, sayHello } from './foo.js'
  1. //index文件
  2. import * as bar from './bar.js'
  3. console.log(bar.name)

在开发和封装一个功能的时候,通常我们希望将暴露的接口放到一个文件中

这样方便指定统一的接口规范,也方便阅读

这个时候,我们就可以使用 export 和 import 结合使用

default

前面我们学习的导出功能都是有名字的导出(named exports):

  • 在导出 export 时指定了名字;
  • 在导入 import 时需要知道具体的名字;

还有一种导出叫做默认导出(default export)

  • 默认导出 export 时可以不需要指定名字;
  • 在导入时不需要使用 {},并且可以自己来指定名字;
  • 它也方便我们和现有的 CommonJS 等规范相互操作;

注意:在一个模块中,只能有一个默认导出(default export);

import 函数

通过 import 加载一个模块,是不可以在其放到逻辑代码中的,比如:

为什么会出现这个情况呢?

  • 这是因为 ES Module 在被 JS 引擎解析时,就必须知道它的依赖关系;
  • 由于这个时候 js 代码没有任何的运行,所以无法在进行类似于 if 判断中根据代码的执行情况;
  • 甚至下面的这种写法也是错误的:因为我们必须到运行时能确定 path 的值;

但是某些情况下,我们确确实实希望动态的来加载某一个模块:

  • 如果根据不同的条件,动态来选择加载模块的路径;
  • 这个时候我们需要使用 import() 函数来动态加载;
  1. / 演练: import()函数
  2. let flag = true;
  3. if (flag) {
  4. // require的本质是一个函数
  5. // require('')
  6. // 执行函数
  7. // 如果是webpack的环境下: 模块化打包工具: es CommonJS require()
  8. // 纯ES Module环境下面: import(), 这里的import是个函数,返回的是promise
  9. // 脚手架 -> webpack: import()会单独打包
  10. import('./modules/foo.js').then(res => {
  11. console.log("在then中的打印");
  12. console.log(res.name);
  13. console.log(res.age);
  14. }).catch(err => {
  15. })
  16. }
  17. console.log('后续代码')

import meta

import.meta 是一个给 JavaScript 模块暴露特定上下文的元数据属性的对象。

  • 它包含了这个模块的信息,比如说这个模块的 URL;
  • 在 ES11(ES2020)中新增的特性;

ESModule 的解析流程

ES Module 是如何被浏览器解析并且让模块之间可以相互引用的呢?

ES Module 的解析过程可以划分为三个阶段:

  • 阶段一:构建(Construction),根据地址查找 js 文件,并且下载,将其解析成模块记录(Module Record);
  • 阶段二:实例化(Instantiation),对模块记录进行实例化,并且分配内存空间,解析模块的导入和导出语句,把模块指向对应的内存地址。
  • 阶段三:运行(Evaluation),运行代码,计算值,并且将值填充到内存地址中;

阶段一:构建阶段

25_模块化 - 图6

阶段二和三:实例化阶段 – 求值阶段

25_模块化 - 图7

CommonJS 和 ES Module 交互

  • 结论一:通常情况下,CommonJS 不能加载 ES Module
    • 因为 CommonJS 是同步加载的,但是 ES Module 必须经过静态分析等,无法在这个时候执行 JavaScript 代码;
    • 但是这个并非绝对的,某些平台在实现的时候可以对代码进行针对性的解析,也可能会支持;
    • Node 当中是不支持的;
  • 结论二:多数情况下,ES Module 可以加载 CommonJS
    • ES Module 在加载 CommonJS 时,会将其 module.exports 导出的内容作为 default 导出方式来使用;
    • 这个依然需要看具体的实现,比如 webpack 中是支持的、Node 最新的 Current 版本也是支持的;
    • 在最新的 LTS 版本中也不支持;