模块 - ESM/CJS 互操作性

时间回到 2015 年,你正在编写一个 ESM 转 CJS 的转译器。当时还没有关于如何实现这一点的规范;你所拥有的仅仅是 ES 模块之间如何交互的规范、关于 CommonJS 模块之间如何交互的知识,以及一种摸索出解决方案的直觉。考虑一个导出的 ES 模块:

ts
export const A = {};
export const B = {};
export default "Hello, world!";

你会如何将其转变为一个 CommonJS 模块?回想一下,默认导出(default exports)只是具有特殊语法的命名导出,似乎只有一种选择:

ts
exports.A = {};
exports.B = {};
exports.default = "Hello, world!";

这是一种很好的类比,它让你可以在导入端实现类似的逻辑:

ts
import hello, { A, B } from "./module";
console.log(hello, A, B);
// transpiles to:
const module_1 = require("./module");
console.log(module_1.default, module_1.A, module_1.B);

到目前为止,CJS 世界中的一切都与 ESM 世界中的一切一一对应。将上述等价性进一步扩展,我们可以看到我们也拥有:

ts
import * as mod from "./module";
console.log(mod.default, mod.A, mod.B);
// transpiles to:
const mod = require("./module");
console.log(mod.default, mod.A, mod.B);

你可能会注意到,在这种方案中,无法编写一个 ESM 导出,使其产生的输出将函数、类或原始值赋值给 exports

ts
// @Filename: exports-function.js
module.exports = function hello() {
console.log("Hello, world!");
};

但现有的 CommonJS 模块经常采取这种形式。我们的转译器处理后的 ESM 导入如何访问此模块?我们刚刚确定了命名空间导入 (import *) 会转译为普通的 require 调用,因此我们可以支持如下输入:

ts
import * as hello from "./exports-function";
hello();
// transpiles to:
const hello = require("./exports-function");
hello();

我们的输出在运行时可以工作,但我们遇到了合规性问题:根据 JavaScript 规范,命名空间导入始终解析为模块命名空间对象,即一个其成员为模块导出的对象。在这种情况下,require 将返回函数 hello,但 import * 永远无法返回函数。我们假设的对应关系似乎无效。

这里有必要退后一步,澄清一下目标是什么。ES2015 规范中刚引入模块时,支持将 ESM 向下转译(downleveling)为 CJS 的转译器就出现了,这让用户可以在运行时实现对新语法的支持之前,就能采用这种新语法。甚至有一种感觉,即编写 ESM 代码是“面向未来”的新项目的良好方式。要使这一点成为现实,就需要从执行转译器的 CJS 输出到一旦运行时开发出支持后原生执行 ESM 输入之间,存在一条无缝的迁移路径。目标是找到一种将 ESM 向下转译为 CJS 的方法,该方法允许在未来的运行时中,用其真实的 ESM 输入替换所有这些转译后的输出,且行为不会发生可观察的变化。

通过遵循规范,转译器很容易找到一系列转换,使它们转译后的 CommonJS 输出的语义与它们所对应的 ESM 输入的指定语义相匹配(箭头表示导入):

A flowchart with two similar flows side-by-side. Left: ESM. Right: ESM transpiled to CJS. In the ESM flow: "Importing module" flows to "Imported module" through arrow labeled "specified behavior". In the ESM transpiled to CJS flow: "Importing module" flows to "Imported module" through arrow labeled "designed based on spec".

然而,CommonJS 模块(作为 CommonJS 编写,而不是作为转译为 CommonJS 的 ESM 编写)在 Node.js 生态系统中已经非常成熟,因此,编写为 ESM 并转译为 CJS 的模块必然会开始“导入”编写为 CommonJS 的模块。然而,这种互操作性的行为并未由 ES2015 指定,也尚未在任何真正的运行时中存在。

