模块 - ESM/CJS 互操作性

现在是 2015 年,你正在编写一个 ESM 到 CJS 的转译器。没有具体说明如何执行此操作;你所拥有的只是 ES 模块如何相互交互的规范、CommonJS 模块如何相互交互的知识以及解决问题的技巧。考虑一个导出 ES 模块:

¥It’s 2015, and you’re writing an ESM-to-CJS transpiler. There’s no specification for how to do this; all you have is a specification of how ES modules are supposed to interact with each other, knowledge of how CommonJS modules interact with each other, and a knack for figuring things out. Consider an exporting ES module:

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

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

¥How would you turn this into a CommonJS module? Recalling that default exports are just named exports with special syntax, there seems to be only one choice:

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

这是一个很好的模拟,它可以让你在导入端实现类似的功能:

¥This is a nice analog, and it lets you implement a similar on the importing side:

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 世界中的一切一一对应。将上述等式进一步扩展,我们可以看到:

¥So far, everything in CJS-world matches up one-to-one with everything in ESM-world. Extending the equivalence above one step further, we can see that we also have:

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 被分配了函数、类或基础类型:

¥You might notice that in this scheme, there’s no way to write an ESM export that produces an output where exports is assigned a function, class, or primitive:

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

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

¥But existing CommonJS modules frequently take this form. How might an ESM import, processed with our transpiler, access this module? We just established that a namespace import (import *) transpiles to a plain require call, so we can support an input like:

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

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

¥Our output works at runtime, but we have a compliance problem: according to the JavaScript specification, a namespace import always resolves to a Module Namespace Object, that is, an object whose members are the exports of the module. In this case, require would return the function hello, but import * can never return a function. The correspondence we assumed appears invalid.

值得退一步并澄清目标是什么。一旦模块进入 ES2015 规范,转译器就出现了,支持将 ESM 降级为 CJS,允许用户在运行时实现对它的支持之前就采用新语法。甚至有人认为编写 ESM 代码是“面向未来”新项目的好方法。为此,一旦运行时开发了对它的支持,就需要有一个从执行转译器的 CJS 输出到本地执行 ESM 输入的无缝迁移路径。目标是找到一种将 ESM 降级为 CJS 的方法,允许在未来运行时将任何或所有转译输出替换为其真正的 ESM 输入,而行为不会发生明显变化。

¥It’s worth taking a step back here and clarifying what the goal is. As soon as modules landed in the ES2015 specification, transpilers emerged with support for downleveling ESM to CJS, allowing users to adopt the new syntax long before runtimes implemented support for it. There was even a sense that writing ESM code was a good way to “future-proof” new projects. For this to be true, there needed to be a seamless migration path from executing the transpilers’ CJS output to executing the ESM input natively once runtimes developed support for it. The goal was to find a way to downlevel ESM to CJS that would allow any or all of those transpiled outputs to be replaced by their true ESM inputs in a future runtime, with no observable change in behavior.

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

¥By following the specification, it was easy enough for transpilers to find a set of transformations that made the semantics of their transpiled CommonJS outputs match the specified semantics of their ESM inputs (arrows represent imports):

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,而不是 ESM 转换为 CommonJS)在 Node.js 生态系统中已经很成熟,因此,编写为 ESM 并转换为 CJS 的模块将不可避免地开始“导入”编写为 CommonJS 的模块 。不过,ES2015 并未指定这种互操作性的行为,并且在任何实际运行时中还不存在。

¥However, CommonJS modules (written as CommonJS, not as ESM transpiled to CommonJS) were already well-established in the Node.js ecosystem, so it was inevitable that modules written as ESM and transpiled to CJS would start “importing” modules written as CommonJS. The behavior for this interoperability, though, was not specified by ES2015, and didn’t yet exist in any real runtime.

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,该行为必须与运行时选择实现的行为相匹配。

¥Even if transpiler authors did nothing, a behavior would emerge from the existing semantics between the require calls they emitted in transpiled code and the exports defined in existing CJS modules. And to allow users to transition seamlessly from transpiled ESM to true ESM once their runtime supported it, that behavior would have to match the one the runtime chose to implement.

