模块 - 理论

JavaScript 中的脚本和模块

🌐 Scripts and modules in JavaScript

在 JavaScript 最早期的时候,当这种语言只在浏览器中运行时,是没有模块的,但仍然可以通过在 HTML 中使用多个 script 标签,将网页的 JavaScript 拆分成多个文件:

🌐 In the early days of JavaScript, when the language only ran in browsers, there were no modules, but it was still possible to split the JavaScript for a web page into multiple files by using multiple script tags in HTML:

html
<html>
<head>
<script src="a.js"></script>
<script src="b.js"></script>
</head>
<body></body>
</html>

这种方法有一些缺点,尤其是随着网页变得越来越大和复杂时。特别是,加载到同一页面上的所有脚本共享相同的作用域——通常称为“全局作用域”——这意味着脚本必须非常小心,以免覆盖彼此的变量和函数。

🌐 This approach had some downsides, especially as web pages grew larger and more complex. In particular, all scripts loaded onto the same page share the same scope—appropriately called the “global scope”—meaning the scripts had to be very careful not to overwrite each others’ variables and functions.

任何通过为文件提供独立作用域的同时仍能提供将部分代码提供给其他文件的方法来解决此问题的系统,都可以称为“模块系统”。(说模块系统中的每个文件都被称为“模块”可能听起来很显而易见,但这个术语通常用于与 script 文件形成对比,后者在模块系统之外运行,在全局作用域中。)

🌐 Any system that solves this problem by giving files their own scope while still providing a way to make bits of code available to other files can be called a “module system.” (It may sound obvious to say that each file in a module system is called a “module,” but the term is often used to contrast with script files, which run outside a module system, in a global scope.)

许多模块系统,TypeScript 支持输出多种模块,但本篇文档将重点介绍今天最重要的两种系统:ECMAScript 模块(ESM)和 CommonJS(CJS)。

ECMAScript 模块(ESM)是内置于语言中的模块系统,在现代浏览器中以及 Node.js 自 v12 起都受支持。它使用专用的 importexport 语法:

js
// a.js
export default "Hello from a.js";
js
// b.js
import a from "./a.js";
console.log(a); // 'Hello from a.js'

CommonJS (CJS) is the module system that originally shipped in Node.js, before ESM was part of the language specification. It’s still supported in Node.js alongside ESM. It uses plain JavaScript objects and functions named exports and require:

js
// a.js
exports.message = "Hello from a.js";
js
// b.js
const a = require("./a");
console.log(a.message); // 'Hello from a.js'

因此,当 TypeScript 检测到一个文件是 CommonJS 或 ECMAScript 模块时,它会首先假设该文件将拥有自己的作用域。不过,除此之外,编译器的工作就变得有些复杂了。

🌐 Accordingly, when TypeScript detects that a file is a CommonJS or ECMAScript module, it starts by assuming that file will have its own scope. Beyond that, though, the compiler’s job gets a little more complicated.

TypeScript 与模块相关的工作

🌐 TypeScript’s job concerning modules

TypeScript 编译器的主要目标是通过在编译时捕获某些类型的运行时错误来防止它们发生。无论是否涉及模块,编译器都需要了解代码的预期运行环境——例如,哪些全局变量可用。当涉及模块时,编译器需要回答几个额外的问题才能完成其工作。让我们使用几行示例输入代码来思考分析它所需的所有信息:

🌐 The TypeScript compiler’s chief goal is to prevent certain kinds of runtime errors by catching them at compile time. With or without modules involved, the compiler needs to know about the code’s intended runtime environment—what globals are available, for example. When modules are involved, there are several additional questions the compiler needs to answer in order to do its job. Let’s use a few lines of input code as an example to think about all the information needed to analyze it:

ts
import sayHello from "greetings";
sayHello("world");

要检查此文件,编译器需要知道 sayHello 的类型(它是可以接受一个字符串参数的函数吗?),这就引出了相当多的额外问题:

🌐 To check this file, the compiler needs to know the type of sayHello (is it a function that can accept one string argument?), which opens quite a few additional questions:

  1. 模块系统会直接加载此 TypeScript 文件,还是会加载我(或另一个编译器)从此 TypeScript 文件生成的 JavaScript 文件?
  2. 考虑到要加载的文件名及其在磁盘上的位置,模块系统期望找到哪种类型的模块?
  3. 如果触发输出 JavaScript,则该文件中存在的模块语法将如何在输出代码中转换?
  4. 模块系统会到哪里查找由 "greetings" 指定的模块?查找会成功吗?
  5. 该查找解析的文件属于哪种模块?
  6. 模块系统是否允许(2)中检测到的模块类型使用(3)中确定的语法引用(5)中检测到的模块类型?
  7. 一旦分析了 "greetings" 模块,该模块的哪一部分会绑定到 sayHello

请注意,所有这些问题都取决于 host 的特性——也就是最终使用输出的 JavaScript(或原始的 TypeScript,视情况而定)来指导其模块加载行为的系统,通常是运行时(如 Node.js)或打包工具(如 Webpack)。

🌐 Notice that all of these questions depend on characteristics of the host—the system that ultimately consumes the output JavaScript (or raw TypeScript, as the case may be) to direct its module loading behavior, typically either a runtime (like Node.js) or bundler (like Webpack).

ECMAScript 规范定义了 ESM 的导入和导出如何相互关联,但它并没有指定如何进行(步骤 4 中的)文件查找,这被称为 模块解析,也没有提及诸如 CommonJS 之类的其他模块系统。因此,运行时和打包工具,尤其是那些希望同时支持 ESM 和 CJS 的工具,有很大的自由度来设计自己的规则。因此,TypeScript 如何回答上述问题可能会根据代码的运行环境而有很大差异。没有唯一的正确答案,所以必须通过配置选项告诉编译器这些规则。

🌐 The ECMAScript specification defines how ESM imports and exports link up with each other, but it doesn’t specify how the file lookup in (4), known as module resolution, happens, and it doesn’t say anything about other module systems like CommonJS. So runtimes and bundlers, especially those that want to support both ESM and CJS, have a lot of freedom to design their own rules. Consequently, the way TypeScript should answer the questions above can vary dramatically depending on where the code is intended to run. There’s no single right answer, so the compiler must be told the rules through configuration options.

