现在是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:
tsexport 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:
tsexports.A = {};exports.B = {};exports.default = "Hello, world!";
这是一个很好的模拟,它可以让你在导入端实现类似的功能:
🌐 This is a nice analog, and it lets you implement a similar on the importing side:
tsimport 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:
tsimport * 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.jsmodule.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:
tsimport * 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 输入。目标是找到一种将 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):
然而,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.
即使转译器的作者什么都没做,也会从已转译代码中生成的 require 调用与现有 CJS 模块中定义的 exports 之间的现有语义中产生一种行为。而且,为了让用户在其运行时支持真正的 ESM 时能够无缝地从转译的 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 是否能够将从 CJS 转译来的 ESM 识别为与 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.
allowSyntheticDefaultImports 和 esModuleInterop
🌐 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:
tsimport * 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:
🌐 The only workaround was for users to go back to using the older TypeScript import syntax representing a CommonJS require:
tsimport hello = require("./exports-function");
强制用户回到非 ESM 语法,本质上是承认“我们不知道 CJS 模块如 "./exports-function" 将来是否或如何能通过 ESM 导入访问,但我们知道 import * 不能,即使在我们使用的转译方案下运行时它是可行的。” 这并没有达到允许该文件无需修改就迁移到真实 ESM 的目标,但允许 import * 链接到函数的替代方案也同样不能做到。今天在 TypeScript 中,当 allowSyntheticDefaultImports 和 esModuleInterop 被禁用时,这仍然是现象行为。
🌐 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 并没有完全避免这个错误引发的合规性问题,因为它允许函数的命名空间导入可以正常工作,并保留它们的调用签名,只要函数声明与命名空间声明合并——即使该命名空间是空的。因此,当一个模块导出一个裸函数时,它会被识别为“非模块实体”:
tsdeclare function $(selector: string): any;export = $; // Cannot `import *` this 👍A should-be-meaningless change allowed the invalid import to type check without errors:
tsdeclare 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:
-
要导入一个导出函数或原始值的 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 时,让我们在输出中添加一个特殊的额外字段:
tsexports.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,允许类型检查器将默认导入直接链接到 exports,而不是任何缺少 export default 声明的模块类型的 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:
-
Babel 和其他工具会根据目标模块上是否存在
__esModule属性来改变它们的默认导入行为,但allowSyntheticDefaultImports只在目标模块的类型中未找到默认导出时启用一种_回退_行为。如果目标模块有__esModule标志但没有默认导出,这就会产生不一致。转译器和打包工具仍然会将对该模块的默认导入链接到它的exports.default,其值为undefined,在 TypeScript 中理想情况下这应被视为错误,因为真正的 ESM 导入如果无法链接会导致错误。但在使用allowSyntheticDefaultImports时,TypeScript 会认为这种导入的默认导入链接到整个exports对象,从而允许将命名导出作为其属性访问。 -
allowSyntheticDefaultImports并没有改变命名空间导入的类型定义,这造成了一个奇怪的不一致:两者都可以使用,并且类型是一样的:ts// @Filename: exportEqualsObject.d.tsdeclare const obj: object;export = obj;// @Filename: main.tsimport 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")'. -
最重要的是,
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 采纳。
🌐 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.cjsmodule.exports = { hello: "world" };// @Filename: import.mjsimport 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.jsexports.__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 总是 会合成默认导出,从而在转译模块上创建一个“双重默认”。
🌐 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.cjsexports.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 模块使用
🌐 Cannot require a true ES module before Node.js v22
真正的 CommonJS 模块可以 require 一个被 ESM 转译为 CJS 的模块,因为在运行时它们都是 CommonJS。但在低于 v22.12.0 的 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 versions older than v22.12.0, 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.jsexport function doSomething() { /* ... */ }// @Filename: dependent.jsimport { 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.jsexport function add(a, b) {return a + b;}// @Filename: math.jsexport * 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 设置为 node16、node18 或 nodenext。)
🌐 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, node18, or nodenext.)
使用 CommonJS 代码的应用应始终启用 esModuleInterop
🌐 Applications with CommonJS code should always enable esModuleInterop
在一个 TypeScript 应用(与其他人可能使用的库相对)中,如果使用 tsc 来生成 JavaScript 文件,是否启用 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.)
应避免在没有使用 esModuleInterop 的情况下使用 allowSyntheticDefaultImports。它会改变编译器的检查行为,但不会改变 tsc 发出的代码,这可能导致生成不安全的 JavaScript。此外,它引入的检查更改只是 esModuleInterop 引入的检查的一部分。即使 tsc 并未用于发出代码,也最好启用 esModuleInterop 而不是 allowSyntheticDefaultImports。
有些人反对在启用 esModuleInterop 时,将 __importDefault 和 __importStar 辅助函数包含在 tsc 的 JavaScript 输出中,原因可能是它会略微增加磁盘上的输出体积,或者因为辅助函数采用的互操作算法似乎通过检查 __esModule 错误地表示了 Node.js 的互操作行为,从而导致之前讨论的风险。这两种反对意见都可以在一定程度上得到解决,而无需接受在禁用 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.jsexports.__esModule = true;exports.default = function doSomething() { /* ... */ };exports.something = "something";// @Filename: node_modules/true-cjs-dependency/index.jsmodule.exports = function doSomethingElse() { /* ... */ };// @Filename: src/sayHello.tsexport default function sayHello() { /* ... */ }export const hello = "hello";// @Filename: src/main.tsimport 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 时,显示的所有导入都不会出错(并且都可调用),但无效的命名空间导入将会被捕获。
🌐 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(例如,在我们的根 package.json 中添加 "type": "module"),会发生什么变化?第一次导入 doSomething 来自 "transpiled-dependency" 将不再可调用——它表现出“双默认”问题,我们必须调用 doSomething.default() 而不是 doSomething()。(TypeScript 在 --module node16 — nodenext 下能够理解并捕获这个问题。)但值得注意的是,对 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—nodenext.) But notably, the second import of doSomethingElse, which needed esModuleInterop to work when compiling to CommonJS, works fine in true 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 中作为默认导入的 default 属性访问:
🌐 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:
jsimport pkg from "pkg";pkg.default();
在大多数打包器或转译的 ESM 中作为默认导入本身:
🌐 in most bundlers or transpiled ESM as the default import itself:
jsimport pkg from "pkg";pkg();
在原生 CommonJS 中,它作为 require 调用的默认属性:
🌐 and in vanilla CommonJS as the default property of a require call:
jsconst 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 被禁用时才能成功编译。如果一个库发布了这样的类型,就会迫使其所有用户也必须禁用 strictNullChecks。esModuleInterop 可以允许类型声明包含类似的“传染性”默认导入:
🌐 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.tsimport 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:
tsimport 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.