前言
本篇文章不会介绍模块的详细用法,因为核心是重新认识和理解模块的本质内容是什么,直奔主题,下面先给出最后结论,接下来在逐个进行分析。
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 对象。
详细信息可以观察以下示例代码和输出结果:
js
// 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
查看当前模块的缓存内容
仍然通过示例代码和输出结果来观察:
js
// 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 模块输出的是值的拷贝,即一旦输出一个值,模块内部的变化就影响不到这个值。
示例代码和输出结果如下:
js
// 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 官方文档 循环部分相关的例子来进行演示:
js
// 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
关键字进行了声明。
js
// a.mjs
let count = 0
export {
count
}
// index.mjs
import {count, add} from './a.mjs'
console.log('count = ', count)
count = 1
console.log('count = ', count)
模块缓存
ECMAScript 模块 没有使用 CommonJS 模块的 require.cache
缓存方式,因为 ECMAScript 模块加载器有自己独立的缓存。
代码示例和输出结果如下:
js
// a.mjs
console.log('load a.mjs')
// index.mjs
import './a.mjs'
import './a.mjs'
输出的是值的引用
ECMAScript 模块输出的是值的引用,即 ECMAScript 模块是 动态引用,并且不会缓存值,模块里面的变量绑定其所在的模块。
示例代码和输出结果如下:
js
// 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 = 0
let add = () => {
count++
console.log('add call in a.mjs,count = ', count)
}
export {
count,
add
}
模块的循环加载
ECMAScript 模块处理 "循环加载" 与 CommonJS 模块有本质的不同。
ES6 模块 是动态引用,如果使用 import
从一个模块加载变量(即import foo from 'foo'
),那些变量不会被缓存,而是成为一个 指向被加载模块的引用,需要开发者自己保证,真正取值的时候能够取到值。
示例代码和输出结果如下:
js
// 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
命令,导致无法被同步加载。
示例代码和输出结果如下:
js
// 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 模块,但是 只能整体加载 ,不能只加载单一的输出项。
示例代码和输出结果如下:
js
// 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 格式按需要输出具名接口
jsimport cjsModule from '../index.js'; // CommonJS 格式 export const foo = cjsModule.foo; // ECMAScript 格式
-
另一种做法是通过在
package.json
文件中的exports
字段,指明两种格式 模块各自的 加载入口 ,下面代码指定require()
和import
,加载该模块时会自动切换到不同的入口文件js"exports":{ "require": "./index.js", "import": "./esm/wrapper.js" }