另一个需要记住的关键概念是,TypeScript 几乎总是从其 输出 的 JavaScript 文件角度来考虑这些问题,而不是从其 输入 的 TypeScript(或 JavaScript!)文件角度考虑。如今,一些运行时环境和打包工具支持直接加载 TypeScript 文件,在这些情况下,考虑输入文件和输出文件的区别就没有意义。本文大部分内容讨论的是将 TypeScript 文件编译为 JavaScript 文件的情况,而这些 JavaScript 文件随后由运行时模块系统加载。研究这些情况对于理解编译器的选项和行为至关重要——从这里开始会更容易,并且在思考 esbuild、Bun 以及其他 以 TypeScript 为首的运行时和打包工具 时可以进行简化。因此,目前我们可以将 TypeScript 在模块处理上的工作总结为输出文件的角度:

🌐 The other key idea to keep in mind is that TypeScript almost always thinks about these questions in terms of its output JavaScript files, not its input TypeScript (or JavaScript!) files. Today, some runtimes and bundlers support loading TypeScript files directly, and in those cases, it doesn’t make sense to think about separate input and output files. Most of this document discusses cases where TypeScript files are compiled to JavaScript files, which in turn are loaded by the runtime module system. Examining these cases is essential for building an understanding of the compiler’s options and behavior—it’s easier to start there and simplify when thinking about esbuild, Bun, and other TypeScript-first runtimes and bundlers. So for now, we can summarize TypeScript’s job when it comes to modules in terms of output files:

充分了解主持人的规则

🌐 Understand the rules of the host enough

  1. 将文件编译为有效的输出模块格式
  2. 以确保这些输出中的导入能够成功解析,并且
  3. 知道应该为 导入的名称 指定什么类型

宿主是谁?

🌐 Who is the host?

在我们继续之前,值得确保我们对“主机”(host)这一术语有相同的理解,因为它会经常出现。我们之前将其定义为“最终消费输出代码以指导其模块加载行为的系统”。换句话说,它是 TypeScript 外部的系统,而 TypeScript 的模块分析试图对其进行建模:

🌐 Before we move on, it’s worth making sure we’re on the same page about the term host, because it will come up frequently. We defined it before as “the system that ultimately consumes the output code to direct its module loading behavior.” In other words, it’s the system outside of TypeScript that TypeScript’s module analysis tries to model:

  • 当输出的代码(无论是由 tsc 还是第三方转译器生成的)直接在像 Node.js 这样的运行时中运行时,运行时就是宿主环境。
  • 当由于运行时直接使用 TypeScript 文件而没有“输出代码”时,运行时仍然是宿主。
  • 当打包工具处理 TypeScript 输入或输出并生成一个包时,打包工具就是宿主,因为它查看了最初的一组导入/引用,查找了它们所引用的文件,并生成了一个新的文件或文件集合,其中原始的导入和引用被删除或彻底转换。(这个包本身可能包含模块,而运行它的运行时将是它的宿主,但 TypeScript 对打包后发生的任何事情都一无所知。)
  • 如果另一个转译器、优化器或格式化工具在 TypeScript 的输出上运行,只要它不修改所看到的导入和导出,它就不是 TypeScript 所关心的宿主。
  • 在网页浏览器中加载模块时,TypeScript 需要建模的行为实际上是由网页服务器和在浏览器中运行的模块系统共同决定的。浏览器的 JavaScript 引擎(或像 RequireJS 这样的基于脚本的模块加载框架)控制接受哪些模块格式,而网页服务器则决定当一个模块触发加载另一个模块的请求时发送哪个文件。
  • TypeScript 编译器本身不是宿主,因为除了尝试对其他宿主进行建模之外,它不提供与模块相关的任何行为。

模块输出格式

🌐 The module output format

在任何项目中,我们需要首先回答的关于模块的问题是主机期望哪种类型的模块,这样 TypeScript 才能为每个文件设置匹配的输出格式。有时,主机只支持一种类型的模块——例如浏览器中的 ESM,或 Node.js v11 及更早版本中的 CJS。Node.js v12 及更高版本同时接受 CJS 和 ES 模块,但它会使用文件扩展名和 package.json 文件来确定每个文件的格式,如果文件内容与预期格式不匹配,则会抛出错误。

🌐 In any project, the first question about modules we need to answer is what kinds of modules the host expects, so TypeScript can set its output format for each file to match. Sometimes, the host only supports one kind of module—ESM in the browser, or CJS in Node.js v11 and earlier, for example. Node.js v12 and later accepts both CJS and ES modules, but uses file extensions and package.json files to determine what format each file should be, and throws an error if the file’s contents don’t match the expected format.

module 编译器选项向编译器提供此信息。它的主要目的是控制在编译期间生成的任何 JavaScript 的模块格式,但它还用于告知编译器如何检测每个文件的模块类型,不同模块类型之间如何允许相互导入,以及是否可使用 import.meta 和顶层 await 等功能。因此,即使一个 TypeScript 项目使用 noEmit,选择合适的 module 设置仍然很重要。如前所述,编译器需要准确理解模块系统,以便对导入进行类型检查(并为其提供 IntelliSense)。有关为项目选择正确 module 设置的指导,请参见选择编译器选项

🌐 The module compiler option provides this information to the compiler. Its primary purpose is to control the module format of any JavaScript that gets emitted during compilation, but it also serves to inform the compiler about how the module kind of each file should be detected, how different module kinds are allowed to import each other, and whether features like import.meta and top-level await are available. So, even if a TypeScript project is using noEmit, choosing the right setting for module still matters. As we established earlier, the compiler needs an accurate understanding of the module system so it can type check (and provide IntelliSense for) imports. See Choosing compiler options for guidance on choosing the right module setting for your project.

可用的 module 设置有