猜测互操作行为运行时将支持什么也不限于 ESM 导入“真正的 CJS”模块。ESM 是否能够识别 ESM-transpiled-from-CJS 与 CJS 不同,以及 CJS 是否能够 require ES 模块,也未指定。即使 ESM 导入是否会使用与 CJS require 调用相同的模块解析算法也是未知的。所有这些变量都必须正确预测,以便为转译器用户提供向原生 ESM 的无缝迁移路径。

¥Guessing what interop behavior runtimes would support wasn’t limited to ESM importing “true CJS” modules either. Whether ESM would be able to recognize ESM-transpiled-from-CJS as distinct from CJS, and whether CJS would be able to require ES modules, were also unspecified. Even whether ESM imports would use the same module resolution algorithm as CJS require calls was unknowable. All these variables would have to be predicted correctly in order to give transpiler users a seamless migration path toward native ESM.

allowSyntheticDefaultImportsesModuleInterop

¥allowSyntheticDefaultImports and esModuleInterop

让我们回到规范合规性问题,其中 import * 转换为 require

¥Let’s return to our specification compliance problem, where import * transpiles to 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 不是类命名空间对象的模块的任何命名空间导入上触发错误来解决此问题:

¥When TypeScript first added support for writing and transpiling ES modules, the compiler addressed this problem by issuing an error on any namespace import of a module whose exports was not a namespace-like object:

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 导入语法:

¥The only workaround was for users to go back to using the older TypeScript import syntax representing a CommonJS require:

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

强制用户恢复到非 ESM 语法本质上是承认“我们不知道未来如何或是否可以通过 ESM 导入访问像 "./exports-function" 这样的 CJS 模块,但我们知道它不能通过 import * 访问,即使 尽管它会在我们使用的转译方案中运行时工作。” 它不能满足允许该文件在不进行任何更改的情况下迁移到真正的 ESM 的目标,但允许 import * 链接到函数的替代方案也不能满足这一目标。当 allowSyntheticDefaultImportsesModuleInterop 被禁用时,这仍然是当今 TypeScript 中的行为。

¥Forcing users to revert to non-ESM syntax was essentially an admission that “we don’t know how or if a CJS module like "./exports-function" will be accessible with ESM imports in the future, but we know it can’t be with import *, even though it will work at runtime in the transpilation scheme we’re using.” It doesn’t meet the goal of allowing this file to be migrated to real ESM without changes, but neither does the alternative of allowing the import * to link to a function. This is still the behavior in TypeScript today when allowSyntheticDefaultImports and esModuleInterop are disabled.

不幸的是,这有点过于简单化了 - TypeScript 并没有完全避免这个错误的合规性问题,因为它允许函数的命名空间导入工作,并保留它们的调用签名,只要函数声明与命名空间声明合并 - 甚至 如果命名空间为空。因此,虽然导出裸函数的模块被识别为“非模块实体”:

¥Unfortunately, this is a slight oversimplification—TypeScript didn’t fully avoid the compliance issue with this error, because it allowed namespace imports of functions to work, and retain their call signatures, as long as the function declaration merged with a namespace declaration—even if the namespace was empty. So while a module exporting a bare function was recognized as a “non-module entity”:

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

一个应该是无意义的更改允许无效导入进行类型检查而不会出现错误:

¥A should-be-meaningless change allowed the invalid import to type check without errors:

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

与此同时,其他转译器也想出了解决同样问题的方法。思考过程是这样的:

¥Meanwhile, other transpilers were coming up with a way to solve the same problem. The thought process went something like this:

  1. 要导入导出函数或基础类型的 CJS 模块,我们显然需要使用默认导入。命名空间导入是非法的,命名导入在这里没有意义。

    ¥To import a CJS module that exports a function or a primitive, we clearly need to use a default import. A namespace import would be illegal, and named imports don’t make sense here.

  2. 最有可能的是,这意味着实现 ESM/CJS 互操作的运行时将选择默认导入 CJS 模块始终直接链接到整个 exports,而不是仅在 exports 是函数或基础类型时才这样做。

    ¥Most likely, this means that runtimes implementing ESM/CJS interop will choose to make default imports of CJS modules always link directly to the whole exports, rather than only doing so if the exports is a function or primitive.

  3. 因此,真正的 CJS 模块的默认导入应该像 require 调用一样工作。但是我们需要一种方法来消除真正的 CJS 模块与转译的 CJS 模块的歧义,因此我们仍然可以将 export default "hello" 转译为 exports.default = "hello",并默认导入该模块链接到 exports.default。基本上,我们自己的一个转译模块的默认导入需要以一种方式工作(模拟 ESM 到 ESM 导入),而任何其他现有 CJS 模块的默认导入需要以另一种方式工作(模拟我们如何看待 ESM) -to-CJS 导入将起作用)。

    ¥So, a default import of a true CJS module should work just like a require call. But we’ll need a way to disambiguate true CJS modules from our transpiled CJS modules, so we can still transpile export default "hello" to exports.default = "hello" and have a default import of that module link to exports.default. Basically, a default import of one of our own transpiled modules needs to work one way (to simulate ESM-to-ESM imports), while a default import of any other existing CJS module needs to work another way (to simulate how we think ESM-to-CJS imports will work).

  4. 当我们将 ES 模块转译为 CJS 时,让我们在输出中添加一个特殊的额外字段:

    ¥When we transpile an ES module to CJS, let’s add a special extra field to the output:

    ts
    exports.A = {};
    exports.B = {};
    exports.default = "Hello, world!";
    // Extra special flag!
    exports.__esModule = true;

    当我们转译默认导入时我们可以检查:

    ¥that we can check for when we transpile a default import:

    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 * 是一个错误:

¥The __esModule flag first appeared in Traceur, then in Babel, SystemJS, and Webpack shortly after. TypeScript added the allowSyntheticDefaultImports in 1.8 to allow the type checker to link default imports directly to the exports, rather than the exports.default, of any module types that lacked an export default declaration. The flag didn’t modify how imports or exports were emitted, but it allowed default imports to reflect how other transpilers would treat them. Namely, it allowed a default import to be used to resolve to “non-module entities,” where import * was an error:

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 抗诉,但这只是部分解决方案,留下了一些未解决的问题:

¥This was usually enough to let Babel and Webpack users write code that already worked in those systems without TypeScript complaining, but it was only a partial solution, leaving a few issues unsolved:

  1. Babel 和其他人根据是否在目标模块上找到 __esModule 属性来改变默认导入行为,但 allowSyntheticDefaultImports 仅在目标模块类型中未找到默认导出时启用回退行为。如果目标模块具有 __esModule 标志但没有默认导出,则会产生不一致。转译器和打包器仍会将此类模块的默认导入链接到其 exports.default,即 undefined,并且理想情况下会在 TypeScript 中出现错误,因为真正的 ESM 导入如果无法链接,则会导致错误。但对于 allowSyntheticDefaultImports,TypeScript 会认为此类导入的默认导入链接到整个 exports 对象,从而允许命名导出作为其属性进行访问。

    ¥Babel and others varied their default import behavior on whether an __esModule property was found on the target module, but allowSyntheticDefaultImports only enabled a fallback behavior when no default export was found in the target module’s types. This created an inconsistency if the target module had an __esModule flag but no default export. Transpilers and bundlers would still link a default import of such a module to its exports.default, which would be undefined, and would ideally be an error in TypeScript, since real ESM imports cause errors if they can’t be linked. But with allowSyntheticDefaultImports, TypeScript would think a default import of such an import links to the whole exports object, allowing named exports to be accessed as its properties.

  2. allowSyntheticDefaultImports 没有改变命名空间导入的类型,造成了奇怪的不一致,两者都可以使用并且具有相同的类型:

    ¥allowSyntheticDefaultImports didn’t change how namespace imports were typed, creating an odd inconsistency where both could be used and would have the same type:

    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.js 中崩溃的代码。

    ¥Most importantly, allowSyntheticDefaultImports did not change the JavaScript emitted by tsc. So while the flag enabled more accurate checking as long as the code was fed into another tool like Babel or Webpack, it created a real danger for users who were emitting --module commonjs with tsc and running in Node.js. If they encountered an error with import *, it may have appeared as if enabling allowSyntheticDefaultImports would fix it, but in fact it only silenced the build-time error while emitting code that would crash in Node.

