commonjs 与 esm 的区别

12,344 阅读6分钟

js 社区存在多种模块化规范,其中最常使用到的是 node 本身实现的 commonjs 和 es6 标准的 esm。

commonjs 和 esm 存在多种根本上的区别,详细的比较在阮一峰的《es6标准入门》已经写得很详细了,这里我想用自己的思路重新总结一下。同时分析一下 babel 对于 esm 的编译转换,存在的局限。

commonjs 和 esm 的主要区别可以概括成以下几点:

  1. 输出拷贝 vs 输出引用
  2. esm 的 import read-only 特性
  3. esm 存在 export/import 提升

下面对这三点做具体分析。

输出拷贝 vs 输出引用

首先看个 commonjs 输出拷贝的例子:

// a.js
let a = 1;
let b = { num: 1 }
setTimeout(() => {
    a = 2;
    b = { num: 2 };
}, 200);
module.exports = {
    a,
    b,
};

// main.js
// node main.js
let {a, b} = require('./a');
console.log(a);  // 1
console.log(b);  // { num: 1 }
setTimeout(() => {
    console.log(a);  // 1
    console.log(b);  // { num: 1 }
}, 500);

所谓输出拷贝,如果了解过 node 或者 webpack 对 commonjs 的实现(不了解可以看我之前的文章),就会知道:exports 对象是模块内外的唯一关联, commonjs 输出的内容,就是 exports 对象的属性,模块运行结束,属性就确定了。

再看 esm 输出引用的例子:

// a.mjs
let a = 1;
let b = { num: 1 }
setTimeout(() => {
    a = 2;
    b = { num: 2 };
}, 200);
export {
    a,
    b,
};

// main.mjs
// node --experimental-modules main.mjs
import {a, b} from './a';
console.log(a);  // 1
console.log(b);  // { num: 1 }
setTimeout(() => {
    console.log(a);  // 2
    console.log(b);  // { num: 2 }
}, 500);

这就是 esm 输出引用跟 commonjs 输出值的区别,模块内部引用的变化,会反应在外部,这是 esm 的规范。

esm 的 import read-only 特性

read-only 的特性很好理解,import 的属性是只读的,不能赋值,类似于 const 的特性,这里就不举例解释了。

esm 存在 export/import 提升

esm 对于 import/export 存在提升的特性,具体表现是规范规定 import/export 必须位于模块顶级,不能位于作用域内;其次对于模块内的 import/export 会提升到模块顶部,这是在编译阶段完成的。

esm 的 import/export 提升在正常情况下,使用起来跟 commonjs 没有区别,因为一般情况下,我们在引入模块的时候,都会在模块的同步代码执行完才获取到输出值。所以即使存在提升,也无法感知。

所以要想验证这个事实,需要考虑到循环依赖的情况。循环依赖指的是模块A依赖模块B,模块B又依赖模块A,互相依赖产生了死循环。所以各个模块方案本身设计了一套规则来解决这个问题。在循环依赖的情况下,模块会出现执行中断,然后我们可以看到 import/export 提升和 commonjs 的区别。

这里用2个循环依赖的例子来解释,首先看 commonjs 的表现:

// a.js
exports.done = false;
let b = require('./b');
console.log('a.js: b.done = %j', b.done);  // true
exports.done = true;
console.log('a.js执行完毕');

// b.js
exports.done = false;
let a = require('./a');
console.log('b.js: a.done = %j', a.done);  // false
exports.done = true;
console.log('b.js执行完毕');

// main.js
let a = require('./a');
let b = require('./b');
console.log('main.js: a.done = %j, b.done = %j', a.done, b.done);  // true true

// 输出结果
// node main.js
b.js: a.done = false
b.js执行完毕
a.js: b.done = true
a.js执行完毕
main.js: a.done = true, b.done = true

这是《es6入门》里的循环依赖的例子,这个例子能提现 commonjs 运行时加载的情况。因为 a.js 依赖 b.js,b.js 又依赖 a.js,所以当 b.js 执行到require('./a')的时候,a.js 会暂停执行,所以此时require('./a')返回的是false,但是在main.js中,a.js的返回值又是true,所以这说明了 commonjs 模块的 exports 是动态执行的,具体 require 能获取到的值,取决于模块的运行情况。

下面是 esm 的循环依赖的例子:

// a.mjs
export let a_done = false;
import { b_done } from './b';
console.log('a.js: b.done = %j', b_done);
console.log('a.js执行完毕');

// b.mjs
import { a_done } from './a';
console.log('b.js: a.done = %j', a_done);
export let b_done = true;
console.log('b.js执行完毕');