🌐 The available module settings are

  • node16:反映了 Node.js v16+ 的模块系统,该系统支持 ES 模块和 CJS 模块并存,具有特定的互操作性和检测规则。
  • node18:反映了 Node.js v18+ 的模块系统,它增加了对 import 属性的支持。
  • nodenext:一个不断变化的目标,会随着 Node.js 模块系统的发展而反映最新的 Node.js 版本。截至 TypeScript 5.8,nodenext 支持 ECMAScript 模块的 require
  • es2015:反映了 JavaScript 模块的 ES2015 语言规范(这是首次将 importexport 引入语言的版本)。
  • es2020:为 es2015 添加对 import.metaexport * as ns from "mod" 的支持。
  • es2022:为 es2020 添加对顶层 await 的支持。
  • esnext:目前与 es2022 相同,但将成为一个动态目标,反映最新的 ECMAScript 规范,以及预计会被纳入即将发布规范版本的与模块相关的 Stage 3+ 提案。
  • commonjssystemamdumd:每个都会导出所命名模块系统中的所有内容,并假设所有内容都可以成功导入该模块系统。这些已不再推荐用于新项目,并且本文档将不会对其进行详细介绍。

Node.js 对模块格式检测和互操作性的规则决定了,在运行于 Node.js 的项目中,将 module 指定为 esnextcommonjs 是不正确的,即使 tsc 生成的所有文件分别都是 ESM 或 CJS。对于打算在 Node.js 中运行的项目,唯一正确的 module 设置是 node16nodenext。虽然对于全 ESM 的 Node.js 项目,使用 esnextnodenext 编译生成的 JavaScript 可能看起来完全相同,但类型检查可能会有所不同。有关更多详细信息,请参阅 关于 nodenext 的参考部分

模块格式检测

🌐 Module format detection

Node.js 既能理解 ES 模块,也能理解 CJS 模块,但每个文件的格式由其文件扩展名以及在搜索该文件所在目录及所有上级目录时找到的第一个 package.json 文件的 type 字段决定:

🌐 Node.js understands both ES modules and CJS modules, but the format of each file is determined by its file extension and the type field of the first package.json file found in a search of the file’s directory and all ancestor directories:

  • .mjs.cjs 文件总是分别被解释为 ES 模块和 CJS 模块。
  • .js 文件会被解析为 ES 模块,如果最近的 package.json 文件包含值为 "module"type 字段。如果没有 package.json 文件,或者 type 字段缺失或具有其他任何值,.js 文件将被解析为 CJS 模块。

如果根据这些规则确定某个文件是 ES 模块,Node.js 在评估该文件时不会将 CommonJS 的 modulerequire 对象注入到文件作用域中,因此尝试使用它们的文件将导致崩溃。相反,如果某个文件被确定为 CJS 模块,文件中的 importexport 声明将导致语法错误并崩溃。

🌐 If a file is determined to be an ES module by these rules, Node.js will not inject the CommonJS module and require objects into the file’s scope during evaluation, so a file that tries to use them will cause a crash. Conversely, if a file is determined to be a CJS module, import and export declarations in the file will cause a syntax error crash.

当将 module 编译器选项设置为 node16node18nodenext 时,TypeScript 会将相同的算法应用于项目的 input 文件,以确定每个对应 output 文件的模块类型。让我们来看一个使用 --module nodenext 的示例项目中如何检测模块格式:

🌐 When the module compiler option is set to node16, node18, or nodenext, TypeScript applies this same algorithm to the project’s input files to determine the module kind of each corresponding output file. Let’s look at how module formats are detected in an example project that uses --module nodenext:

Input file name Contents Output file name Module kind Reason
/package.json {}
/main.mts /main.mjs ESM File extension
/utils.cts /utils.cjs CJS File extension
/example.ts /example.js CJS No "type": "module" in package.json
/node_modules/pkg/package.json { "type": "module" }
/node_modules/pkg/index.d.ts ESM "type": "module" in package.json
/node_modules/pkg/index.d.cts CJS File extension

当输入文件的扩展名为 .mts.cts 时,TypeScript 会分别将该文件视为 ES 模块或 CJS 模块,因为 Node.js 会将输出的 .mjs 文件视为 ES 模块,或者将输出的 .cjs 文件视为 CJS 模块。当输入文件的扩展名为 .ts 时,TypeScript 必须查阅最近的 package.json 文件以确定模块格式,因为这也是 Node.js 在遇到输出的 .js 文件时会执行的操作。(注意,相同的规则也适用于 pkg 依赖中的 .d.cts.d.ts 声明文件:虽然它们不会作为此次编译的一部分产生输出文件,但 .d.ts 文件的存在 暗示 了相应 .js 文件的存在——可能是在 pkg 库的作者对其输入的 .ts 文件运行 tsc 时创建的——Node.js 必须将其解释为 ES 模块,因为其具有 .js 扩展名并且在 /node_modules/pkg/package.json 中存在 "type": "module" 字段。声明文件将在后续章节中详细介绍。)

🌐 When the input file extension is .mts or .cts, TypeScript knows to treat that file as an ES module or CJS module, respectively, because Node.js will treat the output .mjs file as an ES module or the output .cjs file as a CJS module. When the input file extension is .ts, TypeScript has to consult the nearest package.json file to determine the module format, because this is what Node.js will do when it encounters the output .js file. (Notice that the same rules apply to the .d.cts and .d.ts declaration files in the pkg dependency: though they will not produce an output file as part of this compilation, the presence of a .d.ts file implies the existence of a corresponding .js file—perhaps created when the author of the pkg library ran tsc on an input .ts file of their own—which Node.js must interpret as an ES module, due to its .js extension and the presence of the "type": "module" field in /node_modules/pkg/package.json. Declaration files are covered in more detail in a later section.)

TypeScript 使用输入文件检测到的模块格式来确保其输出的语法符合 Node.js 对每个输出文件的预期。如果 TypeScript 在输出文件中发出了包含 importexport 语句的 /example.js,Node.js 在解析该文件时会崩溃。如果 TypeScript 发出了包含 require 调用的 /main.mjs,Node.js 在执行时会崩溃。除了输出之外,模块格式还用于确定类型检查和模块解析的规则,我们将在后面的章节中讨论这些内容。

🌐 The detected module format of input files is used by TypeScript to ensure it emits the output syntax that Node.js expects in each output file. If TypeScript were to emit /example.js with import and export statements in it, Node.js would crash when parsing the file. If TypeScript were to emit /main.mjs with require calls, Node.js would crash during evaluation. Beyond emit, the module format is also used to determine rules for type checking and module resolution, which we’ll discuss in the following sections.