TypeScript 在 2.7 中引入了 esModuleInterop 标志,它改进了导入的类型检查,以解决 TypeScript 的分析与现有转译器和打包器中使用的互操作行为之间剩余的不一致问题,并且至关重要的是,采用了转译器多年来采用的相同 __esModule 条件 CommonJS 触发 前。(import * 的另一个新的触发助手确保结果始终是一个对象,调用签名被剥离,完全解决了前面提到的“解析为非模块实体”错误并没有完全回避的规范合规性问题。)最后,启用新标志后,TypeScript 的类型检查、TypeScript 的触发以及转译和打包生态系统的其余部分就 CJS/ESM 互操作方案达成一致,该方案是规范合法的,并且也许可以被 Node 采用。

¥TypeScript introduced the esModuleInterop flag in 2.7, which refined the type checking of imports to address the remaining inconsistencies between TypeScript’s analysis and the interop behavior used in existing transpilers and bundlers, and critically, adopted the same __esModule-conditional CommonJS emit that transpilers had adopted years before. (Another new emit helper for import * ensured the result was always an object, with call signatures stripped, fully resolving the specification compliance issue that the aforementioned “resolves to a non-module entity” error didn’t quite sidestep.) Finally, with the new flag enabled, TypeScript’s type checking, TypeScript’s emit, and the rest of the transpiling and bundling ecosystem were in agreement on a CJS/ESM interop scheme that was spec-legal and, perhaps, plausibly adoptable by Node.

Node.js 中的互操作

¥Interop in Node.js

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

¥Node.js shipped support for ES modules unflagged in v12. Like the bundlers and transpilers began doing years before, Node.js gave CommonJS modules a “synthetic default export” of their exports object, allowing the entire module contents to be accessed with a default import from ESM:

ts
// @Filename: export.cjs
module.exports = { hello: "world" };
// @Filename: import.mjs
import greeting from "./export.cjs";
greeting.hello; // "world"

这是无缝迁移的一大胜利!不幸的是,相似之处大多到此为止。

¥That’s one win for seamless migration! Unfortunately, the similarities mostly end there.

没有检测到 __esModule(“双重默认”问题)

¥No __esModule detection (the “double default” problem)

Node.js 无法遵循 __esModule 标记来改变其默认导入行为。因此,具有“默认导出”的转译模块在被另一个转译模块“导入”时以一种方式运行,而在被 Node.js 中的真正 ES 模块导入时以另一种方式运行:

¥Node.js wasn’t able to respect the __esModule marker to vary its default import behavior. So a transpiled module with a “default export” behaves one way when “imported” by another transpiled module, and another way when imported by a true ES module in Node.js:

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 trasnpilation, but works in Node.js ESM:
doSomething.default();

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

¥While the transpiled default import only makes the synthetic default export if the target module lacks an __esModule flag, Node.js always synthesizes a default export, creating a “double default” on the transpiled module.

不可靠的命名导出

¥Unreliable named exports

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

¥In addition to making a CommonJS module’s exports object available as a default import, Node.js attempts to find properties of exports to make available as named imports. This behavior matches bundlers and transpilers when it works; however, Node.js uses syntactic analysis to synthesize named exports before any code executes, whereas transpiled modules resolve their named imports at runtime. The result is that imports from CJS modules that work in transpiled modules may not work in 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 ✅

无法 require 真正的 ES 模块

¥Cannot require a true ES module

真正的 CommonJS 模块可以将 ESM 转换为 CJS 模块,因为它们在运行时都是 CommonJS。但在 Node.js 中,如果 require 解析为 ES 模块,则会崩溃。这意味着已发布的库无法从转译模块迁移到真正的 ESM,而不破坏其 CommonJS(真正的或转译的)使用者:

¥True CommonJS modules can require an ESM-transpiled-to-CJS module, since they’re both CommonJS at runtime. But in Node.js, require crashes if it resolves to an ES module. This means published libraries cannot migrate from transpiled modules to true ESM without breaking their CommonJS (true or transpiled) consumers:

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

不同的模块解析算法

¥Different module resolution algorithms

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

¥Node.js introduced a new module resolution algorithm for resolving ESM imports that differed significantly from the long-standing algorithm for resolving require calls. While not directly related to interop between CJS and ES modules, this difference was one more reason why a seamless migration from transpiled modules to true ESM might not be possible:

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.

结论

¥Conclusions

显然,从转译模块无缝迁移到 ESM 是不可能的,至少在 Node.js 中是这样。这给我们留下了什么?

¥Clearly, a seamless migration from transpiled modules to ESM isn’t possible, at least in Node.js. Where does this leave us?

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

¥Setting the right module compiler option is critical

