模块 - ESM/CJS 互操作性

设想回到 2015 年,你正在编写一个把 ESM 转译成 CJS 的转译器。当时并没有关于如何做到这一点的规范;你只有一份描述 ES 模块之间应如何交互的规范、关于 CommonJS 模块之间如何交互的知识,以及一套自己摸索出来的办法。考虑下面这个导出内容的 ES 模块:

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

你会如何把它转换成 CommonJS 模块?回想一下,默认导出不过是带有特殊语法的具名导出,那么似乎只有一种选择:

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 降级为 CJS,让用户能在运行时真正支持之前就使用新语法。当时甚至有一种观念,认为编写 ESM 代码是为新项目“面向未来”做准备的好方法。要让它成立,就必须有一条无缝的迁移路径:一旦运行时支持 ESM,就能从执行转译器的 CJS 输出平滑过渡到原生执行 ESM 输入。目标就是找到一种把 ESM 降级为 CJS 的方式,使得在未来运行时中,任何或所有此类转译输出都能被其真正的 ESM 输入替换,而行为没有任何可观察的变化。

遵循规范,转译器很容易找到一组转换,使其转译出的 CommonJS 输出的语义与 ESM 输入所规定的语义一致(箭头表示导入):

然而,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:

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.

唯一的变通办法是让用户回退到更早的 TypeScript 导入语法,即表示 CommonJS require 的写法:

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

迫使用户回退到非 ESM 语法,实质上等于承认“我们不知道未来 ESM 导入将如何、或者是否能够访问像 "./exports-function" 这样的 CJS 模块,但我们知道用 import * 不能做到,尽管在我们使用的转译方案中它在运行时可以工作”。这不符合让该文件无需修改就能迁移到真正 ESM 的目标,但允许 import * 链接到一个函数的替代方案同样不符合。在禁用 allowSyntheticDefaultImports 和 esModuleInterop 时,TypeScript 至今仍是这种行为。

遗憾的是,这样说略微过于简化——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 条件判断的 CommonJS 输出方式。(另一个针对 import * 的新输出辅助函数确保结果始终是对象,并剥离调用签名,从而彻底解决了前述“解析为非模块实体”错误并未真正回避的规范合规性问题。)最终,在启用这个新选项后,TypeScript 的类型检查、TypeScript 的输出,以及其余转译与打包生态,就 CJS/ESM 互操作方案达成了一致:该方案符合规范,而且或许有可能被 Node 采纳。

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 一个由 ESM 转译为 CJS 的模块,因为它们在运行时都是 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 设置为 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 导入辅助函数,而不是把它们内联到每个需要它们的文件中。为了讨论第二种反对意见,让我们看最后一个示例:

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 中使用。在没有 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 属性访问:

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 时才能成功编译。如果库发布了这样的类型,就会迫使其所有用户也禁用 strictNullChecks。esModuleInterop 可能让类型声明包含类似的“传染性”默认导入:

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 可以完全绕开 esModuleInterop 带来的问题,它强制以 CommonJS 形式输出的文件使用 CommonJS 风格的导入和导出语法。此外,由于 esModuleInterop 只影响 CommonJS,随着越来越多的库转向仅发布 ESM,这个问题的相关性也会下降。

TypeScript 文档是一个开源项目。欢迎提交 Pull Request,帮助我们改进这些页面 ❤

最后更新:2026 年 10 月 23 日