A flowchart with three areas side-by-side. Left: ESM. Middle: True CJS. Right: ESM transpiled to CJS. Left: ESM "Importing module" flows to ESM "Imported module" through arrow labeled "specified behavior," and to True CJS "Imported module" through dotted arrow labeled "unspecified behavior." Right: ESM transpiled to CJS "Importing module" flows to ESM transpiled to CJS "Imported module" through arrow labeled "designed based on spec," and to True CJS "Imported module" through dotted arrow labeled "❓🤷‍♂️❓"

即使转译器作者什么都不做,行为也会从他们转译代码中发出的 require 调用与现有 CJS 模块中定义的 exports 之间的现有语义中产生。为了让用户在运行时支持后能够从转译后的 ESM 无缝过渡到真正的 ESM,该行为必须与运行时选择实现的行为相匹配。

猜测运行时会支持什么样的互操作行为,并不局限于 ESM 导入“真实的 CJS”模块。ESM 是否能够识别从 CJS 转译而来的 ESM(并将其与 CJS 区分开),以及 CJS 是否能够 require ES 模块,这些也都是未指定的。甚至 ESM 导入是否会使用与 CJS require 调用相同的模块解析算法也是不可知的。必须正确预测所有这些变量,才能为转译器用户提供一条通往原生 ESM 的无缝迁移路径。

allowSyntheticDefaultImportsesModuleInterop

让我们回到规范合规性问题,即 import * 转译为 require 的情况:

ts
// Invalid according to the spec:
import * as hello from "./exports-function";
hello();
// but the transpilation works:
const hello = require("./exports-function");
hello();

当 TypeScript 最初添加对编写和转译 ES 模块的支持时,编译器通过对任何其 exports 不是命名空间类对象的模块的命名空间导入发出错误来解决此问题:

ts
import * as hello from "./exports-function";
// TS2497 ^^^^^^^^^^^^^^^^^^^^
// External module '"./exports-function"' resolves to a non-module entity
// and cannot be imported using this construct.

唯一的变通方法是让用户重新使用代表 CommonJS require 的旧版 TypeScript 导入语法:

ts
import hello = require("./exports-function");

强制用户恢复使用非 ESM 语法,本质上承认了“我们不知道像 "./exports-function" 这样的 CJS 模块在未来是否能通过 ESM 导入访问,或者如何访问,但我们知道它不能通过 import * 访问,尽管它在我们正在使用的转译方案中在运行时可以工作。”这并没有达到允许该文件无需更改即可迁移到真正 ESM 的目标,但允许 import * 链接到一个函数也同样不行。这就是 TypeScript 今天在禁用 allowSyntheticDefaultImportsesModuleInterop 时的行为。

不幸的是,这稍微有点简化了——TypeScript 并没有完全通过这个错误规避合规性问题,因为它允许函数命名空间导入工作,并保留其调用签名,只要函数声明与命名空间声明合并即可——即使命名空间是空的。所以虽然一个导出裸函数的模块被识别为“非模块实体”:

ts
declare function $(selector: string): any;
export = $; // Cannot `import *` this 👍

一个本来毫无意义的更改允许无效的导入在没有错误的情况下通过类型检查。

ts
declare namespace $ {}
declare function $(selector: string): any;
export = $; // Allowed to `import *` this and call it 😱

与此同时,其他转译器正在寻找解决同一问题的方法。思维过程大致如下:

  1. 要导入导出函数或原始值的 CJS 模块,我们显然需要使用默认导入。命名空间导入在这里是非法的,而命名导入在这里没有意义。
  2. 很可能,这意味着实现 ESM/CJS 互操作的运行时将选择使 CJS 模块的默认导入始终直接链接到整个 exports,而不是仅在 exports 是函数或原始值时才这样做。
  3. 因此,真实 CJS 模块的默认导入应该像 require 调用一样工作。但我们需要一种方法来区分真实 CJS 模块和我们转译后的 CJS 模块,这样我们仍然可以将 export default "hello" 转译为 exports.default = "hello",并使该模块的默认导入链接到 exports.default。基本上,我们自己转译的模块的默认导入需要以一种方式工作(模拟 ESM 到 ESM 的导入),而任何其他现有 CJS 模块的默认导入需要以另一种方式工作(模拟我们认为 ESM 到 CJS 的导入将会如何工作)。
  4. 当我们把 ES 模块转译为 CJS 时,让我们在输出中添加一个特殊的额外字段:
    ts
    exports.A = {};
    exports.B = {};
    exports.default = "Hello, world!";
    // Extra special flag!
    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,以允许类型检查器将默认导入直接链接到任何缺少 export default 声明的模块类型的 exports(而不是 exports.default)。该标志并不修改导入或导出的发出方式,但它允许默认导入反映其他转译器将如何对待它们。也就是说,它允许在 import * 会报错的情况下,使用默认导入来解析“非模块实体”:

ts
// Error:
import * as hello from "./exports-function";
// Old workaround:
import hello = require("./exports-function");
// New way, with `allowSyntheticDefaultImports`:
import hello from "./exports-function";

这通常足以让 Babel 和 Webpack 用户编写在这些系统中已经有效的代码,而不会引发 TypeScript 的抱怨,但这只是一个部分解决方案,留下了一些未解决的问题:

  1. Babel 等工具根据目标模块上是否找到 __esModule 属性来改变其默认导入行为,但 allowSyntheticDefaultImports 仅在目标模块的类型中未找到默认导出时才启用回退行为。如果目标模块有 __esModule 标志但没有默认导出,这会造成不一致。转译器和打包器仍然会将此类模块的默认导入链接到其 exports.default(这将是 undefined),在 TypeScript 中这理想情况下应该是一个错误,因为如果真正的 ESM 导入无法链接,它们会导致错误。但有了 allowSyntheticDefaultImports,TypeScript 会认为此类导入的默认导入链接到整个 exports 对象,从而允许将命名导出作为其属性进行访问。
  2. allowSyntheticDefaultImports 没有改变命名空间导入的类型检查方式,导致了一种奇怪的不一致:两者都可以使用,并且具有相同的类型。
    ts
    // @Filename: exportEqualsObject.d.ts
    declare const obj: object;
    export = obj;
    // @Filename: main.ts
    import objDefault from "./exportEqualsObject";
    import * as objNamespace from "./exportEqualsObject";
    // This should be true at runtime, but TypeScript gives an error:
    objNamespace.default === objDefault;
    // ^^^^^^^ Property 'default' does not exist on type 'typeof import("./exportEqualsObject")'.
  3. 最重要的是,allowSyntheticDefaultImports 没有改变 tsc 发出的 JavaScript。因此,虽然该标志在代码被输入到 Babel 或 Webpack 等其他工具时可以实现更准确的检查,但对于那些使用 tsc 发出 --module commonjs 并在 Node.js 中运行的用户来说,它带来了真正的危险。如果他们在使用 import * 时遇到错误,看起来开启 allowSyntheticDefaultImports 似乎可以解决它,但实际上它只是静默了构建时的错误,同时发出了在 Node 中会崩溃的代码。

TypeScript 在 2.7 版本中引入了 esModuleInterop 标志,它改进了导入的类型检查,以解决 TypeScript 的分析与现有转译器和打包器使用的互操作行为之间的剩余不一致,最重要的是,采用了转译器几年前采用的相同的 __esModule 条件 CJS 发出。(另一个用于 import * 的新发出辅助工具确保结果始终是一个对象,去除了调用签名,完全解决了上述“解析为非模块实体”错误未能完全规避的规范合规性问题。)最终,随着新标志的启用,TypeScript 的类型检查、TypeScript 的发出以及其余的转译和打包生态系统对于一种符合规范的、并且也许是 Node 可能采用的 CJS/ESM 互操作方案达成了一致。

Node.js 中的互操作

Node.js 在 v12 版本中发布了对 ES 模块的非标记支持。就像打包器和转译器几年前开始做的那样,Node.js 为 CommonJS 模块提供了其 exports 对象的“合成默认导出”,允许通过 ESM 的默认导入访问整个模块内容:

ts
// @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 模块导入时表现为另一种方式。

ts
// @Filename: node_modules/dependency/index.js
exports.__esModule = true;
exports.default = function doSomething() { /*...*/ }
// @Filename: transpile-vs-run-directly.{js/mjs}
import doSomething from "dependency";
// Works after transpilation, but not a function in Node.js ESM:
doSomething();
// Doesn't exist after transpilation, but works in Node.js ESM:
doSomething.default();

虽然转译后的默认导入仅在目标模块缺少 __esModule 标志时才会生成合成默认导出,但 Node.js 始终会合成一个默认导出,从而在转译后的模块上产生一个“双重默认”。

不可靠的命名导出

除了使 CommonJS 模块的 exports 对象可作为默认导入使用外,Node.js 还尝试查找 exports 的属性以作为命名导入使用。这种行为在有效时与打包器和转译器匹配;然而,Node.js 使用语法分析在代码执行前合成命名导出,而转译后的模块则在运行时解析其命名导入。结果是,在转译模块中有效的 CJS 模块导入在 Node.js 中可能无效。

ts
// @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` works, but `world` is missing in Node.js 💥
import mod from "./named-exports.cjs";
mod.world;
// Accessing properties from the default always works ✅

Node.js v22 之前无法 require 真正的 ES 模块

真正的 CommonJS 模块可以 require 一个转译为 CommonJS 的 ESM 模块,因为它们在运行时都是 CommonJS。但在早于 v22.12.0 的 Node.js 版本中,如果 require 解析为 ES 模块,它会崩溃。这意味着已发布的库无法从转译后的模块迁移到真正的 ESM,而不会破坏其 CommonJS(真实的或转译的)消费者。

ts
// @Filename: node_modules/dependency/index.js
export function doSomething() { /* ... */ }
// @Filename: dependent.js
import { doSomething } from "dependency";
// ✅ Works if dependent and dependency are both transpiled
// ✅ Works if dependent and dependency are both true ESM
// ✅ Works if dependent is true ESM and dependency is transpiled
// 💥 Crashes if dependent is transpiled and dependency is true ESM

不同的模块解析算法

Node.js 引入了一种新的模块解析算法来解析 ESM 导入,该算法与长期以来解析 require 调用的算法有很大不同。虽然这与 CJS 和 ES 模块之间的互操作没有直接关系,但这种差异是无法从转译模块无缝迁移到真正 ESM 的又一个原因。

ts
// @Filename: add.js
export function add(a, b) {
return a + b;
}
// @Filename: math.js
export * from "./add";
// ^^^^^^^
// Works when transpiled to CJS,
// but would have to be "./add.js"
// in Node.js ESM.

结论

显然,从转译模块到 ESM 的无缝迁移是不可能的,至少在 Node.js 中是这样。这让我们处于什么境地?

设置正确的 module 编译器选项至关重要