// main.mjs
import { a_done } from './a';
import { b_done } from './b';
console.log('main.js: a.done = %j, b.done = %j', a_done, b_done);

// 输出结果
// node --experimental-modules main.mjs
ReferenceError: a_done is not defined

这里解释一下,为什么a_done is not defined。a.mjs 加载 b.mjs,而 b.mjs 又加载 a.mjs,这就形成了循环依赖。循环依赖产生时,a.mjs 中断执行,这时在 b.mjs 中a_done的值是什么呢?这就要考虑到 a.mjs 的 import/export 提升的问题,a.mjs 中的export a_done被提升到顶部,执行到import './b'时,执行权限移交到 b.mjs,此时a_done只是一个指定导出的接口,但是未定义,所以出现引用报错。

这里先提一下,如果用 babel 来编译执行,是不会报错的,执行结果如下:

// npx babel-node src/main.mjs
b.js: a.done = undefined
b.js执行完毕
a.js: b.done = true
a.js执行完毕
main.js: a.done = false, b.done = true

为什么呢?后面会来分析。

bebel 模拟 esm

这一节来看看,babel 是怎么实现 esm 这几个特性的:输出引用、read-only。

还是上面的例子,稍微改一下:

// a.js
let a = 1;
let b = { num: 1 }
setTimeout(() => {
    a = 2;
    b = { num: 2 };
}, 200);
export {
    a,
    b,
};

// main.js
import {a, b} from './a';
console.log(a);
console.log(b);
setTimeout(() => {
    console.log(a);
    console.log(b);
}, 500);

a = 3;

用babel编译一下,生成了如下的内容:

// a.js
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.b = exports.a = void 0;
var a = 1;
exports.a = a;
var b = {
  num: 1
};
exports.b = b;
setTimeout(function () {
  exports.a = a = 2;
  exports.b = b = {
    num: 2
  };
}, 200);

// main.js
"use strict";
var _a = require("./a");
console.log(_a.a);
console.log(_a.b);
setTimeout(function () {
  console.log(_a.a);
  console.log(_a.b);
}, 500);
_a.a = (3, function () {
  throw new Error('"' + "a" + '" is read-only.');
}());

简单分析一下,对于输出引用,babel 是通过在输出属性变化时,同步修改 exports 对象对应的属性来实现的,比如像这样的代码:

exports.a = a = 2;

另外一个特性 read-only,babel 通过抛异常的方式来实现,比如这样的代码:

_a.a = (3, function () {
  throw new Error('"' + "a" + '" is read-only.');
}());

bebel 模拟 esm 的局限

前面关于 esm 的 import/export 提升的例子,在 node 原生 esm 环境下和babel 编译环境下的执行结果不一致,这是什么原因呢?我们把前面的例子用 babel 编译一下,看看转换成什么形式的代码。

首先还是贴一下 esm 代码:

// a.mjs
export let a_done = false;
import { b_done } from './b';
console.log('a.js: b.done = %j', b_done);
console.log('a.js执行完毕');

// b.mjs
import { a_done } from './a';
console.log('b.js: a.done = %j', a_done);
export let b_done = true;
console.log('b.js执行完毕');

// main.mjs
import { a_done } from './a';
import { b_done } from './b';
console.log('main.js: a.done = %j, b.done = %j', a_done, b_done);

用 babel 编译一下,生成了如下的内容:

// a.js
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.a_done = void 0;
var _b = require("./b");
var a_done = false;
exports.a_done = a_done;
console.log('a.js: b.done=%j', _b.b_done);
console.log('a.js执行完毕');

// b.js
"use strict";
Object.defineProperty(exports, "__esModule", {
  value: true
});
exports.b_done = void 0;
var _a = require("./a");
console.log('b.js: a.done=%j', _a.a_done);
var b_done = true;
exports.b_done = b_done;
console.log('b.js执行完毕');

// main.js
"use strict";
var _a = require("./a");
var _b = require("./b");
console.log('main.js: a.done=%j, b.done=%j', _a.a_done, _b.b_done);

可以看到,babel 也实现了 export 的提升,输出值统一设置为void 0,但是想象一下,a_done其实是 export 对象的属相,那么在 commonjs 的环境下,从对象取值,只可能会出现undefined,而不可能出现is not defined

其实根本原因也是源于 commonjs 输出的是对象,而 esm 输出的是引用,babel 本质是利用 commonjs 来模拟 esm,所以这个特性也是 babel 无法模拟实现的。

结论

本文主要总结了 commonjs 跟 esm 的主要对比,并且分析了 babel 模拟 esm 的方式和局限。

文章主要是个人的理解和总结,如有错误欢迎指正。