由于宿主之间的互操作性规则不同,TypeScript 无法提供正确的检查行为,除非它了解它看到的每个文件代表什么类型的模块,以及应用于它们的规则集。这就是 module 编译器选项的目的。(特别是,打算在 Node.js 中运行的代码比将由打包器处理的代码受到更严格的规则。编译器的输出不会检查 Node.js 兼容性,除非 module 设置为 node16nodenext。)

¥Since interoperability rules differ between hosts, TypeScript can’t offer correct checking behavior unless it understands what kind of module is represented by each file it sees, and what set of rules to apply to them. This is the purpose of the module compiler option. (In particular, code that is intended to run in Node.js is subject to stricter rules than code that will be processed by a bundler. The compiler’s output is not checked for Node.js compatibility unless module is set to node16 or nodenext.)

使用 CommonJS 代码的应用应始终启用 esModuleInterop

¥Applications with CommonJS code should always enable esModuleInterop

在使用 tsc 触发 JavaScript 文件的 TypeScript 应用(而不是其他人可能使用的库)中,是否启用 esModuleInterop 不会产生重大后果。为某些类型的模块编写导入的方式将会改变,但 TypeScript 的检查和触发是同步的,因此无错误的代码应该可以安全地在任一模式下运行。在这种情况下禁用 esModuleInterop 的缺点是,它允许你编写语义明显违反 ECMAScript 规范的 JavaScript 代码,混淆有关命名空间导入的直觉,并使将来更难迁移到运行 ES 模块。

¥In a TypeScript application (as opposed to a library that others may consume) where tsc is used to emit JavaScript files, whether esModuleInterop is enabled doesn’t have major consequences. The way you write imports for certain kinds of modules will change, but TypeScript’s checking and emit are in sync, so error-free code should be safe to run in either mode. The downside of leaving esModuleInterop disabled in this case is that it allows you to write JavaScript code with semantics that clearly violate the ECMAScript specification, confusing intuitions about namespace imports and making it harder to migrate to running ES modules in the future.

另一方面,在由第三方转译器或打包器处理的应用中,启用 esModuleInterop 更为重要。所有主要的打包器和转译器都使用类似 esModuleInterop 的触发策略,因此 TypeScript 需要调整其检查以匹配。(编译器总是会推断 tsc 将触发的 JavaScript 文件中会发生什么,因此即使使用另一个工具代替 tsc,影响触发的编译器选项仍应设置为与该工具的输出尽可能匹配 可能的。)

¥In an application that gets processed by a third-party transpiler or bundler, on the other hand, enabling esModuleInterop is more important. All major bundlers and transpilers use an esModuleInterop-like emit strategy, so TypeScript needs to adjust its checking to match. (The compiler always reasons about what will happen in the JavaScript files that tsc would emit, so even if another tool is being used in place of tsc, emit-affecting compiler options should still be set to match the output of that tool as closely as possible.)

应避免没有 esModuleInteropallowSyntheticDefaultImports。它改变了编译器的检查行为,而不改变 tsc 触发的代码,从而允许触发潜在不安全的 JavaScript。此外,它引入的检查更改是 esModuleInterop 引入的检查更改的不完整版本。即使 tsc 没有用于触发,启用 esModuleInterop 也比启用 allowSyntheticDefaultImports 更好。

¥allowSyntheticDefaultImports without esModuleInterop should be avoided. It changes the compiler’s checking behavior without changing the code emitted by tsc, allowing potentially unsafe JavaScript to be emitted. Additionally, the checking changes it introduces are an incomplete version of the ones introduced by esModuleInterop. Even if tsc isn’t being used for emit, it’s better to enable esModuleInterop than allowSyntheticDefaultImports.

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

¥Some people object to the inclusion of the __importDefault and __importStar helper functions included in tsc’s JavaScript output when esModuleInterop is enabled, either because it marginally increases the output size on disk or because the interop algorithm employed by the helpers seems to misrepresent Node.js’s interop behavior by checking for __esModule, leading to the hazards discussed earlier. Both of these objections can be addressed, at least partially, without accepting the flawed checking behavior exhibited with esModuleInterop disabled. First, the importHelpers compiler option can be used to import the helper functions from tslib rather than inlining them into each file that needs them. To discuss the second objection, let’s look at a final example:

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,显示的导入都不是错误(并且所有导入都是可调用的),但无效的命名空间导入将被捕获。

