自从 Node.js 在 14 和 12 版本支持了 ECMAScript Module,越来越多的包开始支持 ESM。ESM 并不是什么新东西,它已经是 JavaScript 官方标准的包规范了。而因为历史原因 Node.js 之前都使用 CommonJS,而随着支持 ESM 的包越来越多,个人觉得 ESM 之后会成为 Node.js 的主流包规范。

这个文章主要举例了一些代码片段,帮助理解 ESM 和 CJS 的差异。其中不少内容都来自于 Mozilla 的这篇介绍 ESM 的文章,推荐阅读~

这个文章讲解了 ESM 和 CJS 是如何加载运行代码的,了解两者的加载运行机制是搞清楚两者在使用层面差异的好方法。

CJS

CJS 的加载时机是执行到 require 时,每个 require 依次同步执行。在一个文件第一次加载完成后,module 加载结果会被缓存,再次 require 时不会重新加载/执行目标文件/代码。

// ./sayHi.cjs
'use strict';
console.log('module sayHi start loading');

module.exports.sayHi = function() {
  console.log('hi everyone');
}

console.log('module sayHi is loaded');
// index.js
'use strict';
console.log('index.js starts');

// 引用一个不存在的方法
const { hi } = require('./sayHi.cjs');
// 触发错误
hi();

console.log('index.js is running');

举个例子,sayHi.cjs对外输出了 sayHi 方法,index.cjs 方法引入但使用了错误的方法。执行 index.cjs 可以得到如下输出:

# node index.js
index.js starts
module sayHi start loading
module sayHi is loaded
~/workspace/playground/cjs-test1/index.cjs:7
hi();
^

TypeError: hi is not a function
at Object.<anonymous> (~/workspace/playground/cjs-test1/index.cjs:7:1)
...
...

ESM

ESM 加载分为三步:Construction、Instantiation、Evaluation

  • Construction 阶段会将所有的 import 涉及到的 module 都进行加载,形成 Module Record 存储起来
  • Instantiation 阶段会将所有 export 的对象在内存中分配位置,并将各个 module 的 import 和 export 连接起来
  • Evaluation 阶段将运行代码得到 export 的具体值

这三个阶段被设计成可异步执行,重要的原因是因为在浏览器环境下下载文件会很慢,如果是 CJS 这种同步加载的机制会阻塞主进程,导致网页没有响应、整体加载缓慢。

我们用上面同样的例子,使用 ESM 实现,会发现在执行任何代码之前程序就已经抛出了错误,sayHi.mjs 中的 console.log 不会被执行。

// ./sayHi.mjs
console.log('module sayHi start loading');

export function sayHi () {
  console.log('hi everyone');
}

console.log('module sayHi is loaded');

// index.mjs
// 引入不存在的方法
import { hi } from './sayHi.mjs'

console.log('index.js starts');

hi();

console.log('index.js is running');
# node index.mjs
file:///~/Documents/workspace/playground/cjs-test1/index.mjs:2
import { hi } from './sayHi.mjs'
         ^^
SyntaxError: The requested module './sayHi.mjs' does not provide an export named 'hi'
    at ModuleJob._instantiate (node:internal/modules/esm/module_job:123:21)

ESM 详细的加载过程在 Mozilla 的文章中说的很清楚,这里也没有必要再翻译重新写一次。后面就针对几个点用例子展开看下 CJS 和 ESM 的区别。分别是:

  • 循环依赖
  • 引用 vs. 复制
  • 包的安全

    关于循环依赖

    CJS 循环依赖使用 Node.js 官网的例子

    // 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');
    
    // 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);
    

    这个例子里,

  1. main 引用了 a,此时a.donefalse
  2. a 引用 b 后,b 又引用了 a。为了防止无限循环,一个未完全执行的结果被返回给 b,得到 a.done = false
  3. b 执行完毕之后,a 取到b.done为 true,修改自己的a.donetrue
  4. 最终 main 打印两个 module 的done均为true

输出结果:

# node main.js
main starting
a starting
b starting
in b, a.done = false
b done
in a, b.done = true
a done
in main, a.done = true, b.done = true

同样的例子在 ESM 中是行不通的,会获得ReferenceError: Cannot access 'done' before initialization的错误。因为 module b 在运行时 module a 的done还没有被赋值。

但是我们修改 export 的内容为函数,就可以执行。这个例子里,module a 输出了foo方法,module b 输出了bar方法,两者互相引用。这是因为为了方便 Evaluation 阶段的处理,函数方法的初始化赋值会在 Instantiation 阶段就会完成。

// a.mjs
import { bar } from './b.mjs';
console.log('a.mjs')
console.log(bar());
function foo() { return 'foo' }
export { foo };
//b.mjs
import { foo } from './a.mjs';
console.log('b.mjs');
console.log(foo());
function bar() { return 'bar' }
export { bar };
// main.mjs
console.log('main starting');
import * as a from './a.mjs';
import * as b from './b.mjs';
# node main.mjs
b.mjs
foo
a.mjs
bar
main starting

