【Web技术】192- JS 社区臭名昭著的一个问题

292次阅读  |  发布于2年以前

截图自https://sokra.github.io/interop-test/

今天因为 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 后的行为是不一致的了。

降级前:

export default 0
export default 0

降级后:

module.exports.default = 0
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 修复后的结果如下:

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = 0;
"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 的兼容性(大部分的生态都是基于他俩),其行为变化如下:

旧的行为:

新的行为:

请注意,这意味着默认出口在以前没有被定义的情况下现在可能是未定义的。这与 Webpack 的行为相匹配,所以希望它能更加兼容。

还要注意,这意味着导入行为现在取决于文件的扩展名和 package.json 的内容。这也符合 Webpack 的行为,希望能提高兼容性。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8