阮一峰在 ES6 入门 中提到 ES6 模块与 CommonJS 模块有一些重大的差异:
-
CommonJS 模块输出的是一个值的拷贝,ES6 模块输出的是值的引用。
-
CommonJS 模块是运行时加载,ES6 模块是编译时输出接口。
再细读上面阮老师提到的差异,会产生诸多疑问:
- 为什么 CommonJS 模块输出的是一个值的拷贝?其具体细节是什么样子的?
- 什么叫
运行时加载? - 什么叫
编译时输出接口? - 为什么 ES6 模块输出的是值的引用?
于是就有了这篇文章,力求把 ESM 模块 和 CommonJS 模块 讨论清楚。
CommonJS 产生的历史背景
CommonJS 由 Mozilla 工程师 Kevin Dangoor 于 2009 年 1 月创立,最初命名为ServerJS。2009 年 8 月,该项目更名为CommonJS。旨在解决 Javascript 中缺少模块化标准的问题。
Node.js 后来也采用了 CommonJS 的模块规范。
由于 CommonJS 并不是 ECMAScript 标准的一部分,所以 类似 module 和 require 并不是 JS 的关键字,仅仅是对象或者函数而已,意识到这一点很重要。
我们可以在打印 module、require 查看细节:
Module {
id: '.',
path: '/Users/liangmac/Desktop/work/bestright/test/test-exmp',
exports: {},
filename: '/Users/liangmac/Desktop/work/bestright/test/test-exmp/1.js',
loaded: false,
children: [],
paths: [
'/Users/liangmac/Desktop/work/bestright/test/test-exmp/node_modules',
'/Users/liangmac/Desktop/work/bestright/test/node_modules',
'/Users/liangmac/Desktop/work/bestright/node_modules',
'/Users/liangmac/Desktop/work/node_modules',
'/Users/liangmac/Desktop/node_modules',
'/Users/liangmac/node_modules',
'/Users/node_modules',
'/node_modules'
]
}
[Function: require] {
resolve: [Function: resolve] { paths: [Function: paths] },
main: Module {
id: '.',
path: '/Users/liangmac/Desktop/work/bestright/test/test-exmp',
exports: {},
filename: '/Users/liangmac/Desktop/work/bestright/test/test-exmp/1.js',
loaded: false,
children: [],
paths: [
'/Users/liangmac/Desktop/work/bestright/test/test-exmp/node_modules',
'/Users/liangmac/Desktop/work/bestright/test/node_modules',
'/Users/liangmac/Desktop/work/bestright/node_modules',
'/Users/liangmac/Desktop/work/node_modules',
'/Users/liangmac/Desktop/node_modules',
'/Users/liangmac/node_modules',
'/Users/node_modules',
'/node_modules'
]
},
extensions: [Object: null prototype] {
'.js': [Function (anonymous)],
'.json': [Function (anonymous)],
'.node': [Function (anonymous)]
},
cache: [Object: null prototype] {
'/Users/liangmac/Desktop/work/bestright/test/test-exmp/1.js': Module {
id: '.',
path: '/Users/liangmac/Desktop/work/bestright/test/test-exmp',
exports: {},
filename: '/Users/liangmac/Desktop/work/bestright/test/test-exmp/1.js',
loaded: false,
children: [],
paths: [Array]
}
}
}
可以看到 module 是一个对象, require 是一个函数,仅此而已。
我们来重点介绍下 module 中的一些属性:
exports:这就是module.exports对应的值,由于还没有赋任何值给它,它目前是一个空对象。loaded:表示当前的模块是否加载完成。paths:node 模块的加载路径,这块不展开讲,感兴趣可以看node 文档
require 函数中也有一些值得注意的属性:
main指向当前当前引用自己的模块,所以类似 python 的__name__ == '__main__', node 也可以用require.main === module来确定是否是以当前模块来启动程序的。extensions表示目前 node 支持的几种加载模块的方式。cache表示 node 中模块加载的缓存,也就是说,当一个模块加载一次后,之后require不会再加载一次,而是从缓存中读取。
前面提到,CommonJS 中 module 是一个对象, require 是一个函数。而与此相对应的 ESM 中的 import 和 export 则是关键字,是 ECMAScript 标准的一部分。理解这两者的区别非常关键。
先看几个 CommonJS 例子
大家看看下面几个 CommonJS 例子,看看能不能准确预测结果:
例一,在模块外为简单类型赋值:
// a.js
let val = 1;
const setVal = (newVal) => {
val = newVal
}
module.exports = {
val,
setVal
}
// b.js
const { val, setVal } = require('./a.js')
console.log(val);
setVal(101);
console.log(val);
运行 b.js,输出结果为:
1
1
例二,在模块外为引用类型赋值:
// a.js
let obj = {
val: 1
};
const setVal = (newVal) => {
obj.val = newVal
}
module.exports = {
obj,
setVal
}
// b.js
const { obj, setVal } = require('./a.js')
console.log(obj);
setVal(101);
console.log(obj);
运行 b.js,输出结果为:
{ val: 1 }
{ val: 101 }
例三,在模块内导出后改变简单类型:
// a.js
let val = 1;
setTimeout(() => {
val = 101;
}, 100)
module.exports = {
val
}
// b.js
const { val } = require('./a.js')
console.log(val);
setTimeout(() => {
console.log(val);
}, 200)
运行 b.js,输出结果为:
1
1
例四,在模块内导出后用 module.exports 再导出一次:
// a.js
setTimeout(() => {
module.exports = {
val: 101
}
}
, 100)
module.exports = {
val: 1
}
// b.js
const a = require('./a.js')
console.log(a);
setTimeout(() => {
console.log(a);
}, 200)
运行 b.js,输出结果为:
{ val: 1 }
{ val: 1 }
例五,在模块内导出后用 exports 引用写法 再导出一次:
// a.js
setTimeout(() => {
module.exports.val = 101;
}, 100)
module.exports.val = 1
// b.js
const a = require('./a.js')
console.log(a);
setTimeout(() => {
console.log(a);
}, 200)
运行 b.js,输出结果为:
{ val: 1 }
{ val: 101 }
如何解释上面的例子?没有魔法!一言道破 CommonJS 值拷贝的细节
拿出 JS 最朴素的思维,来分析上面例子的种种现象。
例一中,代码可以简化为:
const myModule = {
exports: {}
}
let val = 1;
const setVal = (newVal) => {
val = newVal
}
myModule.exports = {
val,
setVal
}
const { val: useVal, setVal: useSetVal } = myModule.exports
console.log(useVal);
useSetVal(101)
console.log(useVal);
例二中,代码可以简化为:
const myModule = {
exports: {}
}
let obj = {
val: 1
};
const setVal = (newVal) => {
obj.val = newVal
}
myModule.exports = {
obj,
setVal
}
const { obj: useObj, setVal: useSetVal } = myModule.exports
console.log(useObj);
useSetVal(101)
console.log(useObj);
例三中,代码可以简化为:
const myModule = {
exports: {}
}
let val = 1;
setTimeout(() => {
val = 101;
}, 100)
myModule.exports = {
val
}
const { val: useVal } = myModule.exports
console.log(useVal);
setTimeout(() => {
console.log(useVal);
}, 200)
例四中,代码可以简化为:
const myModule = {
exports: {}
}
setTimeout(() => {
myModule.exports = {
val: 101
}
}, 100)
myModule.exports = {
val: 1
}
const useA = myModule.exports
console.log(useA);
setTimeout(() => {
console.log(useA);
}, 200)
例五中,代码可以简化为:
const myModule = {
exports: {}
}
setTimeout(() => {
myModule.exports.val = 101;
}, 100)
myModule.exports.val = 1;
const useA = myModule.exports
console.log(useA);
setTimeout(() => {
console.log(useA);
}, 200)
尝试运行上面的代码,可以发现和 CommonJS 输出的效果一致。所以 CommonJS 不是什么魔法,仅仅是日常写的最简简单单的 JS 代码。
其值拷贝发生在给 module.exports 赋值的那一刻,例如:
let val = 1;
module.exports = {
val
}
做的事情仅仅是给 module.exports 赋予了一个新的对象,在这个对象里有一个key叫做 val,这个 val 的值是当前模块中 val 的值,仅此而已。
CommonJS 的具体实现
为了更透彻的了解 CommonJS,我们来写一个简单的模块加载器,主要参考了 nodejs 源码;
在 node v16.x 中 module 主要实现在 lib/internal/modules/cjs/loader.js 文件下。
在 node v4.x 中 module 主要实现在 lib/module.js 文件下。
下面的实现主要参考了 node v4.x 中的实现,因为老版本相对更“干净”一些,更容易抓住细节。
另外 深入Node.js的模块加载机制,手写require函数 这篇文章写的也很不错,下面的实现很多也参考了这篇文章。
为了跟官方Module名字区分开,我们自己的类命名为MyModule:
function MyModule(id = '') {
this.id = id; // 模块路径
this.exports = {}; // 导出的东西放这里,初始化为空对象
this.loaded = false; // 用来标识当前模块是否已经加载
}
require方法
我们一直用的 require 其实是 Module 类的一个实例方法,内容很简单,先做一些参数检查,然后调用 Module._load 方法,源码在这里,本示例为了简洁,去掉了一些判断:
MyModule.prototype.require = function (id) {
return MyModule._load(id);
}
require 是一个很简单函数,主要是包装了 _load 函数,这个函数主要做了如下事情:
- 先检查请求的模块在缓存中是否已经存在了,如果存在了直接返回缓存模块的
exports - 如果不在缓存中,就创建一个
Module实例,将该实例放到缓存中,用这个实例加载对应的模块,并返回模块的exports
MyModule._load = function (request) { // request是传入的路径
const filename = MyModule._resolveFilename(request);
// 先检查缓存,如果缓存存在且已经加载,直接返回缓存
const cachedModule = MyModule._cache[filename];
if (cachedModule) {
return cachedModule.exports;
}
// 如果缓存不存在,我们就加载这个模块
const module = newMyModule(filename);
// load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
MyModule._cache[filename] = module;
// 如果 load 失败,需要将 _cache 中相应的缓存删掉。这里简单起见,不做这个处理
module.load(filename);
return module.exports;
}
可以看到上述源码还调用了两个方法:MyModule._resolveFilename 和 MyModule.prototype.load,下面我们来实现下这两个方法。
MyModule._resolveFilename
这个函数的作用是通过用户传入的 require 参数来解析到真正的文件地址,源码中这个方法比较复杂,因为他要支持多种参数:内置模块,相对路径,绝对路径,文件夹和第三方模块等等。
本示例为了简洁,只实现相对文件的导入:
MyModule._resolveFilename = function (request) {
return path.resolve(request);
}
MyModule.prototype.load
MyModule.prototype.load 是一个实例方法,源代码在这里,这个方法就是真正用来加载模块的方法,这其实也是不同类型文件加载的一个入口,不同类型的文件会对应 MyModule._extensions 里面的一个方法:
MyModule.prototype.load = function (filename) {
// 获取文件后缀名
const extname = path.extname(filename);
// 调用后缀名对应的处理函数来处理,当前实现只支持 JS
MyModule._extensions[extname](this, filename);
this.loaded = true;
}
加载文件: MyModule._extensions['X']
前面提到不同文件类型的处理方法都挂载在 MyModule._extensions 上,事实上 node 的加载器不仅仅可以加载 .js 模块,也可以加载 .json 和 .node 模块。本示例简单起见仅实现 .js 类型文件的加载:
MyModule._extensions['.js'] = function (module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename);
}
可以看到js的加载方法很简单,只是把文件内容读出来,然后调了另外一个实例方法 _compile 来执行他。对应的源码在这里。
_compile 实现
MyModule.prototype._compile 是加载JS文件的核心所在,这个方法需要将目标文件拿出来执行一遍。对应的源码在这里。
_compile 主要做了如下事情:
1、执行之前需要将它整个代码包裹一层,以便注入 exports, require, module, __dirname, __filename,这也是我们能在JS文件里面直接使用这几个变量的原因。要实现这种注入也不难,假如我们 require 的文件是一个简单的 Hello World,长这样:
module.exports = "hello world";
那我们怎么来给他注入 module 这个变量呢?答案是执行的时候在他外面再加一层函数,使他变成这样:
function (module) { // 注入module变量,其实几个变量同理
module.exports = "hello world";
}
nodeJS 也是这样实现的,在node源码里,会有这样的代码:
NativeModule.wrap = function(script) {
return NativeModule.wrapper[0] + script + NativeModule.wrapper[1];
};
NativeModule.wrapper = [
'(function (exports, require, module, __filename, __dirname) { ',
'\n});'
];
这样通过MyModule.wrap包装的代码就可以获取到 exports, require, module, __filename, __dirname 这几个变量了。
2、放入沙盒里执行包装好的代码,并返回模块的 export。沙盒执行使用了 node 的 vm 模块。
在本实现中,_compile 实现如下:
注意:vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
MyModule.prototype._compile = function (content, filename) {
var self = this;
// 获取包装后函数体
const wrapper = MyModule.wrap(content);
// vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
// 返回值就是转化后的函数,所以compiledWrapper是一个函数
const compiledWrapper = vm.runInThisContext(wrapper, {
filename
});
const dirname = path.dirname(filename);
const args = [self.exports, self.require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
}
wrapper 和 warp 的实现如下:
MyModule.wrapper = [
'(function (myExports, myRequire, myModule, __filename, __dirname) { ',
'\n});'
];
MyModule.wrap = function (script) {
return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};
注意上面的 wrapper 中我们使用了 myRequire 和 myModule 来区分原生的 require 和 module, 下面的例子中我们会使用自己实现的函数来加载文件。
最后生成一个实例并导出
最后我们 new 一个 MyModule 的实理并导出,方便外面使用:
const myModuleInstance = new MyModule();
const MyRequire = (id) => {
return myModuleInstance.require(id);
}
module.exports = {
MyModule,
MyRequire
}
完整代码
最后的完整代码如下:
const path = require('path');
const vm = require('vm');
const fs = require('fs');
function MyModule(id = '') {
this.id = id; // 模块路径
this.exports = {}; // 导出的东西放这里,初始化为空对象
this.loaded = false; // 用来标识当前模块是否已经加载
}
MyModule._cache = {};
MyModule._extensions = {};
MyModule.wrapper = [
'(function (myExports, myRequire, myModule, __filename, __dirname) { ',
'\n});'
];
MyModule.wrap = function (script) {
return MyModule.wrapper[0] + script + MyModule.wrapper[1];
};
MyModule.prototype.require = function (id) {
return MyModule._load(id);
}
MyModule._load = function (request) { // request是传入的路径
const filename = MyModule._resolveFilename(request);
// 先检查缓存,如果缓存存在且已经加载,直接返回缓存
const cachedModule = MyModule._cache[filename];
if (cachedModule) {
return cachedModule.exports;
}
// 如果缓存不存在,我们就加载这个模块
// 加载前先new一个MyModule实例,然后调用实例方法load来加载
// 加载完成直接返回module.exports
const module = new MyModule(filename);
// load之前就将这个模块缓存下来,这样如果有循环引用就会拿到这个缓存,但是这个缓存里面的exports可能还没有或者不完整
MyModule._cache[filename] = module;
// 如果 load 失败,需要将 _cache 中相应的缓存删掉。这里简单起见,不做这个处理
module.load(filename);
return module.exports;
}
MyModule._resolveFilename = function (request) {
return path.resolve(request);
}
MyModule.prototype.load = function (filename) {
// 获取文件后缀名
const extname = path.extname(filename);
// 调用后缀名对应的处理函数来处理,当前实现只支持 JS
MyModule._extensions[extname](this, filename);
this.loaded = true;
}
MyModule._extensions['.js'] = function (module, filename) {
var content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename);
};
MyModule.prototype._compile = function (content, filename) {
var self = this;
// 获取包装后函数体
const wrapper = MyModule.wrap(content);
// vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
// 返回值就是转化后的函数,所以compiledWrapper是一个函数
const compiledWrapper = vm.runInThisContext(wrapper, {
filename
});
const dirname = path.dirname(filename);
const args = [self.exports, self.require, self, filename, dirname];
return compiledWrapper.apply(self.exports, args);
}
const myModuleInstance = new MyModule();
const MyRequire = (id) => {
return myModuleInstance.require(id);
}
module.exports = {
MyModule,
MyRequire
}
用自定义的 MyModule 来加载文件
刚刚我们实现了一个简单的 Module,但是能不能正常用还存疑。是骡子是马拉出来遛遛,我们用自己的 MyModule 来加载文件,看看能不能正常运行。
可以查看 demos/01,代码的入口为 app.js:
const { MyRequire } = require('./myModule.js');
MyRequire('./b.js');
b.js 的代码如下:
const { obj, setVal } = myRequire('./a.js')
console.log(obj);
setVal(101);
console.log(obj);
可以看到现在我们用 myRequire 取代 require 来加载 ./a.js 模块。
再看看 ./a.js 的代码:
let obj = {
val: 1
};
const setVal = (newVal) => {
obj.val = newVal
}
myModule.exports = {
obj,
setVal
}
可以看到现在我们用 myModule 取代 module 来导出模块。
最后执行 node app.js 查看运行结果:
{ val: 1 }
{ val: 101 }
可以看到最终效果和使用原生的 module 模块一致。
用自定义的 MyModule 来测试循环引用
在这之前,我们先看看原生的 module 模块的循环引用会发生什么异常。可以查看 demos/02,代码的入口为 app.js:
require('./a.js')
./a.js 的代码:
const { b, setB } = require('./b.js');
console.log('running a.js');
console.log('b val', b);
console.log('setB to bb');
setB('bb')
let a = 'a';
const setA = (newA) => {
a = newA;
}
module.exports = {
a,
setA
}
再看看 ./b.js 的代码:
const { a, setA } = require('./a.js');
console.log('running b.js');
console.log('a val', a);
console.log('setA to aa');
setA('aa')
let b = 'b';
const setB = (newB) => {
b = newB;
}
module.exports = {
b,
setB
}
可以看到 ./a.js 和 ./b.js 在文件的开头都相互引用了对方。
执行 node app.js 查看运行结果:
running b.js
a val undefined
setA to aa
/Users/xxx/Desktop/esm_commonjs/demos/02/b.js:9
setA('aa')
^
TypeError: setA is not a function
at Object.<anonymous> (/Users/xxx/Desktop/esm_commonjs/demos/02/b.js:9:1)
at xxx
我们会发现一个 TypeError 的异常报错,提示 setA is not a function。这样的异常在预期之内,我们再试试自己实现的 myModule 的异常是否和原生 module 的行为一致。
我们查看 demos/03,这里我们用自己的 myModule 来复现上面的循环引用,代码的入口为 app.js:
const { MyRequire } = require('./myModule.js');
MyRequire('./a.js');
a.js 的代码如下:
const { b, setB } = myRequire('./b.js');
console.log('running a.js');
console.log('b val', b);
console.log('setB to bb');
setB('bb')
let a = 'a';
const setA = (newA) => {
a = newA;
}
myModule.exports = {
a,
setA
}
再看看 ./b.js 的代码:
const { a, setA } = myRequire('./a.js');
console.log('running b.js');
console.log('a val', a);
console.log('setA to aa');
setA('aa')
let b = 'b';
const setB = (newB) => {
b = newB;
}
myModule.exports = {
b,
setB
}
可以看到现在我们用 myRequire 取代了 require,用 myModule 取代了 module。
最后执行 node app.js 查看运行结果:
running b.js
a val undefined
setA to aa
/Users/xxx/Desktop/esm_commonjs/demos/03/b.js:9
setA('aa')
^
TypeError: setA is not a function
at Object.<anonymous> (/Users/xxx/Desktop/esm_commonjs/demos/03/b.js:9:1)
at xxx
可以看到,myModule 的行为和原生 Module 处理循环引用的异常是一致的。
疑问:为什么 CommonJS 相互引用没有产生类似“死锁”的问题?
我们可以发现 CommonJS 模块相互引用时,没有产生类似死锁的问题。关键在 Module._load 函数里,具体源代码在这里。Module._load 函数主要做了下面这些事情:
- 检查缓存,如果缓存存在且已经加载,直接返回缓存,不做下面的处理
- 如果缓存不存在,新建一个 Module 实例
- 将这个 Module 实例放到缓存中
- 通过这个 Module 实例来加载文件
- 返回这个 Module 实例的 exports
其中的关键在 放到缓存中 与 加载文件 的顺序,在我们的 MyModule 中,也就是这两行代码:
MyModule._cache[filename] = module;
module.load(filename);
回到上面循环加载的例子中,解释一下到底发生了什么:
当 app.js 加载 a.js 时,Module 会检查缓存中有没有 a.js,发现没有,于是 new 一个 a.js 模块,并将这个模块放到缓存中,再去加载 a.js 文件本身。
在加载 a.js 文件时,Module 发现第一行是加载 b.js,它会检查缓存中有没有 b.js,发现没有,于是 new 一个 b.js 模块,并将这个模块放到缓存中,再去加载 b.js 文件本身。
在加载 b.js 文件时,Module 发现第一行是加载 a.js,它会检查缓存中有没有 a.js,发现存在,于是 require 函数返回了缓存中的 a.js。
但是其实这个时候 a.js 根本还没有执行完,还没走到 module.exports 那一步,所以 b.js 中 require('./a.js') 返回的只是一个默认的空对象。所以最终会报 setA is not a function 的异常。
说到这里,那如何设计会导致“死锁”呢?其实也很简单 —— 将 放到缓存中 与 加载文件 的执行顺序互换,在我们的 MyModule 代码中,也就是这样写:
module.load(filename);
MyModule._cache[filename] = module;
这样互换一下,再执行 demo03,我们发现异常如下:
RangeError: Maximum call stack size exceeded
at console.value (node:internal/console/constructor:290:13)
at console.log (node:internal/console/constructor:360:26)
我们发现这样写会死锁,最终导致 JS 报栈溢出异常。
总结下 CommonJS 模块输出的是一个值的拷贝 CommonJS 模块是运行时加载
require 文件 核心
读取文件用的fs 模块
MyModule._extensions['.js'] = function (module, filename) {
const content = fs.readFileSync(filename, 'utf8');
module._compile(content, filename);
}
读取后转换为js 用的是 vm沙箱模块
// vm是nodejs的虚拟机沙盒模块,runInThisContext方法可以接受一个字符串并将它转化为一个函数
// 返回值就是转化后的函数,所以compiledWrapper是一个函数
const compiledWrapper = vm.runInThisContext(wrapper, {
filename
});
下期 深入理解ESM模块 敬请期待
参考文档
- 阮一峰:Module 的加载实现
- 深入Node.js的模块加载机制,手写require函数
- commonjs 与 esm 的区别
- The Node.js Way - How
require()Actually Works - stackoverflow:How does require() in node.js work?
- Node模块加载机制:展示了一些魔改 require 的场景
- docs: ES 模块和 CommonJS 之间的差异
- Requiring modules in Node.js: Everything you need to know
- JavaScript Execution Context and Hoisting Explained with Code Examples
- 深入了解JavaScript执行过程(JS系列之一)
- JS执行过程详解
- 7 Types of Native Errors in JavaScript You Should Know
搬运 微信文章链接 mp.weixin.qq.com/s/cBqbsHGhE…