从 TypeScript 5.6 开始,其他 --module 模式(如 esnextcommonjs)也会将特定格式的文件扩展名(.mts.cts)作为文件级别的输出格式覆盖。例如,即使 --module 设置为 commonjs,名为 main.mts 的文件也会将 ESM 语法输出到 main.mjs

🌐 As of TypeScript 5.6, other --module modes (like esnext and commonjs) also respect format-specific file extensions (.mts and .cts) as a file-level override for the emit format. For example, a file named main.mts will emit ESM syntax into main.mjs even if --module is set to commonjs.

值得再次提到的是,TypeScript 在 --module node16--module node18--module nodenext 中的行为完全是受 Node.js 行为的驱动。由于 TypeScript 的目标是在编译时捕捉潜在的运行时错误,它需要对运行时会发生的情况有一个非常准确的模型。这套相当复杂的模块类型检测规则对于检查将在 Node.js 中运行的代码是_必要的_,但如果应用于非 Node.js 的环境,可能会过于严格或根本不正确。

🌐 It’s worth mentioning again that TypeScript’s behavior in --module node16, --module node18, and --module nodenext is entirely motivated by Node.js’s behavior. Since TypeScript’s goal is to catch potential runtime errors at compile time, it needs a very accurate model of what will happen at runtime. This fairly complex set of rules for module kind detection is necessary for checking code that will run in Node.js, but may be overly strict or just incorrect if applied to non-Node.js hosts.

输入模块语法

🌐 Input module syntax

需要注意的是,在输入源文件中看到的 input 模块语法与输出到 JS 文件的模块语法在某种程度上是解耦的。也就是说,一个包含 ESM 导入的文件:

🌐 It’s important to note that the input module syntax seen in input source files is somewhat decoupled from the output module syntax emitted to JS files. That is, a file with an ESM import:

ts
import { sayHello } from "greetings";
sayHello("world");

可能完全按原样以 ESM 格式触发,也可能以 CommonJS 触发:

🌐 might be emitted in ESM format exactly as-is, or might be emitted as CommonJS:

ts
Object.defineProperty(exports, "__esModule", { value: true });
const greetings_1 = require("greetings");
(0, greetings_1.sayHello)("world");

这取决于 module 编译器选项(以及任何适用的 模块格式检测 规则,如果 module 选项支持多种模块类型)。一般来说,这意味着仅查看输入文件的内容不足以确定它是 ES 模块还是 CJS 模块。

🌐 depending on the module compiler option (and any applicable module format detection rules, if the module option supports more than one kind of module). In general, this means that looking at the contents of an input file isn’t enough to determine whether it’s an ES module or a CJS module.

Today, most TypeScript files are authored using ESM syntax (import and export statements) regardless of the output format. This is largely a legacy of the long road ESM has taken to widespread support. ECMAScript modules were standardized in 2015, were supported in most browsers by 2017, and landed in Node.js v12 in 2019. During much of this window, it was clear that ESM was the future of JavaScript modules, but very few runtimes could consume it. Tools like Babel made it possible for JavaScript to be authored in ESM and downleveled to another module format that could be used in Node.js or browsers. TypeScript followed suit, adding support for ES module syntax and softly discouraging the use of the original CommonJS-inspired import fs = require("fs") syntax in the 1.5 release.

The upside of this “author ESM, output anything” strategy was that TypeScript could use standard JavaScript syntax, making the authoring experience familiar to newcomers, and (theoretically) making it easy for projects to start targeting ESM outputs in the future. There are three significant downsides, which became fully apparent only after ESM and CJS modules were allowed to coexist and interoperate in Node.js:

  1. Early assumptions about how ESM/CJS interoperability would work in Node.js turned out to be wrong, and today, interoperability rules differ between Node.js and bundlers. Consequently, the configuration space for modules in TypeScript is large.
  2. When the syntax in input files all looks like ESM, it’s easy for an author or code reviewer to lose track of what kind of module a file is at runtime. And because of Node.js’s interoperability rules, what kind of module each file is became very important.
  3. When input files are written in ESM, the syntax in type declaration outputs (.d.ts files) looks like ESM too. But because the corresponding JavaScript files could have been emitted in any module format, TypeScript can’t tell what kind of module a file is just by looking at the contents of its type declarations. And again, because of the nature of ESM/CJS interoperability, TypeScript has to know what kind of module everything is in order to provide correct types and prevent imports that will crash.

In TypeScript 5.0, a new compiler option called verbatimModuleSyntax was introduced to help TypeScript authors know exactly how their import and export statements will be emitted. When enabled, the flag requires imports and exports in input files to be written in the form that will undergo the least amount of transformation before emit. So if a file will be emitted as ESM, imports and exports must be written in ESM syntax; if a file will be emitted as CJS, it must be written in the CommonJS-inspired TypeScript syntax (import fs = require("fs") and export = {}). This setting is particularly recommended for Node.js projects that use mostly ESM, but have a select few CJS files. It is not recommended for projects that currently target CJS, but may want to target ESM in the future.

ESM 和 CJS 互操作性

🌐 ESM and CJS interoperability

ES 模块能否 import 一个 CommonJS 模块?如果可以,默认导入是链接到 exports 还是 exports.default?CommonJS 模块能否 require 一个 ES 模块?CommonJS 并不是 ECMAScript 规范的一部分,所以自从 ESM 在 2015 年标准化后,运行时、打包工具和转译器一直可以自由地为这些问题给出自己的答案,因此没有一套标准的互操作规则。如今,大多数运行时和打包工具大致可以分为三类:

🌐 Can an ES module import a CommonJS module? If so, does a default import link to exports or exports.default? Can a CommonJS module require an ES module? CommonJS isn’t part of the ECMAScript specification, so runtimes, bundlers, and transpilers have been free to make up their own answers to these questions since ESM was standardized in 2015, and as such no standard set of interoperability rules exist. Today, most runtimes and bundlers broadly fall into one of three categories:

  1. 仅限 ESM。 一些运行时环境,例如浏览器引擎,只支持语言本身的一部分:ECMAScript 模块。
  2. 类似打包器。 在任何主要的 JavaScript 引擎能够运行 ES 模块之前,Babel 允许开发者通过将其转换为 CommonJS 来编写它们。这些从 ESM 转换为 CJS 的文件与手写 CJS 文件的交互方式暗示了一套宽松的互操作规则,这些规则已成为打包器和转译器的事实标准。
  3. Node.js。 在 Node.js v20.19.0 之前,CommonJS 模块无法同步加载 ES 模块(使用 require);它们只能通过动态 import() 调用异步加载。ES 模块可以默认导入 CJS 模块,这总是绑定到 exports。(这意味着使用 __esModule 的 Babel 类 CJS 输出的默认导入在 Node.js 与某些打包工具中表现不同。)

