查看原文
其他

遇到过ESM与CJS转换时的default问题么?

The following article is from ByteDance Web Infra Author 杨健

今天因为 esbuild 的一个 bug ,需要升级 esbuild 的版本,升级完后惊讶的发现 Babel 居然挂了,我只是升级了个小版本(0.14.1 -> 0.14.5),理应不该出现如此大的变动,后来追踪了下 esbuild 的 changelog ,发现了 esbuild 在 0.14.4 引入了一个巨大的 breaking change (严谨如 esbuild 也没严格遵循语义化版本,可见业务如果强依赖语义化版本是个多不靠谱的事情)。

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 0import 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", { valuetrue });
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", { valuetrue });
var bar_1 = __importDefault(require("bar"));
consol.log('foo', bar_1.default); // 结果为0

// 计算过程如下
require("bar")=> {default0,__esModuletrue}
bar_1 => __importDefault => ({default:0,_esModule}).__esModule ? ({default: __esModule}) : {default: {default:0,__esModule:true}} => {default0,__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。



彦祖,亦菲,点个「在看」

您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存