¥Assume we’re compiling src to CommonJS for use in Node.js. Without allowSyntheticDefaultImports or esModuleInterop, the import of doSomethingElse from "true-cjs-dependency" is an error, and the others are not. To fix the error without changing any compiler options, you could change the import to import doSomethingElse = require("true-cjs-dependency"). However, depending on how the types for the module (not shown) are written, you may also be able to write and call a namespace import, which would be a language-level specification violation. With esModuleInterop, none of the imports shown are errors (and all are callable), but the invalid namespace import would be caught.

如果我们决定将 src 迁移到 Node.js 中的真正 ESM(例如,将 "type": "module" 添加到我们的根 package.json),会发生什么变化?第一个导入,来自 "transpiled-dependency"doSomething,将不再是可调用的 - 它表现出“双重默认”问题,我们必须调用 doSomething.default() 而不是 doSomething()。(TypeScript 在 --module node16nodenext 下理解并捕获这一点。)但值得注意的是,doSomethingElse 的第二次导入(在编译为 CommonJS 时需要 esModuleInterop 才能工作)在真正的 ESM 中运行良好。

¥What would change if we decided to migrate src to true ESM in Node.js (say, add "type": "module" to our root package.json)? The first import, doSomething from "transpiled-dependency", would no longer be callable—it exhibits the “double default” problem, where we’d have to call doSomething.default() rather than doSomething(). (TypeScript understands and catches this under --module node16 and nodenext.) But notably, the second import of doSomethingElse, which needed esModuleInterop to work when compiling to CommonJS, works fine in true ESM.

如果这里有什么可抗诉的,那就不是 esModuleInterop 在第二次导入时所做的事情。它所做的更改,既允许默认导入,又阻止可调用命名空间导入,完全符合 Node.js 真正的 ESM/CJS 互操作策略,并使迁移到真正的 ESM 变得更容易。问题(如果有的话)是 esModuleInterop 似乎无法为我们的第一次导入提供无缝迁移路径。但这个问题并不是通过启用 esModuleInterop 引入的;第一次导入完全不受它的影响。不幸的是,如果不破坏 main.tssayHello.ts 之间的语义契约,这个问题就无法解决,因为 sayHello.ts 的 CommonJS 输出在结构上看起来与 transpiled-dependency/index.js 相同。如果 esModuleInteropdoSomething 的转译导入的工作方式更改为与 Node.js ESM 中的工作方式相同,那么它会以相同的方式更改 sayHello 导入的行为,使输入代码违反 ESM 语义(因此仍然 防止 src 目录在没有更改的情况下迁移到 ESM)。

¥If there’s something to complain about here, it’s not what esModuleInterop does with the second import. The changes it makes, both allowing the default import and preventing callable namespace imports, are exactly in line with Node.js’s real ESM/CJS interop strategy, and made migration to real ESM easier. The problem, if there is one, is that esModuleInterop seems to fail at giving us a seamless migration path for the first import. But this problem was not introduced by enabling esModuleInterop; the first import was completely unaffected by it. Unfortunately, this problem cannot be solved without breaking the semantic contract between main.ts and sayHello.ts, because the CommonJS output of sayHello.ts looks structurally identical to transpiled-dependency/index.js. If esModuleInterop changed the way the transpiled import of doSomething works to be identical to the way it would work in Node.js ESM, it would change the behavior of the sayHello import in the same way, making the input code violate ESM semantics (thus still preventing the src directory from being migrated to ESM without changes).

正如我们所看到的,从转译模块到真正的 ESM 不存在无缝迁移路径。但 esModuleInterop 是朝着正确方向迈出的一步。对于那些仍然喜欢最小化模块语法转换和包含导入辅助函数的人来说,启用 verbatimModuleSyntax 是比禁用 esModuleInterop 更好的选择。verbatimModuleSyntax 强制在 CommonJS 触发的文件中使用 import mod = require("mod")export = ns 语法,避免我们讨论过的所有类型的导入歧义,但代价是难以迁移到真正的 ESM。

