JS 从设计之初就没有考虑到模块化。模块化是将一个大型项目拆分成一个个独立的小模块,这样做便于项目的代码结构组织,每个模块只专注于自己的模块内容,同时也能够按需加载模块内容。
在 ESM 出现之前社区有很多模块化方案,这里将会介绍几种主流方案,了解它们的原理、解决什么样的问题以及有什么的缺点。
原始模式
// moduleA.js
var name = 'module'
var sayHello = function() {
console.log('say Hello: ' + this.name);
}
// moduleB.js
var sayHello = function() {
console.log('say Hello: ' + this.name);
}
// main.js
sayHello();
<!-- index.html -->
<!-- ...省略其他 -->
<body>
<script src="moduleA.js"></script>
<script src="moduleB.js"></script>
<script src="main.js"></script>
</body>
</html>
最初的模块化代码,sayHello 输出值受 <script> 标签加载顺序的影响,两个模块之间存在冲突覆盖的问题。
命名空间
为了解决两个模块之间的冲突问题,引入了"命名空间"的方法:
// moduleA.js
var moduleA = {
name: 'moduleA'
};
moduleA.sayHello = function() {
console.log('say Hello: ' + this.name);
}
// moduleB.js
var moduleB = {
name: 'moduleB'
};
moduleB.sayHello = function() {
console.log('say Hello: ' + this.name);
}
// main.js
moduleA.sayHello();
命名空间解决了变量名冲突覆盖的问题。但是外部可以很轻易的修改模块内的内容:
// moduleA.js
var moduleA = {
name: 'moduleA'
};
moduleA.sayHello = function() {
console.log('say Hello: ' + this.name);
}
// main.js
moduleA.name = 'main';
moduleA.sayHello();
IIFE
为了解决代码隔离问题,可以使用闭包来封装数据,外部用户无法修改模块内的数据( *这种方式只能封装数据,外部依然可以很轻易的修改函数内容):
// moduleA.js
var moduleA = (function() {
var name = 'moduleA';
return {
sayHello: function() {
console.log('say Hello: ' + name);
}
}
})();
// main.js
moduleA.name = 'main';
moduleA.sayHello();
假设 moduleA 依赖于 moduleB,则需要将 moduleB 传递给立即执行函数,那么 moduleB 必须在 moduleA 前面加载。
// moduleB.js
var moduleB = (function() {
var name = 'moduleB';
return {
sayHello: function() {
console.log('say Hello: ' + name);
}
}
})();
// moduleA.js
var moduleA = (function(modB) {
var name = 'moduleA';
return {
sayHello: function() {
modB.sayHello();
console.log('say Hello: ' + name);
}
}
})(moduleB);
// main.js
moduleA.name = 'main';
moduleA.sayHello();
<!-- index.html -->
<!-- ...省略其他 -->
<body>
<!-- moduleB 必须在 moduleA 前面 -->
<script src="moduleB.js"></script>
<script src="moduleA.js"></script>
<script src="main.js"></script>
</body>
</html>
CommonJS
随着 Node 的出现,JS 服务端模块化标准 CommonJS 来了。
// moduleA.js
var name = 'moduleA';
var sayHello = function() {
console.log('say Hello:' + name );
}
module.exports.name = name;
module.exports.sayHello = sayHello;
// main.js
var moduleA = require('moduleA.js');
moduleA.sayHello();
加载原理
Node 提供了一个 Module 类,每个文件就是一个模块,其中 exports 属性就是对外开放的内容:
// class Module
function Module(id, parent){
this.id = id;
this.exports = {};
this.parent = parent;
this.filename = null;
this.loaded = false;
this.children = []
}
Node 提供的 require 的基本功能就是读取文件并执行,最终返回 module.exports。
模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
// 在执行模块代码之前,Node.js 将使用如下所示的函数封装器对其进行封装:
(function(exports, require, module, __filename, __dirname) {
// 模块代码实际存在于此处
});
特性
- CommonJS 是同步加载
-
- 同步加载就导致在浏览器端无法使用,会阻塞 JS 运行。而服务端由于所有资源都在一起,所以没有这个问题。
- CommonJS 是运行时加载
-
- CommonJS 加载的是个对象(module.exports),对象只有在脚本运行时才能生成。
- CommonJS 模块输出的是值的拷贝
-
- 一旦输出一个值,模块内部的变化就影响不到这个值
// moduleA.js
var counter = 3;
function incCounter() {
counter++;
}
module.exports = {
counter: counter,
incCounter: incCounter,
};
// main.js
var moduleA = require('moduleA.js');
moduleA.counter; // 3
moduleA.incCounter();
moduleA.counter; // 3
AMD
既然服务端有了 CommonJS 规范,自然会想在浏览器端设计出一套规范。
AMD(Async Module Definition)是浏览器端模块化开发规范的一种,Require.js 库就是 AMD 的一种具体实现,下面用 Require.js 举例。
<!-- index.html -->
<!-- ...省略其他 -->
<body>
<!-- data-main属性的作用是,指定网页程序的主模块。 -->
<script src="js/require.js" data-main="js/main"></script>
</body>
</html>
// main.js
require(['moduleA', 'moduleB', 'moduleC'], function (moduleA, moduleB, moduleC){
// some code here
});
// moduleA.js
define(['moduleB'], function(modB) {
var name = 'moduleA';
var sayHello = function() {
console.log('say Hello:' + name);
}
return {
sayHello
}
})
加载原理
- require.js 定义了两个全局函数 require 和 define
-
- require 会根据入口文件的依赖关系,手动插入 script 标签来加载对应模块
- 模块加载完后,会根据模块内 define 的依赖关系继续加载其余模块
- 层层递进,逐级执行
-
- 假设 moduleA 依赖 moduleB,moduleB 依赖 moduleC。那么当这三个模块都加载完成后,会先执行 moduleC 的内容,然后是 moduleB 最后是 moduleA(* 这个机制有点类似于事件的捕获/冒泡)。
特性
- 先执行依赖模块,拿到返回内容后,在父模块中使用(在示例中就是会执行 moduleB 拿到返回结果后,在 moduleA 中使用)
-
- 缺陷:这样的执行时机也被人诟病,如果 moduleA 中依赖了 moduleB 但是并没有使用 moduleB,moduleB 一样会被执行。
- 缺陷:全局声明了 require 和 define 这样的关键词。
CMD
基于 AMD 的缺陷,于是有了 CMD(Common Module Definition),而 Sea.js 是这个规范的具体实现。
与 AMD 相比,CMD 在 API 设计上与 AMD 不太一样,但是最主要的还是它们的执行时机不同:
假设 moduleA 依赖 moduleB,moduleB 依赖 moduleC。那么会先执行 moduleA,遇到 moduleB 调用时,再去执行 moduleB。
UMD
UMD 是为了让模块能够同时在浏览器和服务器上运行,即兼容 AMD 和 CommonJS 而设计出来的一种模块格式写法,了解即可。
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
//AMD
define(['jquery'], factory);
} else if (typeof exports === 'object') {
//Node, CommonJS之类的
module.exports = factory(require('jquery'));
} else {
//浏览器全局变量(root 即 window)
root.returnExports = factory(root.jQuery);
}
}(this, function ($) {
//方法
function myFunc(){};
//暴露公共方法
return myFunc;
}));
ESM
ESM(ECMA Script Module)是 ES6 语言标准层面的模块加载方案,同时兼容浏览器和服务端,是目前 JS 模块化的标准化方案。
// moduleA.js
var name = 'moduleA';
var sayHello = function() {
console.log('say Hello:' + name);
}
export { sayHello };
// moduleB.js
var name = 'moduleB';
var sayHello = function() {
console.log('say Hello:' + name);
}
export { sayHello };
// main.js
import { sayHello as sayHelloA} from 'moduleA.js';
import { sayHello as sayHelloB} from 'moduleB.js';
sayHelloA(); // say Hello: moduleA
sayHelloB(); // say Hello: moduleB
<!-- index.html -->
<!-- ...省略其他 -->
<body>
<!-- data-main属性的作用是,指定网页程序的主模块。 -->
<script src="main.js" type="module"></script>
</body>
</html>
加载原理
使用 import 和 export 两个关键字进行模块的导入和导出,引擎解析模块主要分为三个阶段:
- 构建阶段
-
- 这个阶段会从入口文件(main.js)开始,根据 import 关键字查找、下载所有文件,并解析成模块记录(module record)
- 实例化阶段
-
- 为所有模块分配内存空间(此时还没有内容),然后依据 import、export 语句将模块指向对应地址,这个过程称为 "链接(Link)"。
- 实例化模块关系图,这个过程使用 "深度优先后续遍历" 的方式 , 它会顺着关系图到达最底端没有任何依赖的模块,然后回到上一层把模块的导入链接起来。
- 运行阶段
-
- 运行模块代码,并将结果填充进分配到的内存空间。
- 这个过程使用 "深度优先后续遍历" 的方式。
特性
- ESM 输出的是值的引用
-
- 与 CommonJS 输出值的拷贝不用,JS 引擎对脚本静态分析的时候,遇到模块加载命令 import,就会生成一个只读引用。等到脚本真正执行时,再根据这个只读引用,到被加载的那个模块里面去取值。
- ESM 是编译时输出接口
-
- ESM 模块对象,它对外输出内容是静态定义的,在代码静态解析时就会生成。
- ESM 异步加载
-
- ESM 异步加载,有独立的依赖分析阶段。
循环依赖
循环依赖是指两个模块之间有相互引用的关系,例如 a 模块引入了 b 模块,b 模块也引入了 a 模块。这在大型复杂项目是很常见的,那么这个时候要如何加载模块呢?
这里只介绍 ESM 的方式:
ES6 处理“循环加载”与 CommonJS 有本质的不同。ES6 模块是动态引用,如果使用import从一个模块加载变量(即import foo from 'foo'),那些变量不会被缓存,而是成为一个指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
在 a.mjs 的第1行,发现引用了 b 模块的内容,于是运行 b.mjs。由于 a.mjs 已经运行一次了,所以不会重新运行,于是在 b.mjs 的第四行会报错,因为此时 a 模块的内存空间还没有 foo 的值。
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo); // ReferenceError: foo is not defined
export let bar = 'bar';
解决这个问题的方法,就是让b.mjs运行的时候,foo已经有定义了。这可以通过将foo写成函数来解决。
这是因为函数具有提升作用,在执行import {bar} from './b'时,函数foo就已经有定义了,所以b.mjs加载的时候不会报错。
// a.mjs
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
function foo() { return 'foo' }
export {foo};
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export {bar};
这里就有疑问了,为什么使用 function 变量提升后就会在 import 之前就定义了呢?那在 import 之前定义内容行不行?答案是不行。
// a.mjs
// 在 import 之前定义行不行?
var foo = 1;
import {bar} from './b';
console.log('a.mjs');
console.log(bar());
export {foo};
// b.mjs
import {foo} from './a';
console.log('b.mjs');
console.log(foo); // undefined
function bar() { return 'bar' }
export {bar};
即使在 import 前面定义变量,依然不行。这和引擎的执行机制有关:
// modualA.js
console.log('in modualA');
const a = 1;
export { a };
// main.js
console.log('in main');
import { a } from 'modualA';
console.log(a);
// 执行 main.js
// in modualA
// in main
// 1
即使打印在 import 前面,引擎仍然会去先执行被引用模块内容,再执行当前内容。