遇到过ESM与CJS转换时的default问题么?
The following article is from ByteDance Web Infra Author 杨健
esbuild 0.14.4 引入的 breaking ,正是 js 社区臭名昭著的一个问题,即 ESM 和 CJS 的 Interop(互操作性)问题,esbuild 的 changelog 写了相当长的篇幅总结了这个问题( esbuild 的 changelog 是业界良心,总能学到新东西)。下面内容均来自 esbuild changelog 的翻译。
在开发 ECMAScript 模块导入/导出语法时,CommonJS 模块格式(用于 Node.js )已经被广泛使用。正因为如此,为了解决 ESM 和 CJS 的交互性问题,名为 default 的导出名称被赋予了特殊的语法。你可以不写 import { default as foo } from 'bar'
,而只写 import foo from 'bar'
。
这个想法的初衷是,当 ECMAScript 模块(又称 ES 模块)被引入时,你可以使用新的导入语法来导入现有的 CommonJS 模块来实现兼容性。由于 CommonJS 模块的导出是动态的,而 ES 模块的导出是静态的,一般来说,在模块实例化的时候不可能确定一个 CommonJS 模块的导出名称,因为此时代码还没有被执行。所以 module.exports 的值只能作为默认的导出(因为无法确定其他 name ,只能约定一个 default 作为整体的导出 name ),特殊的默认导入语法让你很容易访问 module.exports(即import foo from 'bar'
等价于 const foo = require('bar')
。
到这里一切设计都很合乎情理,似乎这个设计也无懈可击,然而这里同时埋下了祸根,即这个交互性问题其实只需要支持个
import foo from 'bar'
这个 syntax sugar (语法糖)即可满足,然而却同时错误的支持了export default 'xxx'
这个语法,为后续的交互性问题埋下了祸根。
然而(一切不幸的开始),ES 模块语法需要一段时间才能被 JavaScript 运行系统原生支持,而人们仍然希望在这期间开始使用 ES 模块语法。Babel 通过将 ES 编译到 CJS 让你现在就可以使用 ES 模块进行编码。你可以将每个 ES 模块文件转化为一个行为相同的 CommonJS 模块文件。
然而,这种转换有一个问题:如何准确的将import语法降级到 commonjs,上述设计意味着export default 0
和import foo from 'bar'
在转换为 CommonJS 时行为将不再一致。代码export default 0
变成了module.exports.default = 0
,代码import foo from 'bar'
变成了const foo = require('bar')
(这里是为了对齐上述的交互性行为)。这导致代码在降级到 cjs 前和降级到 cjs 后的行为是不一致的了。
降级前:
bar.js
export default 0
foo.js
import foo from 'bar' // foo结果应该为0
console.log('foo',foo);
降级后:
bar.js
module.exports.default = 0
foo.js
const foo = require('bar') // foo结果为{default:0}
console.log('foo',foo);
降级前后运行结果不一致,这是非常显然的bug。
为了解决这个问题,Babel 在将 ES 模块转换为 CommonJS 模块时,通过将属性 __esModule 设置为true 标记这个模块是一个编译后的 ES 模块。然后,在导入 default 导出时,它可以知道使用 module.exports.default 的值,而不是 module.exports 的值,以确保 CommonJS 模块的行为与原始 ES 模块的行为正确匹配。这一修正在整个生态系统中被广泛采用,并进入了其他工具,如 TypeScript ,甚至 esbuild 。babel 修复后的结果如下:
bar.js
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = 0;
foo.js
"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
var bar_1 = __importDefault(require("bar"));
consol.log('foo', bar_1.default); // 结果为0
// 计算过程如下
* require("bar")=> {default: 0,__esModule: true}
* bar_1 => __importDefault => ({default:0,_esModule}).__esModule ? ({default: __esModule}) : {default: {default:0,__esModule:true}} => {default: 0,__esModule:true}
* bar_1.default => ({default:0,__esModule:true}).default => 0
至此,前端社区的代码实际上可以认为跑在了一个虚拟的 Babel |Webpack 的 runtime上,这个 babel runtime 通过将ES编译为CJS帮我们解决了ESM和CJS的交互性问题了,如果没有后续Node的背刺,实际上已经是趋于稳定了。
然而(另一个不幸的事情,让事情雪上加霜),当 Node.js 最终发布他们的 ES 模块实现时,他们采用了原来的实现,即 default 导出总是等于 module.exports ,这打破了与现有的 ES 模块生态系统的兼容性(即和 Babel runtime 的兼容性),这些模块已经被 Babel 交叉编译成 CommonJS 模块。现在你必须根据你的代码是需要在 Node 环境中还是在 Babel 环境中运行,来添加或删除一个额外的 .default
属性,这就导致了更严重性的互操作性问题。此外,像 esbuild 这样的 JavaScript 工具现在需要猜测你是想要 Node 风格还是 Babel 风格的默认导入。工具没有办法肯定地知道某个文件所期望的是哪一种,如果你的工具猜错了,你的代码就会被破坏。
至此我们总结下,目前 ESM 和 CJS 的交互性问题,由三件不幸的事情组成,
import xxx from 'bar'
本来应该是个处理交互性的语法糖,但是并没有和其他的模块导入 && 导出进行区分(就不应该支持export default
), Babel 错误的实现了 ESM 到 CJS 的降级方案,虽然后来修复了但是还是造成了一定问题,node 选择了与 Babel runtime (前端社区)不兼容的方案,导致市面上存在两套 interop 的逻辑,并且彼此不兼容,我们可以明显的感知到node社区和前端社区存在很大的割裂性。
esbuild 的兼容性修复
这个版本改变了 esbuild 围绕默认导出和 __esModule 标记的启发式方法,以试图改善与 Webpack 和 Node 的兼容性(大部分的生态都是基于他俩),其行为变化如下:
旧的行为:
如果导入语句被用来加载一个CommonJS文件,并且 module.exports 中存在 default 属性,那么 esbuild 将把默认导出设置为 module.exports.default(像 Babel)。否则默认出口被设置为 module.exports(像Node)。 module.exports 是一个对象, module.exports.__esModule 是 truthy ,并且 如果一个 require 调用被用来加载一个 ES 模块文件,返回的模块命名空间对象的 __esModule 属性被设置为 true 。这就像 ES 模块通过 Babel 兼容的转换被转换为 CommonJS 一样。 当编写纯 ESM 代码时,esModule 标记可能会不一致地出现在模块命名空间对象上(即 import * as
)。具体来说,如果一个模块命名空间对象被物化(materialized)了,那么 esModule 标记就会出现,但如果它被优化掉了,那么 __esModule 标记就会消失。不允许创建一个名为 esModule 的 ES 模块导出。这避免了生成的代码与上述行为冲突导致代码 break ,同时也避免了 esModule 的重复定义问题。
新的行为:
如果导入语句被用来加载一个CommonJS文件,并且 文件名不是以 .mjs 或 .mts 结尾,package.json 文件不包含 "type": "module",那么 esbuild 将把默认导出设置为 module.exports.default(像Babel一样)。否则,默认出口将被设置为module.exports(像 Node )。 module.exports 是一个对象 module.exports.__esModule是真实的,并且
请注意,这意味着默认出口在以前没有被定义的情况下现在可能是未定义的。这与 Webpack 的行为相匹配,所以希望它能更加兼容。
还要注意,这意味着导入行为现在取决于文件的扩展名和 package.json 的内容。这也符合 Webpack 的行为,希望能提高兼容性。
如果一个 require 调用被用来加载一个 ES 模块文件,返回的模块命名空间对象的 __esModule属性被设置为true。这就像ES模块已经通过Babel兼容的转换被转换为CommonJS一样。 如果导入语句或 import() 表达式被用来加载一个 ES 模块,esModule 标记现在不应该出现在模块命名空间对象上。这释放了 esModule 的导出名称,使其可以用于 ES 模块。 现在允许在 ES 模块中使用 __esModule 作为一个正常的导出名。这个属性可以被其他 ES 模块访问,但不能被使用 require 加载 ES 模块的代码访问,他们将会始终看到这个属性被设置为 true。