¥As we’ve seen, there is no seamless migration path from transpiled modules to true ESM. But esModuleInterop is one step in the right direction. For those who still prefer to minimize module syntax transformations and the inclusion of the import helper functions, enabling verbatimModuleSyntax is a better choice than disabling esModuleInterop. verbatimModuleSyntax enforces that the import mod = require("mod") and export = ns syntax be used in CommonJS-emitting files, avoiding all the kinds of import ambiguity we’ve discussed, at the cost of ease of migration to true ESM.

库代码需要特殊考虑

¥Library code needs special considerations

作为 CommonJS 提供的库应该避免使用默认导出,因为访问这些转译导出的方式在不同的工具和运行时之间有所不同,并且其中一些方式会让用户感到困惑。默认导出由 tsc 转换为 CommonJS,可在 Node.js 中作为默认导入的默认属性进行访问:

¥Libraries that ship as CommonJS should avoid using default exports, since the way those transpiled exports can be accessed varies between different tools and runtimes, and some of those ways will look confusing to users. A default export, transpiled to CommonJS by tsc, is accessible in Node.js as the default property of a default import:

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

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

¥in most bundlers or transpiled ESM as the default import itself:

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

在普通 CommonJS 中作为 require 调用的默认属性:

¥and in vanilla CommonJS as the default property of a require call:

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

如果用户必须访问默认导入的 .default 属性,那么他们会检测到配置错误的模块气味,并且如果他们尝试编写在 Node.js 和打包器中运行的代码,他们可能会被卡住。一些第三方 TypeScript 转译器公开了一些选项,这些选项可以更改默认导出的触发方式以减轻这种差异,但它们不会生成自己的声明 (.d.ts) 文件,因此这会在运行时行为和类型检查之间造成不匹配,进一步 使用户感到困惑和沮丧。需要作为 CommonJS 发布的库不应使用默认导出,而应为具有单个主导出的模块使用 export =,或者为具有多个导出的模块使用命名导出:

¥Users will detect a misconfigured module smell if they have to access the .default property of a default import, and if they’re trying to write code that will run both in Node.js and a bundler, they might be stuck. Some third-party TypeScript transpilers expose options that change the way default exports are emitted to mitigate this difference, but they don’t produce their own declaration (.d.ts) files, so that creates a mismatch between the runtime behavior and the type checking, further confusing and frustrating users. Instead of using default exports, libraries that need to ship as CommonJS should use export = for modules that have a single main export, or named exports for modules that have multiple exports:

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

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

¥Libraries (that ship declaration files) should also take extra care to ensure the types they write are error-free under a wide range of compiler options. For example, it’s possible to write one interface that extends another in such a way that it only compiles successfully when strictNullChecks is disabled. If a library were to publish types like that, it would force all their users to disable strictNullChecks too. esModuleInterop can allow type declarations to contain similarly “infectious” default imports:

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

假设此默认导入仅在启用 esModuleInterop 的情况下工作,并且当没有该选项的用户引用此文件时会导致错误。无论如何,用户可能应该启用 esModuleInterop,但对于库来说,让其配置像这样具有传染性通常被视为不好的形式。库最好提供如下声明文件:

¥Suppose this default import only works with esModuleInterop enabled, and causes an error when a user without that option references this file. The user should probably enable esModuleInterop anyway, but it’s generally seen as bad form for libraries to make their configurations infectious like this. It would be much better for the library to ship a declaration file like:

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

像这样的例子导致了一种传统观点:库不应该启用 esModuleInterop。这个建议是一个合理的开始,但我们已经研究了启用 esModuleInterop 时名称空间导入类型发生变化的示例,可能会引入错误。因此,无论库是否使用 esModuleInterop 进行编译,它们都面临着编写使它们的选择具有感染力的语法的风险。

¥Examples like this have led to conventional wisdom that says libraries should not enable esModuleInterop. This advice is a reasonable start, but we’ve looked at examples where the type of a namespace import changes, potentially introducing an error, when enabling esModuleInterop. So whether libraries compile with or without esModuleInterop, they run the risk of writing syntax that makes their choice infectious.

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

¥Library authors who want to go above and beyond to ensure maximum compatibility would do well to validate their declaration files against a matrix of compiler options. But using verbatimModuleSyntax completely sidesteps the issue with esModuleInterop by forcing CommonJS-emitting files to use CommonJS-style import and export syntax. Additionally, since esModuleInterop only affects CommonJS, as more libraries move to ESM-only publishing over time, the relevance of this issue will decline.