TypeScript 需要知道应使用哪一套规则,以便在导入(特别是 default)时提供正确的类型,并在运行时会崩溃的导入上报错。当 module 编译器选项设置为 node16node18nodenext 时,会强制执行 Node.js 的特定版本规则。[注1] 所有其他 module 设置,结合 esModuleInterop 选项,会在 TypeScript 中产生类似打包器的互操作行为。(虽然使用 --module esnext 可以防止你 编写 CommonJS 模块,但并不能阻止你将它们作为依赖 导入。目前没有 TypeScript 设置可以防止 ES 模块导入 CommonJS 模块,这在直接面向浏览器的代码中是合适的。)

🌐 TypeScript needs to know which of these rule sets to assume in order to provide correct types on (particularly default) imports and to error on imports that will crash at runtime. When the module compiler option is set to node16, node18, or nodenext, Node.js’s version-specific rules are enforced.1 All other module settings, combined with the esModuleInterop option, result in bundler-like interop in TypeScript. (While using --module esnext does prevent you from writing CommonJS modules, it does not prevent you from importing them as dependencies. There’s currently no TypeScript setting that can guard against an ES module importing a CommonJS module, as would be appropriate for direct-to-browser code.)

默认情况下,模块说明符不会转换

🌐 Module specifiers are not transformed by default

虽然 module 编译器选项可以将输入文件中的导入和导出转换为输出文件中的不同模块格式,但模块 specifier(即你 import 的字符串 from,或传递给 require)会按原样输出。例如,像这样的输入:

🌐 While the module compiler option can transform imports and exports in input files to different module formats in output files, the module specifier (the string from which you import, or pass to require) is emitted as-written. For example, an input like:

ts
import { add } from "./math.mjs";
add(1, 2);

可能会被触发为:

🌐 might be emitted as either:

ts
import { add } from "./math.mjs";
add(1, 2);

or:

ts
const math_1 = require("./math.mjs");
math_1.add(1, 2);

这取决于 module 编译器选项,但模块指定符无论如何都是 "./math.mjs"。默认情况下,模块指定符必须以适用于代码目标运行时或打包器的方式编写,而理解这些相对于 output 的指定符是 TypeScript 的工作。查找模块指定符所引用文件的过程称为 模块解析

🌐 depending on the module compiler option, but the module specifier will be "./math.mjs" either way. By default, module specifiers must be written in a way that works for the code’s target runtime or bundler, and it’s TypeScript’s job to understand those output-relative specifiers. The process of finding the file referenced by a module specifier is called module resolution.

TypeScript 5.7 introduced the --rewriteRelativeImportExtensions option, which transforms relative module specifiers with .ts, .tsx, .mts, or .cts extensions to their JavaScript equivalents in output files. This option is useful for creating TypeScript files that can be run directly in Node.js during development and still be compiled to JavaScript outputs for distribution or production use.

This documentation was written before the introduction of --rewriteRelativeImportExtensions, and the mental model it presents is built around modeling the behavior of the host module system operating on its input files, whether that’s a bundler operating on TypeScript files or a runtime operating on .js outputs. With --rewriteRelativeImportExtensions, the way to apply that mental model is to apply it twice: once to the runtime or bundler processing the TypeScript input files directly, and once again to the runtime or bundler processing the transformed outputs. Most of this documentation assumes that only the input files or only the output files will be loaded, but the principles it presents can be extended to the case where both are loaded.

模块解析

🌐 Module resolution

让我们回到我们的第一个例子,回顾一下到目前为止我们所学到的内容:

🌐 Let’s return to our first example and review what we’ve learned about it so far:

ts
import sayHello from "greetings";
sayHello("world");

到目前为止,我们已经讨论了主机的模块系统和 TypeScript 的 module 编译器选项如何可能影响这段代码。我们知道输入的语法看起来像 ESM,但输出格式取决于 module 编译器选项,可能还取决于文件扩展名,以及 package.json "type" 字段。我们也知道 sayHello 会绑定到什么,甚至这个导入是否允许,都可能取决于此文件和目标文件的模块类型。但我们还没有讨论如何去_找到_目标文件。

🌐 So far, we’ve discussed how the host’s module system and TypeScript’s module compiler option might impact this code. We know that the input syntax looks like ESM, but the output format depends on the module compiler option, potentially the file extension, and package.json "type" field. We also know that what sayHello gets bound to, and even whether the import is even allowed, may vary depending on the module kinds of this file and the target file. But we haven’t yet discussed how to find the target file.

模块解析由宿主定义

🌐 Module resolution is host-defined

虽然 ECMAScript 规范定义了如何解析和解释 importexport 语句,但模块解析由宿主环境决定。如果你正在创建一个全新的 JavaScript 运行时,你可以自由地创建一个模块解析方案,例如:

🌐 While the ECMAScript specification defines how to parse and interpret import and export statements, it leaves module resolution up to the host. If you’re creating a hot new JavaScript runtime, you’re free to create a module resolution scheme like:

ts
import monkey from "🐒"; // Looks for './eats/bananas.js'
import cow from "🐄"; // Looks for './eats/grass.js'
import lion from "🦁"; // Looks for './eats/you.js'

却仍然声称实现了“符合标准的 ESM”。不言而喻,如果没有对这个运行时模块解析算法的内置了解,TypeScript 根本无法知道应该为 monkeycowlion 分配什么类型。正如 module 通知编译器主机期望的模块格式一样,moduleResolution 和一些自定义选项一起,指定了主机用于将模块说明符解析为文件的算法。这也解释了为什么 TypeScript 在输出时不修改导入说明符:导入说明符与磁盘上的文件(如果存在的话)之间的关系是由主机定义的,而 TypeScript 并不是主机。

