现在是 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.jsmodule.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 代码是“面向未来”新项目的良好方式。为了使这成为现实,需要有一条从执行转译器的 CJS 输出到在运行时开发支持后本机执行 ESM 输入的无缝迁移路径。目标是找到一种将 ESM 降级到 CJS 的方法,该方法允许在未来的运行时中用其真正的 ESM 输入替换任何或所有这些转译后的输出,而不会观察到行为上的变化。
通过遵循规范,转译器很容易找到一组转换,这些转换使它们转译后的 CommonJS 输出的语义与它们 ESM 输入的指定语义相匹配(箭头表示导入)
但是,CommonJS 模块(以 CommonJS 编写,而不是以 ESM 转译为 CommonJS)已经在 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 语法实际上是承认“我们不知道或不知道像"./exports-function"
这样的 CJS 模块将来是否可以通过 ESM 导入访问,但我们知道它不能使用import *
,即使它将在我们使用的转译方案中在运行时工作。” 它不符合允许此文件在不进行更改的情况下迁移到真正的 ESM 的目标,但允许import *
链接到函数的替代方案也不行。这仍然是 TypeScript 在今天禁用allowSyntheticDefaultImports
和esModuleInterop
时的行为。
不幸的是,这有点过于简化了——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 😱
与此同时,其他转译器正在想出一个解决相同问题的方法。思路是这样的
- 要导入导出函数或原语的 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;ts// import hello from "./modue";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 *
是一个错误
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 报错,但这只是一个部分解决方案,还有一些问题没有解决
- 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 采用。
Node.js 中的互操作性
Node.js 在 v12 版本中未标记的情况下提供了对 ES 模块的支持。与打包器和转译器在几年前开始做的一样,Node.js 为 CommonJS 模块提供了其 `exports` 对象的“合成默认导出”,允许使用 ESM 从默认导入访问整个模块内容。
ts
// @Filename: export.cjsmodule.exports = { hello: "world" };// @Filename: import.mjsimport greeting from "./export.cjs";greeting.hello; // "world"
这对无缝迁移来说是一个胜利!不幸的是,相似之处几乎到此为止。
没有 `__esModule` 检测(“双默认”问题)
Node.js 无法尊重 `__esModule` 标记来改变其默认导入行为。因此,当被另一个转译模块“导入”时,具有“默认导出”的转译模块的行为方式不同,而当被 Node.js 中的真实 ES 模块导入时,行为方式又不同。
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 trasnpilation, 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.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 ✅
无法 `require` 真实的 ES 模块
真正的 CommonJS 模块可以require
一个被转译为 CJS 的 ESM 模块,因为它们在运行时都是 CommonJS。但在 Node.js 中,require
如果解析到一个 ES 模块就会崩溃。这意味着发布的库无法从转译的模块迁移到真正的 ESM,而不会破坏它们的 CommonJS(真正的或转译的)使用者。
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
不同的模块解析算法
Node.js 引入了一种新的模块解析算法来解析 ESM 导入,该算法与长期以来用于解析require
调用的算法有很大不同。虽然这与 CJS 和 ES 模块之间的互操作性没有直接关系,但这种差异是导致从转译的模块到真正的 ESM 的无缝迁移可能无法实现的另一个原因。
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.
结论
显然,从转译的模块到 ESM 的无缝迁移是不可能的,至少在 Node.js 中是这样。这让我们怎么办呢?
设置正确的module
编译器选项至关重要
由于互操作性规则在不同的主机之间有所不同,TypeScript 无法提供正确的检查行为,除非它了解它看到的每个文件代表哪种模块,以及对它们应用哪套规则。这就是module
编译器选项的用途。(特别是,打算在 Node.js 中运行的代码比将由捆绑器处理的代码受到更严格的规则限制。除非module
设置为node16
或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.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`,所有显示的导入都不是错误(并且都可以调用),但无效的命名空间导入会被捕获。
如果我们决定将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 的容易程度。
库代码需要特殊考虑
库(提供声明文件)应该格外注意,确保它们编写的类型在各种编译器选项下都是无错误的。例如,可以编写一个接口扩展另一个接口,但只有在禁用 `strictNullChecks` 时才能成功编译。如果库发布了这样的类型,它将迫使所有用户也禁用 `strictNullChecks`。`esModuleInterop` 可以允许类型声明包含类似的“传染性”默认导入
ts
// @Filename: /node_modules/dependency/index.d.tsimport express from "express";declare function doSomething(req: express.Request): any;export = doSomething;
假设这个默认导入 *只有* 在启用 `esModuleInterop` 时才有效,并且当没有该选项的用户引用此文件时会导致错误。用户 *可能* 应该启用 `esModuleInterop`,但通常认为库以这种方式使它们的配置具有传染性是不好的做法。库最好发布一个像这样的声明文件
ts
import express = require("express");// ...
像这样的例子导致了库 *不应该* 启用 `esModuleInterop` 的传统智慧。这个建议是一个合理的起点,但我们已经看到了当启用 `esModuleInterop` 时,命名空间导入的类型会发生变化,可能会 *引入* 错误的例子。因此,无论库是使用 `esModuleInterop` 编译还是不使用 `esModuleInterop` 编译,它们都存在编写语法使其选择具有传染性的风险。
想要超越极限以确保最大兼容性的库作者最好针对编译器选项矩阵验证他们的声明文件。但是使用 `verbatimModuleSyntax` 通过强制 CommonJS 发射文件使用 CommonJS 风格的导入和导出语法,完全避开了 `esModuleInterop` 的问题。此外,由于 `esModuleInterop` 仅影响 CommonJS,随着越来越多的库随着时间的推移迁移到仅 ESM 发布,这个问题的相关性将下降。