关于引用与复制

另外一个比较重要的差异点是 CJS 输出的对象是复制,而 ESM 输出的对象是引用。

举个例子,假设有三个文件,module a 输出了一个数字和自增的方法。module b 和 module c 引用进来打印并调用一次自增函数。

在 CJS 中,因为每个 export 的对象是复制,所以 module b 和 c 的输出值都是1inc之后也不会增加。

// a.cjs
let a = 1;
function inc() {
  a++;
  console.log('module a, after inc a=%s', a)
}

module.exports = {
  a,
  inc
}
// b.cjs
const { a, inc } = require('./a.cjs')

console.log('module b, a=%s', a)
inc();
console.log('module b, after inc, a=%s', a)
// c.cjs
const { a, inc } = require('./a.cjs')

console.log('module c, a=%s', a)
inc();
console.log('module c, after inc, a=%s', a)
# require('./b.cjs')
# require('./c.cjs')

module b, a=1
module a, after inc a=2
module b, after inc, a=1
module c, a=1
module a, after inc a=3
module c, after inc, a=1

同样的逻辑,在 ESM 中,module a 输出的是对象的引用,module b 和 c 都是使用同一份对象,所以inca的值都是同时增加。

// a.mjs
export let a = 1;
export function inc() {
  a++;
  console.log('module a, after inc a=%s', a)
}
// b.mjs
import { a, inc } from './a.mjs'

console.log('module b, a=%s', a)
inc();
console.log('module b, after inc, a=%s', a)
//c.mjs
import { a, inc } from './a.mjs'

console.log('module c, a=%s', a)
inc();
console.log('module c, after inc, a=%s', a)
# import './b.mjs'
# import './c.mjs'

module b, a=1
module a, after inc a=2
module b, after inc, a=2
module c, a=2
module a, after inc a=3
module c, after inc, a=3

一个小的注意点是 CJS 如果 export 的 a 不是数字而是 object,那多份 export 复制的对象 a 还是指向同一个 object。输出的结果和 ESM 是一样的。

// a.cjs
let a = { v: 1 };
function inc() {
  a.v++;
  console.log('module a, after inc a=%s', a.v)
}

module.exports = {
  a,
  inc
}
// b.cjs
const { a, inc } = require('./a.cjs')

console.log('module b, a=%s', a.v)
inc();
console.log('module b, after inc, a=%s', a.v)
// c.cjs
const { a, inc } = require('./a.cjs')

console.log('module c, a=%s', a.v)
inc();
console.log('module c, after inc, a=%s', a.v)
# require('./b.cjs')
# require('./c.cjs')

module b, a=1
module a, after inc a=2
module b, after inc, a=2
module c, a=2
module a, after inc a=3
module c, after inc, a=3

关于安全

CJS 被诟病比较多的一点是输出的对象可以被任意的使用方篡改。在大型应用里,依赖的三方包非常多,大多数时候我们都相信这些包是安全的,但是其实并不是👻

看个和上面类似的例子,

  1. module a 输出了数字a1,module b 在引入后,对 module a export 对象中的a直接进行了修改
  2. 因为 CJS module 对象 export 的结果是缓存起来的对象(详见官方文档),在 module c 引入 module a 的时候,直接复制了被修改过的 export 结果。导致 module c 拿到的结果是boom而不再是数字1
// a.cjs
let a = 1;

module.exports = {
  a
}
// b.cjs
const moduleA = require('./a.cjs')

console.log('module b, a=%s', moduleA.a)
moduleA.a = 'boom'
console.log('module b, after edit, a=%s', moduleA.a)
// c.cjs
const { a } = require('./a.cjs')

console.log('module c, a=%s', a)
# require('./b.cjs')
# require('./c.cjs')

module b, a=1
module b, after edit, a=boom
module c, a=boom

而在 ESM 中,规范通过将 export 的对象设置为只读的方式禁止了模块外的使用者修改模块的输出结果。

同样的例子在运行到moduleA.a = 'boom'时,报错终止了程序。

// a.mjs
export let a = 1;
// b.mjs
import * as moduleA from './a.mjs'

console.log('module b, a=%s', moduleA.a)
moduleA.a = 'boom'
console.log('module b, after edit, a=%s', moduleA.a)
// c.mjs
import { a } from './a.mjs'

console.log('module c, a=%s', a)
# import './b.mjs'
# import './c.mjs'

module b, a=1
file:///~/Documents/workspace/playground/cjs-test1/b.mjs:34
moduleA.a = 'boom'
          ^

TypeError: Cannot assign to read only property 'a' of object '[object Module]'

但如果 a 是对象,直接修改 a 对象内的值仍然是可以成功的。和之前的比较类似,不再举例子了。

其他参考

https://blog.logrocket.com/commonjs-vs-es-modules-node-js/

https://webreflection.medium.com/cjs-vs-esm-5f8b90a4511a