🌐 and still claim to implement “standards-compliant ESM.” Needless to say, TypeScript would have no idea what types to assign to monkey, cow, and lion without built-in knowledge of this runtime’s module resolution algorithm. Just as module informs the compiler about the host’s expected module format, moduleResolution, along with a few customization options, specify the algorithm the host uses to resolve module specifiers to files. This also clarifies why TypeScript doesn’t modify import specifiers during emit: the relationship between an import specifier and a file on disk (if one even exists) is host-defined, and TypeScript is not a host.

可用的 moduleResolution 选项有:

🌐 The available moduleResolution options are:

  • classic:TypeScript 最古老的模块解析模式,不幸的是,当 module 被设置为除 commonjsnode16nodenext 以外的任何值时,它是默认值。它可能是为了尽力为各种 RequireJS 配置提供解析支持而创建的。它不应在新项目中使用(甚至不应用于不使用 RequireJS 或其他 AMD 模块加载器的旧项目),并计划在 TypeScript 6.0 中废弃。
  • node10:以前称为 node,当 module 设置为 commonjs 时,这是不幸的默认值。它是 Node.js 12 之前版本的一个相当不错的模型,有时也可以勉强近似大多数打包工具处理模块解析的方式。它支持从 node_modules 查找包,加载目录 index.js 文件,并在相对模块说明符中省略 .js 扩展名。然而,由于 Node.js v12 引入了针对 ES 模块的不同模块解析规则,它对现代版本的 Node.js 来说是一个非常糟糕的模型。它不应被用于新项目。
  • node16:这是 --module node16--module node18 的对应项,并且默认由该 module 设置设定。Node.js v12 及更高版本支持 ESM 和 CJS 两种模块,每种模块都使用自己的模块解析算法。在 Node.js 中,导入语句中的模块说明符以及动态 import() 调用中不允许省略文件扩展名或 /index.js 后缀,而 require 调用中的模块说明符则可以。此模块解析模式在必要时会理解并强制执行此限制,其依据是由 --module node16/node18 实现的模块格式检测规则。(对于 node16nodenextmodulemoduleResolution 是相辅相成的:将一个设置为 node16nodenext 而将另一个设置为其他值是错误的。)
  • nodenext:目前与 node16 相同,它是 --module nodenext 的对应项,并默认使用该 module 设置。它旨在成为一种前瞻模式,将在新增 Node.js 模块解析功能时提供支持。
  • bundler:Node.js v12 为导入 npm 包引入了一些新的模块解析功能——package.json"exports""imports" 字段——并且许多打包工具在采用这些功能时,并没有同时采用更严格的 ESM 导入规则。这种模块解析模式为面向打包工具的代码提供了基础算法。它默认支持 package.json"exports""imports",但可以配置为忽略它们。它要求将 module 设置为 esnext

TypeScript 模仿宿主的模块解析,但具有类型

🌐 TypeScript imitates the host’s module resolution, but with types

还记得 TypeScript 关于模块的三个 工作 组成部分吗?

🌐 Remember the three components of TypeScript’s job concerning modules?

  1. 将文件编译为有效的输出模块格式
  2. 确保这些输出中的导入能够成功解析
  3. 知道应为导入的名称分配哪种类型

模块解析是完成最后两个步骤所必需的。但当我们大部分时间都在处理输入文件时,很容易忘记(2)——模块解析的一个关键部分是验证输出文件中导入或 require 调用(这些调用包含与输入文件相同的模块说明符 same module specifiers as the input files)在运行时是否能够正常工作。让我们看一个包含多个文件的新示例:

🌐 Module resolution is needed to accomplish last two. But when we spend most of our time working in input files, it can be easy to forget about (2)—that a key component of module resolution is validating that the imports or require calls in the output files, containing the same module specifiers as the input files, will actually work at runtime. Let’s look at a new example with multiple files:

ts
// @Filename: math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: main.ts
import { add } from "./math";
add(1, 2);

当我们看到从 "./math" 导入时,可能会很自然地想,“这就是一个 TypeScript 文件如何引用另一个文件的方式。编译器遵循这个(无扩展名的)路径来为 add 分配类型。”

🌐 When we see the import from "./math", it might be tempting to think, “This is how one TypeScript file refers to another. The compiler follows this (extensionless) path in order to assign a type to add.”

A simple flowchart diagram. A file (rectangle node) main.ts resolves (labeled arrow) through module specifier './math' to another file math.ts.

这并不完全错误,但实际情况更为复杂。"./math" 的解析(以及随后 add 的类型)需要反映在运行时 输出 文件上实际上发生的情况。对这个过程更稳妥的思考方式如下所示:

🌐 This isn’t entirely wrong, but the reality is deeper. The resolution of "./math" (and subsequently, the type of add) need to reflect the reality of what happens at runtime to the output files. A more robust way to think about this process would look like this:

A flowchart diagram with two groups of files: Input files and Output files. main.ts (an input file) maps to output file main.js, which resolves through the module specifier "./math" to math.js (another output file), which maps back to the input file math.ts.

该模型清楚地表明,对于 TypeScript 来说,模块解析主要是准确模拟主机在输出文件之间的模块解析算法,只需进行少量重映射即可找到类型信息。让我们看另一个例子,通过简单模型的视角似乎不直观,但通过稳健模型则完全合理:

🌐 This model makes it clear that for TypeScript, module resolution is mostly a matter of accurately modeling the host’s module resolution algorithm between output files, with a little bit of remapping applied to find type information. Let’s look at another example that appears unintuitive through the lens of the simple model, but makes perfect sense with the robust model:

ts
// @moduleResolution: node16
// @rootDir: src
// @outDir: dist
// @Filename: src/math.mts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.mts
import { add } from "./math.mjs";
add(1, 2);

Node.js ESM import 声明使用严格的模块解析算法,要求相对路径必须包含文件扩展名。当我们只考虑输入文件时,"./math.mjs" 似乎解析为 math.mts 会显得有些奇怪。由于我们使用 outDir 将编译输出放在不同的目录中,math.mjs 甚至不存在于 main.mts 的旁边!为什么这会被解析?使用我们的新思维模型,这就不成问题了:

🌐 Node.js ESM import declarations use a strict module resolution algorithm that requires relative paths to include file extensions. When we only think about input files, it’s a little strange that "./math.mjs" seems to resolve to math.mts. Since we’re using an outDir to put compiled outputs in a different directory, math.mjs doesn’t even exist next to main.mts! Why should this resolve? With our new mental model, it’s no problem:

A flowchart diagram with identical structure to the one above. There are two groups of files: Input files and Output files. src/main.mts (an input file) maps to output file dist/main.mjs, which resolves through module specifier "./math.mjs" to dist/math.mjs (another output file), which maps back to input file src/math.mts.

理解这个心理模型可能不会立即消除在输入文件中看到输出文件扩展名的奇怪感觉,而且以捷径方式思考是很自然的:"./math.mjs" 指的是输入文件 math.mts。我必须写出输出扩展名,但编译器知道当我写 .mjs 时去找 .mts 这个捷径甚至是编译器内部的工作方式,但更稳健的心理模型解释了 TypeScript 中模块解析之所以这样工作的原因:在输出文件中的模块说明符与输入文件中的模块说明符相同的约束下,这是唯一实现我们两个目标(验证输出文件和分配类型)的过程。

🌐 Understanding this mental model may not immediately eliminate the strangeness of seeing output file extensions in input files, and it’s natural to think in terms of shortcuts: "./math.mjs" refers to the input file math.mts. I have to write the output extension, but the compiler knows to look for .mts when I write .mjs. This shortcut is even how the compiler works internally, but the more robust mental model explains why module resolution in TypeScript works this way: given the constraint that the module specifier in the output file will be the same as the module specifier in the input file, this is the only process that accomplishes our two goals of validating output files and assigning types.

声明文件的作用

🌐 The role of declaration files

在前面的示例中,我们已经看到模块解析的“重映射”部分在输入和输出文件之间是如何工作的。但当我们导入库代码时会发生什么呢?即使该库是用 TypeScript 编写的,它也可能没有发布其源代码。如果我们不能依赖将库的 JavaScript 文件映射回 TypeScript 文件,我们可以在运行时验证导入是否有效,但我们如何实现第二个目标——分配类型呢?

🌐 In the previous example, we saw the “remapping” part of module resolution working between input and output files. But what happens when we import library code? Even if the library was written in TypeScript, it may not have published its source code. If we can’t rely on mapping the library’s JavaScript files back to a TypeScript file, we can verify that our import works at runtime, but how do we accomplish our second goal of assigning types?

这就是声明文件(.d.ts.d.mts 等)发挥作用的地方。理解声明文件如何被解释的最佳方式是了解它们的来源。当你对一个输入文件运行 tsc --declaration 时,你会得到一个输出的 JavaScript 文件和一个输出的声明文件:

🌐 This is where declaration files (.d.ts, .d.mts, etc.) come into play. The best way to understand how declaration files are interpreted is to understand where they come from. When you run tsc --declaration on an input file, you get one output JavaScript file and one output declaration file:

A diagram showing the relationship between different file types. A .ts file (top) has two arrows labeled 'generates' flowing to a .js file (bottom left) and a .d.ts file (bottom right). Another arrow labeled 'implies' points from the .d.ts file to the .js file.

由于这种关系,编译器会假设每当它看到一个声明文件时,都有一个对应的 JavaScript 文件,其类型信息完全由声明文件描述。出于性能原因,在每种模块解析模式下,编译器总是优先查找 TypeScript 和声明文件,如果找到,就不会继续寻找对应的 JavaScript 文件。如果找到 TypeScript 输入文件,它知道在编译后一定会生成一个 JavaScript 文件;如果找到声明文件,它知道编译(可能是别人编的)已经发生,并且 JavaScript 文件会与声明文件同时生成。

🌐 Because of this relationship, the compiler assumes that wherever it sees a declaration file, there is a corresponding JavaScript file that is perfectly described by the type information in the declaration file. For performance reasons, in every module resolution mode, the compiler always looks for TypeScript and declaration files first, and if it finds one, it doesn’t continue looking for the corresponding JavaScript file. If it finds a TypeScript input file, it knows a JavaScript file will exist after compilation, and if it finds a declaration file, it knows a compilation (perhaps someone else’s) already happened and created a JavaScript file at the same time as the declaration file.

声明文件不仅告诉编译器 JavaScript 文件存在,还告诉编译器它的名称和扩展名是什么:

🌐 The declaration file tells the compiler not only that a JavaScript file exists, but also what its name and extension are:

声明文件扩展名 JavaScript 文件扩展名 TypeScript 文件扩展名
.d.ts .js .ts
.d.ts .js .tsx
.d.mts .mjs .mts
.d.cts .cjs .cts
.d.*.ts .*

最后一行表达了非 JS 文件可以使用 allowArbitraryExtensions 编译器选项进行类型化,以支持模块系统将非 JS 文件作为 JavaScript 对象导入的情况。例如,一个名为 styles.css 的文件可以通过名为 styles.d.css.ts 的声明文件来表示。

🌐 The last row expresses that non-JS files can be typed with the allowArbitraryExtensions compiler option to support cases where the module system supports importing non-JS files as JavaScript objects. For example, a file named styles.css can be represented by a declaration file named styles.d.css.ts.

“等等!很多声明文件是手写的,并不是由 tsc 生成的。你听说过 DefinitelyTyped 吗?”你可能会提出异议。确实如此——手写声明文件,甚至将其移动/复制/重命名以表示外部构建工具的输出,是一项危险且容易出错的操作。DefinitelyTyped 的贡献者以及未使用 tsc 同时生成 JavaScript 和声明文件的类型库作者,应确保每个 JavaScript 文件都有一个同名、扩展名匹配的声明文件。偏离这种结构可能会导致终端用户遇到 TypeScript 虚假错误。npm 包 @arethetypeswrong/cli 可以帮助在发布前捕捉并解释这些错误。

打包器、TypeScript 运行时和 Node.js 加载器的模块解析

🌐 Module resolution for bundlers, TypeScript runtimes, and Node.js loaders

到目前为止,我们确实一直强调 输入文件输出文件 之间的区别。请记住,当在相对模块说明符上指定文件扩展名时,TypeScript 通常会 要求你使用 输出 文件扩展名

🌐 So far, we’ve really emphasized the distinction between input files and output files. Recall that when specifying a file extension on a relative module specifier, TypeScript typically makes you use the output file extension:

ts
// @Filename: src/math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.ts
import { add } from "./math.ts";
// ^^^^^^^^^^^
// An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.

