从 commonjs -> AMD、CMD -> ES Module 等模块化方案等发展,前端构建工具也发生了很大等变化。毕竟都2022年了,基于 esm 的 snowpack、vite 都流行起来了,大有取代 Webpack 的趋势。那么抛开这些构建工具不谈,是否了解这些前面提到的模块化方案呢?
其实不管是 commonjs 还是 ES Module,其本质都是为了更好的管理变量,但不同平台的实现不同,下面让我们一起深入 ♂ 了解这些模块化的原理和实现。
commonjs
CommonJS 是以在浏览器环境之外构建 JavaScript 生态系统为目标而产生的项目,比如node
特点
- CommonJS规范加载模块是同步的,也就是说,只有加载完成,才能执行后面的操作
- 每个文件就是一个模块,有自己的作用域,所有代码都运行在模块作用域,不会污染全局作用域。
- 模块可以多次加载,但是只会在第一次加载时运行一次,然后运行结果就被缓存了,以后再加载,就直接读取缓存结果。要想让模块再次运行,必须清除缓存。
- 模块加载的顺序,按照其在代码中出现的顺序。
- 输入的是被输出的值的拷贝。也就是说,一旦输出一个值,模块内部的变化就影响不到这个值(对象除外)
demo1
// index.js
console.log('index.js')
const {add} = require('./add');
let a = add(1,2)
console.log(a)
// add.js
console.log('add.js');
exports.add = (a, b) => a+ b;
输出
index.js
add.js
3
demo2
// a.js
var b = require('./b');
console.log(b.sayFoo());
setTimeout(() => {
console.log(b.sayFoo());
console.log(require('./b').sayFoo());
}, 1000);
// b.js
let foo = 1;
setTimeout(() => {
foo = 2;
}, 500);
module.exports = {
foo,
sayFoo: () => {
return foo;
},
};
执行:node a.js
// 执行结果:
1 1
2 1
2 1
注意 b.foo 的值一直都是 1
commonjs 的 module对象
Node内部提供一个Module构建函数。所有模块都是Module的实例。
属性
module.id模块的识别符,通常是带有绝对路径的模块文件名module.filename模块的文件名,带有绝对路径。module.loaded返回一个布尔值,表示模块是否已经完成加载module.parent返回一个对象,表示调用该模块的模块module.children返回一个数组,表示该模块要用到的其他模块module.exports表示模块对外输出的值。
exports 变量:指向 module.exports,相当于每个模块顶部都一句 var exports = module.exports;
实现原理
这个过程比较简单,可以参考 CommonJS 模块化简易实现,本文的重点是浏览器方案。
大致过程
- require -> (调用)Module._load
- 检查 Module._cache,是否缓存之中有指定模块
- 如果缓存之中没有,就创建一个新的Module实例
- 将它保存到缓存 Module._cache
- 使用 module.load() 加载指定的模块文件,读取文件内容
- 调用 module.compile(),传入(exports, require, module)等参数,在沙箱中执行代码
- 如果加载/解析过程报错,就从缓存删除该模块
- 返回该模块的 module.exports
浏览器方案
AMD
Asynchronous Module Definition,异步模块定义,AMD是 RequireJS 在推广过程中对模块定义的规范化产出。是在浏览器端模块化开发的方案。
特点
- AMD推崇依赖前置,在定义模块的时候就要声明其依赖的模块
- 模块就在完成后就会立即执行主逻辑,获取导出的数据。(所以依赖中那个先加载完就先执行,但是主模块一定在依赖执行完后执行)
说明
规范只定义了一个函数 "define",它是全局变量。函数的描述为:
define(id?, dependencies?, factory);
id: 模块名,默认为请求时指定的脚本名称dependencies: 需要的依赖factory: 模块的内容,函数or对象
用法
define("alpha", ["require", "exports", "beta"], function (require, exports, beta) {
exports.verb = function() {
return beta.verb();
// or:
return require("beta").verb();
}
});
require.js
主要解决两个问题
- 多个js文件可能有依赖关系,被依赖的文件需要早于依赖它的文件加载到浏览器
- js加载的时候浏览器会停止页面渲染,加载文件越多,页面失去响应时间越长
说明
实现了define和require两个定义模块、调用模块的方法
// index.js
require.config({}) // 初始化配置
require(['a.js', 'b.js'], function (a, b) { ... })
// a.js
defind(['c.js'], function () { ... })
实现一个简单demo
require源码流程分析
简单流程图如下
- 从
require([xx], function)开始,首先会创建模块对象module,包含id,depends,factory,exprots等信息,用于管理模块的依赖,以及加载和执行。同时这个模块会注册在enabledRegistry全局变量中,表示注册但还未激活的模块(重点,后面会用到) - 接着初始化模块代码
- 如果有依赖且依赖没加载完,会通过
script标签去加载依赖模块,然后会等待依赖加载完成(通过订阅-发布模式,事件通过模块的id订阅)在继续执行模块代码 - 当
script加载完依赖代码,通过defind记录的模块信息,在onScriptLoad的回调中注册这个模块,然后同样初始化模块代码,如果有依赖,则重复3去循环加载依赖 - 如果没有依赖,那么就跳过步骤3,直接执行模块的代码,并将输出保存到
module.exports中,并且将执行完的模块保存到全局变量defined中,然后从enabledRegistry中删除自己,然后以模块id为事件名发布这个模块已经完成的事件 - 父级模块收到子模块加载完成的事件后,会判断所有依赖
depends是否都加载完(根据id在defined查找),如果没加载完就继续等待,否则执行函数的方法。这样一层层往上执行,直到所有模块加载并执行完,就会执行最初require中的那个function。
循环依赖处理
如果加载的某个子模块,又引用了父级模块,如
// a.js
define(['modules/b.js'], function (b) {
console.log('a.js: b is ', b);
return {
a: 1
}
})
// b.js
define(['modules/a.js'], function (a) {
console.log('b.js: a is ', a);
return {
b: 'b.js'
}
})
// index.js
require(['modules/a.js', 'modules/b.js'], function (a, b) {
console.log('index.js: a is ', a, ' b is ', b)
})
当最低层的依赖 b.js 加载完后,判断依赖 a.js 还没执行完,也会继续等待(此时 a.js 还在 enabledRegistry 中,而不再 registry 中),于是所有函数就都不会执行。
require.js中的处理方式是
- 在每个模块加载完成后(
onScriptLoad中的最后)都会调用checkLoaded函数。 - 其中会从
enabledRegistry中取出所有require方式定义的模块,然后进行依赖递归检查,递归的时候,会记录下之前递归的所有依赖 - 当递归到
b.js的时候,此时已经记录了[main.js, a,js],但发现b.js的依赖a.js已经被递归过了,但没激活,那么直接取a.js.exports的值给b.js去执行(由于未初始化完成a.js.exports是undefined),接着回到步骤5,最终的输出结果:
CMD
Common Module Definition,通用模块定义,CMD是 SeaJS 在推广过程中对模块定义的规范化产出(参考)。与require.js解决的问题一样,只不过定义方式和模块加载时机上不同
特点
- CMD推崇依赖就近,所以一般不在define的参数中写依赖,在factory中写
- 一个文件一个模块,所以经常就用文件名作为模块id
- 惰性执行,与commonjs一样,执行主模块的时候,遇到require语句才执行对应的模块
说明
-
模块定义
define(factory); // factory的参数为 (require, exports, module) -
模块加载
require(id) require.async(id, callback?) // 模块内部异步加载模块
例子
// add.js
define(function (require, exports) {
exports.add = function (a, b) {
return a +b;
}
})
// index.js
seajs.use('./add.js', function (a) {
var sum = a.add(1,2); // 3
})
seajs
注: 2.2.1的时候还未解决循环依赖
主要实现两个基本功能
- 实现模块定义规范(define)
- 模块系统的启动与运行(seajs.use)
实现一个简单demo
源码分析
相比require.js, seajs稍微好理解点,代码两也少一些,写起来比较像在写node
简单流程图如下
- 从
seajs.use('xx.js', function (x) {}),首先也会创建模块的实例module,并创建一个回调函数callback(入口module,后面用entry指代他,调用module.load - 接着调用
module.pass,更根据路径实例化子模块,并将entry保存在依赖实例_entry中,每次传递完都清空自身的_entry,每次传递都会将待加载的模块数量累加到entry.remain,然后fetch去加载依赖的代码 - 加载完成,执行define,define中会调用
parseDependencies收集代码中require的依赖depends,保存在全局变量anonymousMeta,一起保存的还有执行函数facroty - 执行script回调,将
facroty、depends一起保存在之前的模块实例中,然后执行module.load - 如果有依赖,则执行步骤2
- 没有依赖,则2中的
_entry没有被清空,就会调用_entry中保存的模块入口entry.onload - onload中,
--entry.remain,判断依赖是否都加载完成(entry.remain === 0),是则执行callback,否则等待其他依赖调用 callback按顺序调用依赖的exec,然后执行入口的function
循环依赖处理
看demo
// a.js
define(function (require, exports, module) {
console.log('a.js loader');
exports.a = 'a.js not ready yet';
let b = require('./b.js'); // 这里引用了b
let c = require('./c.js');
console.log('a.js: b is', b, ' - c is ', c);
exports.a = 'a.js';
})
// b.js
define(function (require, exports, module) {
console.log('b.js loader');
let a = require('./a.js'); // 此处a还没加载完成
console.log('b.js: a is ', a);
module.exports = {
b: 'b.js'
}
})
// c.js
define(function (require, exports, module) {
console.log('c.js loader');
module.exports = {
c: 'c.js'
}
})
//index.js
seajs.use('./modules/a.js', function (a) {
console.log('index.js: a ', a)
});
seajs的主要处理逻辑都在Mdoule.prototype.pass中,在pass中,会将(起始use中定义的模块(entry)传递到子依赖中,当模块module在pass中遍历子依赖时,会发生一下几种情况:
-
子依赖还没加载,则保存到子依赖的
_entry中,并且entry.histroy中记录下子依赖的id,并将记录了entry的子模块数量累加到entry.remain中(此时remain还要减1,表示当前模块module已经加载了),接着清空模块module._entry,所以此时外面不会进入onload,即对应上面的a.js,此时的一些数据如下entry.remian = 2 entry.history = { 'a.js': true, 'b.js': true, 'c.js': true } a._entry = [] b._entry = [entry] c._entry = [entry] -
如果模块没有子依赖,也就不会执行步骤1中的场景,所以外面判断
_entry存在则调用onload,即对应demo中的c.js,执行完onload之后,此时的一些数据如下entry.remian = 1 entry.history = { 'a.js': true, 'b.js': true, 'c.js': true } a._entry = [] b._entry = [entry] // 等待b.js处理 c._entry = [] -
如果子模块依赖了父依赖(循环引用),也就时在
entry.histroy中记录了,也不会执行步骤1,所以外面也会执行onload,即对应上面demo中的b.js的场景,执行完onload之后,此时的一些数据如下entry.remian = 1 entry.history = { 'a.js': true, 'b.js': true, 'c.js': true } a._entry = [] b._entry = [] c._entry = [entry] // 等待c.js执行 -
所以当第2或第3部执行完后,只要等另外一个依赖加载完,
remain就能置为0,然后调用entry.callback()
结果如下
总结
到最后其实可以看出来sea.js和require.js的实现还有有些不同的,require.js通过发布-订阅的方式,通知父模块它的子依赖加载完成,这样父模块才能执行其factory,获取exports,然后一层层的往上通知,最后传递到入口(require(xxx, function))。
而seajs由于不需要执行父模块,最终需要执行的就只有入口的那个函数(seajs.use(xxx, function)),所以通过将回调传递到未加载的子依赖中,等子依赖执行完了直接调用即可。
很明显,seajs也可以用过发布-订阅的方式去实现,只要require.js中不执行父依赖的factory,等使用到的时候在执行就行了。
当然,require.js也可以改造成sea.js的方式加载依赖,但不能直接传递入口函数了,而是要封装一下, 感觉实现起来就没那么优雅了。
Browserify
browserify,用于改写现有的CommonJS模块,使得浏览器端也可以使用这些模块
特点
见阮一峰老师的文章,这里简单概括下
- 将CommonJS模块转成客户端也能使用的模块
- 可以实时生成,也就是可以在发送请求的时候才生成模块代码
现在主要是用webpack做构建了,这东西也没怎么接触,不详细讲
es module
es6在语言层面上实现的模块化方案,前面提到的commonjs和AMD、CMD都是社区制定了一些模块加载方案。
特点
- 编译时确定依赖关系,所以不能这么写
import a from 'a' + '.js',但commonjs可以 - 模块脚本自动采用严格模式,不管有没有声明
use strict - 模块之中,可以使用
import命令加载其他模块(.js后缀不可省略,需要提供绝对 URL 或相对 URL),也可以使用export命令输出对外接口。 - 模块之中,顶层的
this关键字返回undefined,而不是指向window。也就是说,在模块顶层使用this关键字,是无意义的。 - 同一个模块如果加载多次,将只执行一次。
- ES6 模块输出的是值的引用,导出都是以 "活动绑定" 的方式处理(这一点对于循环依赖的模块的执行很有帮助)
- 编译时,就执行模块获取输出
例子
//a.mjs
import {bar} from './b.mjs';
console.log('a.js');
console.log(bar);
export let foo = 'foo'
// b.mjs
import {foo} from './a.mjs';
console.log('b.js');
// console.log('a, foo:', foo);
// 报错, 初始化完成前不能使用 Cannot access 'foo' before initialization
setTimeout(() => {
console.log('a, foo:', foo); // 正常输出
})
export let bar = 'bar';
具体解析过程参考:深入理解 ES Modules
扩展阅读
- cjs, umd, esm or iife?
- CommonJS规范
- require,import区别?
- 前端模块化开发那点历史
- SeaJS从入门到原理
- RequireJS、SeaJS
- Webpack、Browserify和Gulp三者之间到底是怎样的关系