模块 - ESM/CJS 互操作性
时间回到 2015 年,你正在编写一个 ESM 到 CJS 的转译器。关于如何做到这一点,并没有规范;你拥有的只是 ES 模块应该如何相互交互的规范、CommonJS 模块如何相互交互的知识,以及解决问题的能力。考虑一个导出 ES 模块:
export const A = {};
export const B = {};
export default "Hello, world!";你将如何把它转换成 CommonJS 模块?回想一下,默认导出只是带有特殊语法的命名导出,似乎只有一种选择:
exports.A = {};
exports.B = {};
exports.default = "Hello, world!";这是一个很好的类比,它让你可以在导入端实现类似的功能:
import hello, { A, B } from "./module";
console.log(hello, A, B);
// 转译为:
const module_1 = require("./module");
console.log(module_1.default, module_1.A, module_1.B);到目前为止,CJS 世界中的一切都与 ESM 世界中的一切一一对应。将上述等价性再进一步,我们可以看到我们还有:
import * as mod from "./module";
console.log(mod.default, mod.A, mod.B);
// 转译为:
const mod = require("./module");
console.log(mod.default, mod.A, mod.B);你可能注意到,在这种方案中,无法编写一个 ESM 导出,使其输出将 exports 赋值为函数、类或原始类型:
// @Filename: exports-function.js
module.exports = function hello() {
console.log("Hello, world!");
};但现有的 CommonJS 模块经常采用这种形式。用我们的转译器处理的 ESM 导入,如何访问这个模块?我们刚刚确定命名空间导入(import *)会转译为普通的 require 调用,因此我们可以支持像这样的输入:
import * as hello from "./exports-function";
hello();
// 转译为:
const hello = require("./exports-function");
hello();我们的输出在运行时有效,但我们遇到了规范性问题:根据 JavaScript 规范,命名空间导入总是解析为模块命名空间对象,即一个其成员是模块导出的对象。在这种情况下,require 会返回函数 hello,但 import * 永远不能返回一个函数。我们假设的对应关系似乎是无效的。
这里值得退一步,澄清目标是什么。一旦 ES2015 规范中加入了模块,转译器就出现了对将 ESM 降级为 CJS 的支持,允许用户在运行时实现对其支持之前很久就采用新语法。甚至有一种感觉,编写 ESM 代码是让新项目“面向未来”的好方法。为此,需要有一条无缝的迁移路径,从执行转译器的 CJS 输出到一旦运行时支持就原生执行 ESM 输入。目标是找到一种将 ESM 降级为 CJS 的方法,使得将来任何或所有这些转译输出都可以被真正的 ESM 输入替换,而在行为上没有可观察到的变化。
通过遵循规范,转译器很容易找到一组转换,使它们转译的 CommonJS 输出的语义与它们 ESM 输入的指定语义相匹配(箭头表示导入):
然而,CommonJS 模块(作为 CommonJS 编写,而不是作为转译为 CommonJS 的 ESM)在 Node.js 生态系统中已经根深蒂固,因此以 ESM 编写并转译为 CJS 的模块不可避免地开始“导入”以 CommonJS 编写的模块。但是,这种互操作性的行为既没有被 ES2015 指定,也尚未在任何真实的运行时中存在。
即使转译器作者什么也不做,行为也会从他们在转译代码中发出的 require 调用与现有 CJS 模块中定义的 exports 之间的现有语义中产生。并且为了让用户在其运行时支持后能够从转译的 ESM 无缝过渡到真正的 ESM,这种行为必须与运行时选择实现的行为相匹配。
猜测运行时将支持哪种互操作行为并不限于 ESM 导入“真正的 CJS”模块。ESM 是否能够将从 CJS 转译的 ESM 与 CJS 区分开,以及 CJS 是否能够 require ES 模块,也是未指定的。甚至 ESM 导入是否会使用与 CJS require 调用相同的模块解析算法也是不可知的。所有这些变量都必须正确预测,以便为转译器用户提供一条通往原生 ESM 的无缝迁移路径。
allowSyntheticDefaultImports 和 esModuleInterop
让我们回到规范符合性问题,其中 import * 转译为 require:
// 根据规范无效:
import * as hello from "./exports-function";
hello();
// 但转译有效:
const hello = require("./exports-function");
hello();当 TypeScript 首次添加对编写和转译 ES 模块的支持时,编译器通过在任何其 exports 不是类似命名空间对象的模块的命名空间导入上发出错误来解决此问题:
import * as hello from "./exports-function";
// TS2497 ^^^^^^^^^^^^^^^^^^^^
// 外部模块 '"./exports-function"' 解析为非模块实体,不能使用此构造导入。唯一的解决方法是让用户回到使用表示 CommonJS require 的旧 TypeScript 导入语法:
import hello = require("./exports-function");强制用户恢复到非 ESM 语法本质上是在承认“我们不知道像 "./exports-function" 这样的 CJS 模块将来如何或是否可以通过 ESM 导入访问,但我们知道它不能通过 import * 访问,即使在我们使用的转译方案中它在运行时有效。”它无法满足将此文件迁移到真正的 ESM 而无需更改的目标,但允许 import * 链接到函数也不是一个好的选择。当 allowSyntheticDefaultImports 和 esModuleInterop 被禁用时,这仍然是今天 TypeScript 中的行为。
不幸的是,这有点过于简化了——TypeScript 并没有通过这个错误完全避免符合性问题,因为它允许函数的命名空间导入工作,并保留其调用签名,只要该函数声明与命名空间声明合并——即使命名空间是空的。因此,虽然导出裸函数的模块被识别为“非模块实体”:
tsdeclare function $(selector: string): any; export = $; // 不能 `import *` 这个 👍一个看似无意义的更改却允许无效的导入通过类型检查而没有错误:
tsdeclare namespace $ {} declare function $(selector: string): any; export = $; // 允许 `import *` 这个并调用它 😱
与此同时,其他转译器正在想办法解决同样的问题。思考过程大致如下:
- 要导入一个导出函数或原始类型的 CJS 模块,我们显然需要使用默认导入。命名空间导入是非法的,命名导入在这里也没有意义。
- 很可能,这意味着实现 ESM/CJS 互操作的运行时将选择使 CJS 模块的默认导入始终直接链接到整个
exports,而不仅仅是当exports是函数或原始类型时才这样做。 - 因此,对真正 CJS 模块的默认导入应该像
require调用一样工作。但我们需要一种方法来区分真正的 CJS 模块和我们转译的 CJS 模块,这样我们仍然可以将export default "hello"转译为exports.default = "hello",并使该模块的默认导入链接到exports.default。基本上,对我们自己转译的模块之一的默认导入需要以一种方式工作(模拟 ESM 到 ESM 的导入),而对任何其他现有 CJS 模块的默认导入需要以另一种方式工作(模拟我们认为 ESM 到 CJS 导入的工作方式)。 - 当我们将 ES 模块转译为 CJS 时,让我们在输出中添加一个特殊的额外字段:ts当我们在转译默认导入时,可以检查这个标志:
exports.A = {}; exports.B = {}; exports.default = "Hello, world!"; // 特殊的额外标志! exports.__esModule = true;ts// import hello from "./module"; const _mod = require("./module"); const hello = _mod.__esModule ? _mod.default : _mod;
__esModule 标志首次出现在 Traceur 中,随后很快出现在 Babel、SystemJS 和 Webpack 中。TypeScript 在 1.8 中添加了 allowSyntheticDefaultImports,以允许类型检查器将默认导入直接链接到 exports,而不是链接到任何缺少 export default 声明的模块类型的 exports.default。该标志没有修改导入或导出的输出方式,但它允许默认导入反映其他转译器对待它们的方式。即,它允许使用默认导入来解析“非模块实体”,而 import * 则是错误:
// 错误:
import * as hello from "./exports-function";
// 旧解决方法:
import hello = require("./exports-function");
// 启用 `allowSyntheticDefaultImports` 后的新方法:
import hello from "./exports-function";这通常足以让 Babel 和 Webpack 用户编写在这些系统中已经可以工作的代码,而不会让 TypeScript 报错,但它只是一个部分解决方案,留下了一些未解决的问题:
- Babel 和其他工具根据目标模块上是否找到
__esModule属性来改变其默认导入行为,但allowSyntheticDefaultImports仅在目标模块的类型中未找到默认导出时启用回退行为。如果目标模块有__esModule标志但没有默认导出,就会产生不一致。转译器和打包器仍然会将此类模块的默认导入链接到其exports.default,这将是undefined,并且在 TypeScript 中理想情况下应该是错误,因为如果无法链接,真正的 ESM 导入会导致错误。但是启用allowSyntheticDefaultImports后,TypeScript 会认为此类导入的默认导入链接到整个exports对象,从而允许将其属性作为命名导出访问。 allowSyntheticDefaultImports不会改变命名空间导入的类型化方式,造成了一种奇怪的矛盾,两者都可以使用并且具有相同的类型:ts// @Filename: exportEqualsObject.d.ts declare const obj: object; export = obj; // @Filename: main.ts import objDefault from "./exportEqualsObject"; import * as objNamespace from "./exportEqualsObject"; // 这在运行时应该是 true,但 TypeScript 报错: objNamespace.default === objDefault; // ^^^^^^^ 类型 'typeof import("./exportEqualsObject")' 上不存在属性 'default'。- 最重要的是,
allowSyntheticDefaultImports不会改变tsc发出的 JavaScript。因此,虽然只要代码被馈送到另一个工具(如 Babel 或 Webpack)中,该标志就能实现更准确的检查,但对于使用tsc输出--module commonjs并在 Node.js 中运行的用户来说,它造成了真正的危险。如果他们遇到import *的错误,启用allowSyntheticDefaultImports似乎可以修复它,但事实上它只是静默了构建时错误,同时发出了会在 Node 中崩溃的代码。
TypeScript 在 2.7 中引入了 esModuleInterop 标志,它改进了导入的类型检查,以解决 TypeScript 的分析与现有转译器和打包器中使用的互操作行为之间剩余的不一致之处,并且至关重要的是,采用了与转译器多年前采用的相同的 __esModule 条件 CommonJS 输出。(另一个用于 import * 的新输出辅助函数确保了结果始终是一个对象,并剥离了调用签名,完全解决了上述“解析为非模块实体”错误未能完全规避的规范符合性问题。)最后,启用新标志后,TypeScript 的类型检查、TypeScript 的输出以及转译和打包生态系统的其余部分就一个符合规范且可能被 Node 采用的 CJS/ESM 互操作方案达成了一致。
Node.js 中的互操作
Node.js 在 v12 中正式支持 ES 模块。就像打包器和转译器几年前开始做的那样,Node.js 为 CommonJS 模块的 exports 对象提供了一个“合成默认导出”,允许从 ESM 通过默认导入访问整个模块内容:
// @Filename: export.cjs
module.exports = { hello: "world" };
// @Filename: import.mjs
import greeting from "./export.cjs";
greeting.hello; // "world"这是无缝迁移的一个胜利!不幸的是,相似之处大多到此为止。
没有 __esModule 检测(“双重默认”问题)
Node.js 无法尊重 __esModule 标记来改变其默认导入行为。因此,具有“默认导出”的转译模块在被另一个转译模块“导入”时以一种方式行为,而在被 Node.js 中的真正 ES 模块导入时以另一种方式行为:
// @Filename: node_modules/dependency/index.js
exports.__esModule = true;
exports.default = function doSomething() { /*...*/ }
// @Filename: transpile-vs-run-directly.{js/mjs}
import doSomething from "dependency";
// 转译后有效,但在 Node.js ESM 中不是一个函数:
doSomething();
// 转译后不存在,但在 Node.js ESM 中有效:
doSomething.default();转译的默认导入仅在目标模块缺少 __esModule 标志时才进行合成默认导出,而 Node.js 总是合成一个默认导出,从而在转译模块上产生“双重默认”。
不可靠的命名导出
除了使 CommonJS 模块的 exports 对象作为默认导入可用之外,Node.js 还尝试找到 exports 的属性以作为命名导入可用。这种行为在有效时与打包器和转译器匹配;然而,Node.js 在任何代码执行之前使用语法分析来合成命名导出,而转译模块则在运行时解析其命名导入。结果是,在转译模块中有效的从 CJS 模块的导入可能在 Node.js 中无效:
// @Filename: named-exports.cjs
exports.hello = "world";
exports["worl" + "d"] = "hello";
// @Filename: transpile-vs-run-directly.{js/mjs}
import { hello, world } from "./named-exports.cjs";
// `hello` 有效,但 `world` 在 Node.js 中缺失 💥
import mod from "./named-exports.cjs";
mod.world;
// 从默认对象访问属性总是有效 ✅在 Node.js v22 之前不能 require 真正的 ES 模块
真正的 CommonJS 模块可以 require 一个从 ESM 转译为 CJS 的模块,因为它们在运行时都是 CommonJS。但在版本低于 v22.12.0 的 Node.js 中,如果 require 解析到一个 ES 模块,就会崩溃。这意味着已发布的库不能从转译模块迁移到真正的 ESM,而不破坏它们的 CommonJS(真正的或转译的)消费者:
// @Filename: node_modules/dependency/index.js
export function doSomething() { /* ... */ }
// @Filename: dependent.js
import { doSomething } from "dependency";
// ✅ 如果 dependent 和 dependency 都是转译的,则有效
// ✅ 如果 dependent 和 dependency 都是真正的 ESM,则有效
// ✅ 如果 dependent 是真正的 ESM 而 dependency 是转译的,则有效
// 💥 如果 dependent 是转译的而 dependency 是真正的 ESM,则崩溃不同的模块解析算法
Node.js 引入了一种新的用于解析 ESM 导入的模块解析算法,与长期存在的用于解析 require 调用的算法有很大不同。虽然与 CJS 和 ES 模块之间的互操作不直接相关,但这种差异是无缝地从转译模块迁移到真正的 ESM 可能不可能的另一个原因:
// @Filename: add.js
export function add(a, b) {
return a + b;
}
// @Filename: math.js
export * from "./add";
// ^^^^^^^
// 转译为 CJS 时有效,
// 但在 Node.js ESM 中必须是 "./add.js"结论
显然,从转译模块到 ESM 的无缝迁移是不可能的,至少在 Node.js 中是这样。这给我们留下了什么?
设置正确的 module 编译器选项至关重要
由于宿主之间的互操作规则不同,TypeScript 除非理解它看到的每个文件代表什么类型的模块以及要对它们应用哪组规则,否则无法提供正确的检查行为。这就是 module 编译器选项的目的。(特别地,打算在 Node.js 中运行的代码比将由打包器处理的代码受到更严格的规则约束。除非 module 设置为 node16、node18 或 nodenext,否则编译器的输出不会针对 Node.js 兼容性进行检查。)
包含 CommonJS 代码的应用程序应始终启用 esModuleInterop
在 TypeScript 应用程序(与其他人可能消费的库相对)中,如果使用 tsc 来输出 JavaScript 文件,是否启用 esModuleInterop 没有重大影响。你对某些类型模块的导入方式会改变,但 TypeScript 的检查和输出是同步的,因此无错误的代码在任一模式下运行都应该是安全的。在这种情况下,保持 esModuleInterop 禁用的缺点是它允许你编写语义上明显违反 ECMAScript 规范的 JavaScript 代码,混淆对命名空间导入的理解,并使将来迁移到运行 ES 模块变得更加困难。
另一方面,在由第三方转译器或打包器处理的应用程序中,启用 esModuleInterop 更为重要。所有主要的打包器和转译器都使用类似 esModuleInterop 的输出策略,因此 TypeScript 需要调整其检查以匹配。(编译器总是根据 tsc 将输出的 JavaScript 文件中会发生的情况进行推理,因此即使使用其他工具代替 tsc,也应该尽可能设置影响输出的编译器选项以匹配该工具的输出。)
应避免在没有 esModuleInterop 的情况下使用 allowSyntheticDefaultImports。它会在不改变 tsc 输出的代码的情况下改变编译器的检查行为,从而可能发出不安全的 JavaScript。此外,它引入的检查变化是 esModuleInterop 引入的检查变化的不完整版本。即使 tsc 不用于输出,启用 esModuleInterop 也比 allowSyntheticDefaultImports 更好。
有些人反对在启用 esModuleInterop 时在 tsc 的 JavaScript 输出中包含 __importDefault 和 __importStar 辅助函数,要么是因为它略微增加了磁盘上的输出大小,要么是因为辅助函数采用的互操作算法似乎通过检查 __esModule 而错误地表示了 Node.js 的互操作行为,从而导致前面讨论过的危险。这两种反对意见都可以在不接受 esModuleInterop 禁用时表现出的有缺陷的检查行为的情况下得到部分解决。首先,可以使用 importHelpers 编译器选项从 tslib 导入辅助函数,而不是将它们内联到需要它们的每个文件中。针对第二个反对意见,让我们看最后一个例子:
// @Filename: node_modules/transpiled-dependency/index.js
exports.__esModule = true;
exports.default = function doSomething() { /* ... */ };
exports.something = "something";
// @Filename: node_modules/true-cjs-dependency/index.js
module.exports = function doSomethingElse() { /* ... */ };
// @Filename: src/sayHello.ts
export default function sayHello() { /* ... */ }
export const hello = "hello";
// @Filename: src/main.ts
import doSomething from "transpiled-dependency";
import doSomethingElse from "true-cjs-dependency";
import sayHello from "./sayHello.js";假设我们正在将 src 编译为 CommonJS 以在 Node.js 中使用。如果没有 allowSyntheticDefaultImports 或 esModuleInterop,从 "true-cjs-dependency" 导入 doSomethingElse 会出错,而其他导入不会。要修复此错误而不更改任何编译器选项,你可以将导入更改为 import doSomethingElse = require("true-cjs-dependency")。然而,取决于模块(未显示)的类型是如何编写的,你可能也可以编写和调用命名空间导入,这将是语言级别的规范违反。启用 esModuleInterop 后,显示的导入都不会出错(并且都是可调用的),但无效的命名空间导入会被捕获。
如果我们决定将 src 迁移到 Node.js 中的真正 ESM(比如,在我们的根 package.json 中添加 "type": "module"),会发生什么变化?第一个导入,来自 "transpiled-dependency" 的 doSomething,将不再可调用——它表现出“双重默认”问题,我们必须调用 doSomething.default() 而不是 doSomething()。(TypeScript 在 --module node16-nodenext 下理解并捕获了这一点。)但值得注意的是,第二个导入 doSomethingElse,在编译为 CommonJS 时需要 esModuleInterop 才能工作,在真正的 ESM 中却工作得很好。
如果这里有什么可以抱怨的,那不是 esModuleInterop 对第二个导入做了什么。它所做的更改,既允许默认导入又阻止可调用的命名空间导入,完全符合 Node.js 真正的 ESM/CJS 互操作策略,并使迁移到真正的 ESM 更容易。问题,如果有的话,是 esModuleInterop 似乎未能为我们提供第一个导入的无缝迁移路径。但这个问题并不是启用 esModuleInterop 引入的;第一个导入完全不受它的影响。不幸的是,如果不破坏 main.ts 和 sayHello.ts 之间的语义契约,这个问题就无法解决,因为 sayHello.ts 的 CommonJS 输出在结构上与 transpiled-dependency/index.js 看起来相同。如果 esModuleInterop 改变了对 doSomething 转译导入的工作方式,使其与在 Node.js ESM 中的工作方式相同,它就会以同样的方式改变 sayHello 导入的行为,使输入代码违反 ESM 语义(从而仍然阻止 src 目录在没有更改的情况下迁移到 ESM)。
正如我们所看到的,从转译模块到真正的 ESM 没有无缝的迁移路径。但 esModuleInterop 是朝着正确方向迈出的一步。对于那些仍然希望最小化模块语法转换和导入辅助函数的包含的人来说,启用 verbatimModuleSyntax 是比禁用 esModuleInterop 更好的选择。verbatimModuleSyntax 强制在输出 CommonJS 的文件中使用 import mod = require("mod") 和 export = ns 语法,从而避免了我们所讨论的所有导入歧义,但代价是迁移到真正 ESM 的难度增加。
库代码需要特殊考虑
作为 CommonJS 发布的库应避免使用默认导出,因为转译后的导出在不同工具和运行时中的访问方式不同,其中一些方式会让用户感到困惑。由 tsc 转译为 CommonJS 的默认导出,在 Node.js 中作为默认导入的 default 属性访问:
import pkg from "pkg";
pkg.default();在大多数打包器或转译的 ESM 中作为默认导入本身:
import pkg from "pkg";
pkg();在原生 CommonJS 中作为 require 调用的 default 属性:
const pkg = require("pkg");
pkg.default();如果用户必须访问默认导入的 .default 属性,他们会察觉到模块配置有误,并且如果他们试图编写既能在 Node.js 中运行又能在打包器中运行的代码,他们可能会陷入困境。一些第三方 TypeScript 转译器暴露了改变默认导出输出方式的选项来缓解这种差异,但它们不会生成自己的声明(.d.ts)文件,这导致运行时行为和类型检查之间不匹配,进一步混淆和惹恼用户。相反,对于需要作为 CommonJS 发布的库,对于具有单个主要导出的模块,应使用 export =,对于具有多个导出的模块,应使用命名导出:
- export default function doSomething() { /* ... */ }
+ export = function doSomething() { /* ... */ }库(发布声明文件)还应特别小心,确保它们编写的类型在广泛的编译器选项下都是无错误的。例如,有可能以一种只在 strictNullChecks 禁用时才能成功编译的方式编写一个扩展另一个接口的接口。如果库发布这样的类型,它将强制所有用户也禁用 strictNullChecks。esModuleInterop 可以使类型声明包含类似的“传染性”默认导入:
// @Filename: /node_modules/dependency/index.d.ts
import express from "express";
declare function doSomething(req: express.Request): any;
export = doSomething;假设这个默认导入仅在启用 esModuleInterop 时有效,并且在没有该选项的用户引用此文件时导致错误。用户可能无论如何都应该启用 esModuleInterop,但库让它们的配置具有这种传染性通常被视为不良形式。库最好发布如下的声明文件:
import express = require("express");
// ...像这样的例子导致了传统智慧,即库不应启用 esModuleInterop。这个建议是一个合理的起点,但我们已经看到了启用 esModuleInterop 时命名空间导入的类型会发生变化,可能引入错误的例子。因此,无论库是否启用 esModuleInterop 编译,它们都有可能编写出使其选择具有传染性的语法。
希望超越基本要求以确保最大兼容性的库作者最好针对一组编译器选项验证他们的声明文件。但是使用 verbatimModuleSyntax 通过强制输出 CommonJS 的文件使用 CommonJS 风格的导入和导出语法,完全绕过了 esModuleInterop 的问题。此外,由于 esModuleInterop 仅影响 CommonJS,随着越来越多的库随着时间的推移转向仅发布 ESM,这个问题的重要性将下降。