这个限制适用,因为 TypeScript 不会将扩展名重写.js,并且如果 "./math.ts" 出现在输出的 JS 文件中,该导入在运行时不会解析到另一个 JS 文件。TypeScript 真正想阻止你生成不安全的输出 JS 文件。但如果根本没有输出 JS 文件怎么办?如果你处于以下情况之一呢:

🌐 This restriction applies since TypeScript won’t rewrite the extension to .js, and if "./math.ts" appears in an output JS file, that import won’t resolve to another JS file at runtime. TypeScript really wants to prevent you from generating an unsafe output JS file. But what if there is no output JS file? What if you’re in one of these situations:

  • 你正在打包此代码,打包器配置为在内存中转译 TypeScript 文件,并且它最终将消耗并删除你为生成打包包而编写的所有导入。
  • 你直接在 TypeScript 运行时(例如 Node、Deno 或 Bun)中运行此代码。
  • 你正在为 Node 使用 ts-nodetsx 或其他转换加载器。

在这些情况下,你可以开启 noEmit(或 emitDeclarationOnly)和 allowImportingTsExtensions 来禁用生成不安全的 JavaScript 文件,并在 .ts 扩展名的导入上消除错误。

🌐 In these cases, you can turn on noEmit (or emitDeclarationOnly) and allowImportingTsExtensions to disable emitting unsafe JavaScript files and silence the error on .ts-extensioned imports.

无论是否使用 allowImportingTsExtensions,为模块解析主机选择最合适的 moduleResolution 设置仍然很重要。对于打包工具和 Bun 运行时来说,它是 bundler。这些模块解析器的灵感来自 Node.js,但并未采用严格的 ESM 解析算法,该算法会像 Node.js 对 imports 实现的那样禁用扩展名搜索bundler 模块解析设置反映了这一点,启用了 package.json "exports" 支持,例如 node16nodenext,同时始终允许无扩展名的导入。有关更多指导,请参见选择编译器选项

🌐 With or without allowImportingTsExtensions, it’s still important to pick the most appropriate moduleResolution setting for the module resolution host. For bundlers and the Bun runtime, it’s bundler. These module resolvers were inspired by Node.js, but didn’t adopt the strict ESM resolution algorithm that disables extension searching that Node.js applies to imports. The bundler module resolution setting reflects this, enabling package.json "exports" support like node16nodenext, while always allowing extensionless imports. See Choosing compiler options for more guidance.

库的模块解析

🌐 Module resolution for libraries

在编译应用时,你会根据模块解析的宿主选择 TypeScript 项目的 moduleResolution 选项。在编译库时,你不知道输出代码将运行在哪里,但希望它能在尽可能多的地方运行。使用 "module": "node18"(以及隐含的 "moduleResolution": "node16")是最大化输出 JavaScript 模块限定符兼容性的最佳选择,因为它会强制你遵守 Node.js 对 import 模块解析的更严格规则。让我们来看一下如果库使用 "moduleResolution": "bundler"(或者更糟的是 "node10")进行编译会发生什么情况:

🌐 When compiling an app, you choose the moduleResolution option for a TypeScript project based on who the module resolution host is. When compiling a library, you don’t know where the output code will run, but you’d like it to run in as many places as possible. Using "module": "node18" (along with the implied "moduleResolution": "node16") is the best bet for maximizing the compatibility of the output JavaScript’s module specifiers, since it will force you to comply with Node.js’s stricter rules for import module resolution. Let’s look at what would happen if a library were to compile with "moduleResolution": "bundler" (or worse, "node10"):

ts
export * from "./utils";

假设 ./utils.ts(或 ./utils/index.ts)存在,打包工具对这段代码不会有问题,因此 "moduleResolution": "bundler" 不会报错。使用 "module": "esnext" 编译后,这段导出语句生成的 JavaScript 与输入完全相同。如果将这份 JavaScript 发布到 npm,它可以被使用打包工具的项目使用,但在 Node.js 中运行时会导致错误:

🌐 Assuming ./utils.ts (or ./utils/index.ts) exists, a bundler would be fine with this code, so "moduleResolution": "bundler" doesn’t complain. Compiled with "module": "esnext", the output JavaScript for this export statement will look exactly the same as the input. If that JavaScript were published to npm, it would be usable by projects that use a bundler, but it would cause an error when run in Node.js:

Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/dependency/utils' imported from .../node_modules/dependency/index.js
Did you mean to import ./utils.js?

另一方面,如果我们写:

🌐 On the other hand, if we had written:

ts
export * from "./utils.js";

这将生成可以在 Node.js 和打包工具中都能使用的输出。

🌐 This would produce output that works both in Node.js and in bundlers.

简而言之,"moduleResolution": "bundler" 是具有传染性的,它可以生成仅在打包工具中可用的代码。同样,"moduleResolution": "nodenext" 只是检查输出是否在 Node.js 中可用,但在大多数情况下,能在 Node.js 中运行的模块代码也能在其他运行时和打包工具中运行。

🌐 In short, "moduleResolution": "bundler" is infectious, allowing code that only works in bundlers to be produced. Likewise, "moduleResolution": "nodenext" is only checking that the output works in Node.js, but in most cases, module code that works in Node.js will work in other runtimes and in bundlers.

当然,这些指导只能适用于库输出来自 tsc 的情况。如果库在发布前已经被打包,"moduleResolution": "bundler" 可能是可以接受的。任何修改模块格式或模块规范以生成库最终构建版本的构建工具,都有责任确保产品模块代码的安全性和兼容性,而 tsc 无法再对这项任务做出贡献,因为它无法知道运行时会存在什么模块代码。

🌐 Of course, this guidance can only apply in cases where the library ships outputs from tsc. If the library is being bundled before shipping, "moduleResolution": "bundler" may be acceptable. Any build tool that changes the module format or module specifiers to produce the final build of the library bears the responsibility of ensuring the safety and compatibility of the product’s module code, and tsc can no longer contribute to that task, since it can’t know what module code will exist at runtime.


  1. In Node.js v20.19.0 and later, a require of an ES module is allowed, but only if the resolved module and its top-level imports don’t use top-level await. TypeScript does not try to enforce this rule, as it lacks the ability to tell from a declaration file whether the corresponding JavaScript file contains top-level await.