前言
本篇文章不会介绍模块的详细用法,因为核心是重新认识和理解模块的本质内容是什么,直奔主题,下面先给出最后结论,接下来在逐个进行分析。
ECMAScript Module 和 CommonJS 的相同点:
- 都拥有自己的缓存机制,即 多次加载 同一个模块,该模块内容 只会执行一次
CommonJS
模块内容执行完成后,会生成 Module 对象,同时这个对象会被缓存到require.cache
对象中ECMAScript
模块拥有自己的缓存机制,并使得模块中的变量和该模块进行锁定,保证外部模块可以访问内部变量的最新值
- 可对于输出的接口进行修改
ECMAScript
模块输出的是一个只读引用,相当于通过const
进行声明,意味着不能修改输出接口的引用,但可以修改引用中的内容CommonJS
模块默认没有上述的限制,但一般接收模块输出接口时大多都会使用const
进行声明,此时它们的表现将一致,但如果使用类似let a = require('./a.js')
的方式加载模块,那么对变量a
的引用可以随意更改
ECMAScript Module 和 CommonJS 的差异:
- 加载时机不同
ECMAScript
模块是 编译时输出接口CommonJS
模块是 运行时加载
- 加载方式不同
ECMAScript
模块的import
命令是 异步加载,有一个独立的模块依赖的解析阶段CommonJS
模块的require()
是 同步加载模块
- 输出结果不同
ECMAScript
模块输出的是 值的引用CommonJS
模块输出的是一个 值的浅拷贝
- 缓存方式不同
CommonJS
模块通过require.cache
来对值进行缓存ECMAScript
模块拥有自己的缓存机制
- 处理循环加载的方式不同
CommonJS
模块发生 循环加载 时,只输出已经执行部分,未执行部分不会输出ECMAScript
模块发生 循环加载 时,默认 循环加载 模块内部已经执行完毕,对输出接口是否能使用成功需要开发者自己保证
接下来,先简单了解下 Node.js 的模块加载方法是什么?
Node.js 的模块加载方法
Node.js 有两个模块系统:
CommonJS 模块
,简称 CJSECMAScript 模块
,即 ES6 模块,简称 ESM
CommonJS 模块
CommonJS 模块是为 Node.js 打包 JavaScript 代码的原始方式,模块使用require()
和module.exports
语句定义。
默认情况下,Node.js 会将以下内容视为 CommonJS 模块:
- 扩展名为
.cjs
的文件 - 当最近的父
package.json
文件中 包含 顶层字段"type: "commonjs"
或 不包含 顶层字段"type"
时,则应用于扩展名为.js
的文件 - 当最近的父
package.json
文件包包含顶层字段"type": "module"
时,对于扩展名不是.mjs
、.cjs
、.json
、.node
、或.js
的文件,只有当它们通过require
被加载时才会被认为是 CommonJS 模块,且不是用作程序的命令行入口点
加载原理
CommonJS 的一个模块,就是一个脚本文件,require
命令 第一次加载 脚本时,会 执行整个脚本,然后在内存中 生成一个 Module 对象。
详细信息可以观察以下示例代码和输出结果:
// a.js
var name = "name in a.js"
module.exports = {name
}
console.log("module in a.js")
console.log(module)// index.js
const a = require("./a.js")
console.log('module a in index.js', a)
在终端通过 node index.js
执行后,得到结果如下:
在上图中,该对象的 id
属性是模块名,exports
属性是模块输出的各个接口,loaded
属性是一个布尔值,表示该模块的脚本是否执行完毕,children
属性是当前模块依赖的其他模块集合,其他略过。
模块缓存
CommonJS 模块无论加载多少次,都只会在 第一次加载时运行一次,并生成上面的 Module 对象,以后再加载相同模块,就返回第一次运行的结果,即 Module 对象,除非手动清除系统缓存。
可以通过输出
require.cache
查看当前模块的缓存内容
仍然通过示例代码和输出结果来观察:
// a.js
const a1 = require("./a.js")
console.log('first load a.js', a1)const a2 = require("./a.js")
console.log('second load a.js', a2)console.log('a1 === a2 =>', a1 === a2)// index.js
var name = "name in a.js"
console.log("loading a.js")
module.exports = {name
}
通过上图可以看出,多次加载同一个模块,模块内容只会执行一次,而且得到都是第一次生成的 Module 对象,其中包含了模块输出的各个接口。
输出的是值的拷贝
CommonJS 模块输出的是值的拷贝,即一旦输出一个值,模块内部的变化就影响不到这个值。
示例代码和输出结果如下:
// index.js
const a = require('./a.js');
console.log('before add in index.js,a.count = ', a.count);
a.add();
console.log('after add in index.js,a.count = ', a.count);// a.js
let count = 0;function add() {count++;console.log('add call in a.js,count = ', count);
}module.exports = {count,add
}
模块的循环加载
CommonJS 模块的重要特性是 加载时执行,即脚本代码在进行 require
时,就会全部执行。一旦出现某个模块被 “循环加载”,只输出已经执行部分,未执行部分不会输出。
下面通过 Node 官方文档 循环部分相关的例子来进行演示:
// main.js
console.log('【【【 main starting 】】】');
const a = require('./a.js');
const b = require('./b.js');
console.log('in main, a.done = %j, b.done = %j', a.done, b.done);
console.log('【【【 main done 】】】');// a.js
console.log('==== a starting ====');
exports.done = false;
const b = require('./b.js');
console.log('in a, b.done = %j', b.done);
exports.done = true;
console.log('==== a done ====');// b.js
console.log('<<<< b starting >>>>');
exports.done = false;
const a = require('./a.js');
console.log('in b, a.done = %j', a.done);
exports.done = true;
console.log('<<<< b done >>>>');
其中,a.js
模块和 b.js
模块会相互加载,此时就会产生 “循环加载”,输出结果如:
核心步骤分析如下:
main.js
先执行到const a = require('./a.js');
时,进入a
模块并执行a.js
中第二行为模块添加了done
属性,即exports.done = false;
,接着执行const b = require('./b.js');
时,进入b
模块并执行b.js
中第二行为模块添加了done
属性,即exports.done = false;
,接着执行const a = require('./a.js');
此时 发生循环,因此回到a
模块中,但此时发现a
模块以及执行过了,因此直接使用是上次的缓存 Module 对象,此时b
模块中访问a.done
就是false
,因为a
模块中没有执行完,即 只输出已经执行部分b
模块执行到exports.done = false;
处,核心步骤已完成并输出,会返回a
模块中把 未执行完的部分继续执行完成,此时exports.done = false;
main.js
后执行到const b = require('./b.js');
时,发现b
模块已经执行过了,于是在这拿到的就是第一次执行缓存的 Module 对象,接着在main.js
访问a.done
和b.done
的值就都是true
ECMAScript 模块
ECMAScript 模块 是来打包 JavaScript 代码以供重用的 官方标准格式,模块使用 import
和 export
语句定义。
从 Node.js v13.2 版本开始,Node.js 默认打开了对 ECMAScript 模块 的支持
加载原理
ECMAScript 模块的运行机制与 CommonJS 不一样,JS 引擎 在对脚本进行 静态分析 时,只要遇到模块加载命令 import
,就会生成一个 只读引用,等到脚本 真正执行 时,再根据这个 只读引用,去被加载的模块中 取值。
ECMAScript 模块是 静态分析 阶段生成的 只读引用,因此不好演示具体示例,但可通过下面的例子来验证 只读引用,即相当于通过 const
关键字进行了声明。
// a.mjs
let count = 0export {count
}// index.mjs
import {count, add} from './a.mjs'console.log('count = ', count)
count = 1
console.log('count = ', count)
模块缓存
ECMAScript 模块 没有使用 CommonJS 模块的 require.cache
缓存方式,因为 ECMAScript 模块加载器有自己独立的缓存。
代码示例和输出结果如下:
// a.mjs
console.log('load a.mjs')// index.mjs
import './a.mjs'
import './a.mjs'
输出的是值的引用
ECMAScript 模块输出的是值的引用,即 ECMAScript 模块是 动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
示例代码和输出结果如下:
// a.mjs
import {count, add} from './a.mjs'console.log('before add,count = ', count)
add()
console.log('after add,count = ', count)// index.mjs
let count = 0let add = () => {count++console.log('add call in a.mjs,count = ', count)
}export {count,add
}
模块的循环加载
ECMAScript 模块处理 “循环加载” 与 CommonJS 模块有本质的不同。
ES6 模块 是动态引用,如果使用 import
从一个模块加载变量(即import foo from 'foo'
),那些变量不会被缓存,而是成为一个 指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
示例代码和输出结果如下:
// index.mjs
import './a.mjs'// a.mjs
import {bar} from './b.mjs';
console.log('a.mjs');
console.log(bar);
export let foo = 'foo';// b.mjs
import {foo} from './a.mjs';
console.log('b.mjs');
console.log(foo);
export let bar = 'bar';
详细步骤分析如下:
- 在
index.mjs
中通过import './a.mjs'
执行a
模块 - 进入
a.mjs
模块并开始执行,引擎发现它加载b.mjs
,因此会优先执行b.mjs
- 进入
b.mjs
模块并开始执行,引擎发现b
又需要加载a.mjs
,并接收了a
模块中输出的foo
接口,但此时并不会去执行a.mjs
,而是认为这个接口已经存在了,于是继续往下执行,当执行到console.log(foo)
处,才发现这个接口根本没定义,因此会产生错误 - 如果
b.mjs
中没有发生异常,那么在执行完b
模块后,会再返回去执行a.mjs
循环加载报错的解决方案
本质原因就是发生 “循环加载” 时,ECMAScript 模块会默认循环模块内容已经执行完成,但是实际是没有执行完成,导致在引用循环模块中的接口时,报错本质上也可以认为是 ES6 中的 暂时性死区 引发的报错。
因此,我们可以通过将对应的 export let foo = 'foo';
的声明方式换为:
var
的声明方式,如:export var foo = 'foo';
,- 或将
foo
变量换成 函数声明,如export function foo(){ return 'bar'};
就可以解决问题,因为它们都具有 “变量提升”,因此,即便 a
模块没有被执行完,也可以在 b
模块中正常进行访问,但是要注意使用场景。
不同模块的相互加载
CommonJS 模块加载 ECMAScript 模块
CommonJS 的 require()
命令不能加载 ECMAScript 模块,这会产生报错,因此只能使用 import()
这个方法加载。
require()
不支持 ECMAScript 模块的一个原因是,require()
是同步加载,而 ECMAScript 模块内部可以使用顶层 await
命令,导致无法被同步加载。
示例代码和输出结果如下:
// a.mjs
let name = 'a.mjs'
export default name// index.js
(async () => {let a = await import('./a.mjs');console.log(a);
})();
ECMAScript 模块加载 CommonJS 模块
ECMAScript 模块的 import
命令可以加载 CommonJS 模块,但是 只能整体加载,不能只加载单一的输出项。
示例代码和输出结果如下:
// a.js
let name = 'a.js'module.exports = {name
}// index.mjs
import a from './a.js'
console.log(a)
这是因为 ECMAScript 模块需要支持 静态代码分析,而 CommonJS 模块的输出接口的 module.exports
是一个对象,无法被静态分析,所以只能整体加载。
同时支持两种格式的模块
一个模块同时要支持 CommonJS 和 ECMAScript 两种格式,那么需要进行判断:
- 如果原始模块是 ECMAScript 格式,那么需要给出一个整体输出接口,比如
export default obj
,使得 CommonJS 可以用import()
进行加载 - 如果原始模块是 CommonJS 格式,那么可以加一个包装层,即先整体输入 CommonJS 模块,然后再根据 ECMAScript 格式按需要输出具名接口
import cjsModule from '../index.js'; // CommonJS 格式 export const foo = cjsModule.foo; // ECMAScript 格式
- 另一种做法是通过在
package.json
文件中的exports
字段,指明两种格式 模块各自的 加载入口,下面代码指定require()
和import
,加载该模块时会自动切换到不同的入口文件"exports":{"require": "./index.js","import": "./esm/wrapper.js" }
参考资源
- Module 的加载实现 - 阮一峰
- Node.js 官方文档 —— CommonJS 模块
- Node.js 官方文档 —— ECMAScript 模块