Skip to content

每日一报

esbuild 的一些考虑

为什么 esbuild 转译 commonjs 模块为 esm 模块时,均使用 default 导出

举一个例子,如果转译以下 commonjs 模块:

js
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 转译后的结果如下:

js
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 会这么做?

本质上的原因是 esbuildcommonjs 作为一等公民。esbuild 转译 commonjsesm 的时候是采用与 nodejs 类似的 运行时加载方式,并没有对 commonjs 模块进行静态分析。换句话说 esbuild 并不知道 commonjs 模块具体导出了哪些引用,也就是无法采用具名导出的方式交付给 引用方(importer)

例子

java
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 模块的 高度动态性

js
const obj = {};

// ... do obj something in runtime
obj.a = 1;
obj.b = 2;

export { obj };

export default obj;

至此已经介绍了针对运行时的属性在静态分析需要如何处理导出,即通过引用对象的方式导出(具名导出或默认导出)。由于 commonjs 自身并没有所谓的具名导出,因此 esbuild 会统一将 commonjs 模块的导出处理为 默认导出。这也就是我们所看到的 esbuildcommonjs 的转译产物(esm)中的最底部均会使用 export default 语句。

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 转译 commonjsesm 的时候是采用与 Node 类似的 运行时加载 方式。

可能熟悉 rollup 的处理 commonjs 模块的方案可以知道,rollupesm 作为一等公民。也就是说遇到任何非 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-commonjsstrictRequires = true 的配置下,与 esbuild 在转译 commonjs 模块为 esm 模块的行为类似。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)