由于不同主机之间的互操作规则不同,TypeScript 除非了解它所看到的每个文件代表哪种类型的模块,以及应该对它们应用哪一套规则,否则无法提供正确的检查行为。这就是 module 编译器选项的目的。(特别是,旨在在 Node.js 中运行的代码受到比将由打包器处理的代码更严格的规则约束。除非将 module 设置为 node16node18nodenext,否则不会检查编译器的输出以确保 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 要好。

有些人反对在启用 esModuleInteroptsc 的 JavaScript 输出中包含 __importDefault__importStar 辅助函数,要么是因为它在磁盘上略微增加了输出大小,要么是因为辅助函数采用的互操作算法似乎通过检查 __esModule 错误地代表了 Node.js 的互操作行为,从而导致了前面讨论的危险。这两个反对意见至少可以部分解决,而无需接受在禁用 esModuleInterop 时表现出的有缺陷的检查行为。首先,可以使用 importHelpers 编译器选项从 tslib 导入辅助函数,而不是将它们内联到每个需要它们的文件中。为了讨论第二个反对意见,让我们看一个最终示例:

ts
// @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 中使用。如果没有 allowSyntheticDefaultImportsesModuleInterop,从 "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 node16nodenext 下理解并捕获这一点。)但值得注意的是,第二个 doSomethingElse 的导入,在编译为 CommonJS 时需要 esModuleInterop 才能工作,在真正的 ESM 中工作得很好。

如果这里有什么可以抱怨的,那并不是 esModuleInterop 对第二个导入所做的事情。它所做的更改,既允许默认导入又防止可调用的命名空间导入,完全符合 Node.js 真正的 ESM/CJS 互操作策略,并使向真正 ESM 的迁移变得更容易。如果有问题的话,问题在于 esModuleInterop 似乎未能为第一个导入提供无缝的迁移路径。但这个问题并不是通过启用 esModuleInterop 引入的;第一个导入完全不受其影响。不幸的是,如果不破坏 main.tssayHello.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 属性访问:

js
import pkg from "pkg";
pkg.default();

在大多数打包器或转译后的 ESM 中可以作为默认导入本身访问:

js
import pkg from "pkg";
pkg();

而在原生 CommonJS 中则可以作为 require 调用的 default 属性访问:

js
const pkg = require("pkg");
pkg.default();

如果用户必须访问默认导入的 .default 属性,他们会检测到模块配置错误的“味道”,如果他们试图编写既能在 Node.js 中运行又能在打包器中运行的代码,他们可能会陷入困境。一些第三方 TypeScript 转译器暴露了改变默认导出发出方式的选项以减轻这种差异,但它们不产生自己的声明 (.d.ts) 文件,因此这在运行时行为和类型检查之间产生了不匹配,进一步使用户困惑和沮丧。与其使用默认导出,作为 CommonJS 分发的库应该为具有单个主要导出的模块使用 export =,或者为具有多个导出的模块使用命名导出。

diff
- export default function doSomething() { /* ... */ }
+ export = function doSomething() { /* ... */ }

库(发布声明文件的)还应格外小心,确保它们编写的类型在广泛的编译器选项下是无错误的。例如,可以编写一个扩展另一个接口的接口,使其仅在 strictNullChecks 禁用时才能成功编译。如果一个库发布了这样的类型,它将强迫其所有用户也禁用 strictNullChecksesModuleInterop 可以允许类型声明包含类似的“传染性”默认导入。

ts
// @Filename: /node_modules/dependency/index.d.ts
import express from "express";
declare function doSomething(req: express.Request): any;
export = doSomething;

假设这个默认导入在启用 esModuleInterop 时有效,并且在没有该选项的用户引用此文件时导致错误。用户可能应该无论如何都要启用 esModuleInterop,但通常认为库使其配置具有传染性是不好的做法。库发布如下这样的声明文件要好得多:

ts
import express = require("express");
// ...

这样的例子导致了一种传统的观点,即库应该启用 esModuleInterop。这个建议是一个合理的起点,但我们已经看过示例,在启用 esModuleInterop 时,命名空间导入的类型会发生变化,从而可能引入错误。因此,无论库是在启用还是禁用 esModuleInterop 的情况下编译,它们都冒着编写会导致其选择具有传染性的语法的风险。

想要超越自我以确保最大兼容性的库作者,最好根据编译器选项矩阵验证其声明文件。但使用 verbatimModuleSyntax 可以通过强制发出 CommonJS 的文件使用 CommonJS 样式的导入和导出语法,从而完全避开 esModuleInterop 的问题。此外,由于 esModuleInterop 仅影响 CommonJS,随着越来越多的库随着时间的推移转向仅发布 ESM,此问题的相关性将会下降。

TypeScript 文档是一个开源项目。通过发送 Pull Request 帮助我们改进这些页面 ❤

此页面的贡献者
ABAndrew Branch (9)
XZXavier Zhou (1)
DRDiego Rodrigues (1)
KKnorpelSenf (1)

最后更新:2026 年 3 月 27 日