js 社区存在多种模块化规范,其中最常使用到的是 node 本身实现的 commonjs 和 es6 标准的 esm。
commonjs 和 esm 存在多种根本上的区别,详细的比较在阮一峰的《es6标准入门》已经写得很详细了,这里我想用自己的思路重新总结一下。同时分析一下 babel 对于 esm 的编译转换,存在的局限。
commonjs 和 esm 的主要区别可以概括成以下几点:
- 输出拷贝 vs 输出引用
- esm 的 import read-only 特性
- 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 的方式和局限。
文章主要是个人的理解和总结,如有错误欢迎指正。