每日一报
esbuild 的一些考虑
为什么 esbuild 转译 commonjs 模块为 esm 模块时,均使用 default 导出
举一个例子,如果转译以下 commonjs 模块:
const obj = {};
const random = Math.floor(Math.random() * 10) + 1;
for (let i = 0; i <= random; i++) {
obj[i] = Math.floor(Math.random() * 10) + 2;
}
module.exports = obj;esbuild 转译后的结果如下:
var __getOwnPropNames = Object.getOwnPropertyNames;
var __commonJS = (cb, mod) =>
function __require() {
return (
mod ||
(0, cb[__getOwnPropNames(cb)[0]])(
(mod = { exports: {} }).exports,
mod
),
mod.exports
);
};
// node_modules/demo/index.js
var require_demo = __commonJS({
'node_modules/demo/index.js'(exports, module) {
var obj = {};
var random = Math.floor(Math.random() * 10) + 1;
for (let i = 0; i <= random; i++) {
obj[i] = Math.floor(Math.random() * 10) + 2;
}
module.exports = obj;
}
});
export default require_demo();经过多次测试可以发现 esbuild 转译的 commonjs 模块为 esm 模块时,均以 default 做为导出方式。
为什么 esbuild 会这么做?
本质上的原因是 esbuild 将 commonjs 作为一等公民。esbuild 转译 commonjs 为 esm 的时候是采用与 nodejs 类似的 运行时加载方式,并没有对 commonjs 模块进行静态分析。换句话说 esbuild 并不知道 commonjs 模块具体导出了哪些引用,也就是无法采用具名导出的方式交付给 引用方(importer)。
例子
const obj = {};
const random = Math.floor(Math.random() * 10) + 1;
for (let i = 0; i <= random; i++) {
obj[i] = Math.floor(Math.random() * 10) + 2;
}
for (const key in obj) {
// bad case
export key = obj[key];
}你不可能在 非运行时 阶段确切了解到 obj 对象具体包含了哪些引用,那么就不可能通过 具名导出 的方式处理 obj 对象中的每一个属性,也就是说上述的 export key = obj[key] 语句是错误的,是运行时的方案,不符合 export 语句静态的特性。
在 ESM 中,export 语句必须是静态的。这意味着所有的导出(无论是 命名导出 还是 默认导出)都必须在模块 非运行时 阶段就可以明确知道哪些引用需要导出。像上述例子中的 obj 属性具有不确定性,但 obj 引用对象是确定的,因此可以像下述例子中一样通过导出具有确定性的引用(上述中即为 obj)对象。至于 obj 对象中的属性,那么在 运行时 阶段可以得知,这样既确保了 export 语句的 静态特性 同时又维护了 commonjs 模块的 高度动态性。
const obj = {};
// ... do obj something in runtime
obj.a = 1;
obj.b = 2;
export { obj };
export default obj;至此已经介绍了针对运行时的属性在静态分析需要如何处理导出,即通过引用对象的方式导出(具名导出或默认导出)。由于 commonjs 自身并没有所谓的具名导出,因此 esbuild 会统一将 commonjs 模块的导出处理为 默认导出。这也就是我们所看到的 esbuild 在 commonjs 的转译产物(esm)中的最底部均会使用 export default 语句。
var require_demo = __commonJS({
'node_modules/demo/index.js'(exports, module) {
var obj = {};
var random = Math.floor(Math.random() * 10) + 1;
for (let i = 0; i <= random; i++) {
obj[i] = Math.floor(Math.random() * 10) + 2;
}
module.exports = obj;
},
});
export default require_demo();默认导出的 引用 是确定的,属性是运行时加载确认的。解释完了 esbuild 为什么在转译 commonjs 模块为 esm 模块时,均使用 default 导出。那么上述提到的一个观点。
esbuild转译commonjs为esm的时候是采用与Node类似的运行时加载方式。
可能熟悉 rollup 的处理 commonjs 模块的方案可以知道,rollup 把 esm 作为一等公民。也就是说遇到任何非 esm 模块时,rollup 都会要求提供插件来转译模块为 esm 模块。
针对 commonjs 模块的转译,rollup 会通过 @rollup/plugin-commonjs 插件来处理 commonjs 模块,将 commonjs 模块转译为 esm 模块。
那么问题来了:
@rollup/plugin-commonjs 转译 commonjs 模块的行为与 esbuild 转译 commonjs 模块的行为是否会有所不同呢?
从 default strict requires to true 文章中可以知道。@rollup/plugin-commonjs 在先前的版本中(strictRequires = false | auto)中会采用静态分析的方式尽可能将 commonjs 转译为具有静态性质的 esm 模块。但是由于 commonjs 模块具有高度动态的特性,将其转化为高度静态化的 esm 模块势必会存在一些边界问题。
文章 最后提出将 strictRequires = true 作为默认配置项。也就是说 @rollup/plugin-commonjs 会将所有的 commonjs 模块都包装在函数中,执行类似 nodejs 加载 commonjs 模块的方式,在运行时确认 commonjs 模块具体导出了哪些引用。
因此现版本的 @rollup/plugin-commonjs 在 strictRequires = true 的配置下,与 esbuild 在转译 commonjs 模块为 esm 模块的行为类似。