模块 - 理论

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.

任何通过为文件提供自己的范围来解决此问题的系统,同时仍然提供一种使代码段可供其他文件使用的方法,都可以称为“模块系统”。 (模块系统中的每个文件都称为“模块”,这听起来似乎很明显,但该术语通常用于与脚本文件进行对比,脚本文件在全局范围内运行在模块系统之外。)

¥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)。

¥There are many module systems, and TypeScript supports emitting several, but this documentation will focus on the two most important systems today: ECMAScript modules (ESM) and CommonJS (CJS).

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

¥ECMAScript Modules (ESM) is the module system built into the language, supported in modern browsers and in Node.js since v12. It uses dedicated import and export syntax:

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) 是最初在 Node.js 中提供的模块系统,在 ESM 成为语言规范的一部分之前。Node.js 和 ESM 仍然支持它。它使用名为 exportsrequire 的纯 JavaScript 对象和函数:

¥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 文件?

    ¥Will the module system load this TypeScript file directly, or will it load a JavaScript file that I (or another compiler) generate from this TypeScript file?

  2. 考虑到将加载的文件名及其在磁盘上的位置,模块系统期望找到哪种模块?

    ¥What kind of module does the module system expect to find, given the file name it will load and its location on disk?

  3. 如果触发输出 JavaScript,则该文件中存在的模块语法将如何在输出代码中转换?

    ¥If output JavaScript is being emitted, how will the module syntax present in this file be transformed in the output code?

  4. 模块系统将在哪里查找 "greetings" 指定的模块?查找会成功吗?

    ¥Where will the module system look to find the module specified by "greetings"? Will the lookup succeed?

  5. 该查找解析的文件属于哪种模块?

    ¥What kind of module is the file resolved by that lookup?

  6. 模块系统是否允许(2)中检测到的模块类型使用(3)中确定的语法引用(5)中检测到的模块类型?

    ¥Does the module system allow the kind of module detected in (2) to reference the kind of module detected in (5) with the syntax decided in (3)?

  7. 分析完 "greetings" 模块后,该模块的哪一部分与 sayHello 绑定?

    ¥Once the "greetings" module has been analyzed, what piece of that module is bound to sayHello?

请注意,所有这些问题都取决于宿主的特性,即最终使用输出 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. 将文件编译成有效的输出模块格式,

    ¥to compile files into a valid output module format,

  2. 确保这些输出中的导入能够成功解决,并且

    ¥to ensure that imports in those outputs will resolve successfully, and

  3. 了解要为导入的名称分配什么类型。

    ¥to know what type to assign to imported names.

宿主是谁?

¥Who is the 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 这样的运行时运行时,运行时就是宿主。

    ¥When the output code (whether produced by tsc or a third-party transpiler) is run directly in a runtime like Node.js, the runtime is the host.

  • 当由于运行时直接使用 TypeScript 文件而没有“输出代码”时,运行时仍然是宿主。

    ¥When there is no “output code” because a runtime consumes TypeScript files directly, the runtime is still the host.

  • 当打包器使用 TypeScript 输入或输出并生成打包包时,打包器就是宿主,因为它查看原始的导入/需求集,查找它们引用的文件,并在原始文件所在的位置生成一个新文件或一组文件 导入和需求被删除或变得面目全非。(该打包包本身可能包含模块,运行它的运行时将是其宿主,但 TypeScript 不知道打包器后发生的任何事情。)

    ¥When a bundler consumes TypeScript inputs or outputs and produces a bundle, the bundler is the host, because it looked at the original set of imports/requires, looked up what files they referenced, and produced a new file or set of files where the original imports and requires are erased or transformed beyond recognition. (That bundle itself might comprise modules, and the runtime that runs it will be its host, but TypeScript doesn’t know about anything that happens post-bundler.)

  • 如果另一个转译器、优化器或格式化程序在 TypeScript 的输出上运行,那么它就不是 TypeScript 关心的宿主,只要它不处理它所看到的导入和导出。

    ¥If another transpiler, optimizer, or formatter runs on TypeScript’s outputs, it’s not a host that TypeScript cares about, as long as it leaves the imports and exports it sees alone.

  • 当在 Web 浏览器中加载模块时,TypeScript 需要建模的行为实际上在 Web 服务器和浏览器中运行的模块系统之间分配。浏览器的 JavaScript 引擎(或基于脚本的模块加载框架,如 RequireJS)控制接受哪些模块格式,而 Web 服务器决定当一个模块触发加载另一个模块的请求时发送什么文件。

    ¥When loading modules in a web browser, the behaviors TypeScript needs to model are actually split between the web server and the module system running in the browser. The browser’s JavaScript engine (or a script-based module-loading framework like RequireJS) controls what module formats are accepted, while the web server decides what file to send when one module triggers a request to load another.

  • TypeScript 编译器本身不是宿主,因为除了尝试对其他宿主进行建模之外,它不提供与模块相关的任何行为。

    ¥The TypeScript compiler itself is not a host, because it does not provide any behavior related to modules beyond trying to model other hosts.

模块输出格式

¥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 模块,并具有特定的互操作性和检测规则。

    ¥node16: Reflects the module system of Node.js v16+, which supports ES modules and CJS modules side-by-side with particular interoperability and detection rules.

  • nodenext:目前与 node16 相同,但随着 Node.js 模块系统的发展,将成为反映最新 Node.js 版本的移动目标。

    ¥nodenext: Currently identical to node16, but will be a moving target reflecting the latest Node.js versions as Node.js’s module system evolves.

  • es2015:反映 JavaScript 模块的 ES2015 语言规范(首次将 importexport 引入该语言的版本)。

    ¥es2015: Reflects the ES2015 language specification for JavaScript modules (the version that first introduced import and export to the language).

  • es2020:在 es2015 中添加了对 import.metaexport * as ns from "mod" 的支持。

    ¥es2020: Adds support for import.meta and export * as ns from "mod" to es2015.

  • es2022:添加对顶层 awaites2020 的支持。

    ¥es2022: Adds support for top-level await to es2020.

  • esnext:目前与 es2022 相同,但将是一个移动目标,反映最新的 ECMAScript 规范以及与模块相关的 Stage 3+ 提案,预计将包含在即将推出的规范版本中。

    ¥esnext: Currently identical to es2022, but will be a moving target reflecting the latest ECMAScript specifications, as well as module-related Stage 3+ proposals that are expected to be included in upcoming specification versions.

  • commonjssystemamdumd:每个都会触发指定模块系统中的所有内容,并假设所有内容都可以成功导入到该模块系统中。这些不再推荐用于新项目,并且本文档也不会详细介绍。

    ¥**commonjs, system, amd, and umd**: Each emits everything in the module system named, and assumes everything can be successfully imported into that module system. These are no longer recommended for new projects and will not be covered in detail by this documentation.

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

¥Node.js’s rules for module format detection and interoperability make it incorrect to specify module as esnext or commonjs for projects that run in Node.js, even if all files emitted by tsc are ESM or CJS, respectively. The only correct module settings for projects that intend to run in Node.js are node16 and nodenext. While the emitted JavaScript for an all-ESM Node.js project might look identical between compilations using esnext and nodenext, the type checking can differ. See the reference section on nodenext for more details.

模块格式检测

¥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 模块。

    ¥.mjs and .cjs files are always interpreted as ES modules and CJS modules, respectively.

  • 如果最近的 package.json 文件包含值为 "module"type 字段,则 .js 文件将被解释为 ES 模块。如果没有 package.json 文件,或者 type 字段丢失或具有任何其他值,则 .js 文件将被解释为 CJS 模块。

    ¥.js files are interpreted as ES modules if the nearest package.json file contains a type field with the value "module". If there is no package.json file, or if the type field is missing or has any other value, .js files are interpreted as CJS modules.

如果通过这些规则确定文件是 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 编译器选项设置为 node16nodenext 时,TypeScript 会将相同的算法应用于项目的输入文件,以确定每个相应输出文件的模块类型。让我们看看如何在使用 --module nodenext 的示例项目中检测模块格式:

¥When the module compiler option is set to node16 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:

输入文件名 内容 输出文件名 模块种类 原因
/package.json {}
/main.mts /main.mjs ESM 文件扩展名
/utils.cts /utils.cjs CJS 文件扩展名
/example.ts /example.js CJS package.json 中没有 "type": "module"
/node_modules/pkg/package.json { "type": "module" }
/node_modules/pkg/index.d.ts ESM package.json 中的 "type": "module"
/node_modules/pkg/index.d.cts CJS 文件扩展名

当输入文件扩展名是 .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 触发 /example.js,其中包含 importexport 语句,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 在 --module node16--module nodenext 中的行为完全是由 Node.js 的行为驱动的。由于 TypeScript 的目标是在编译时捕获潜在的运行时错误,因此它需要一个非常准确的模型来了解运行时会发生的情况。这套相当复杂的模块类型检测规则对于检查将在 Node.js 中运行的代码是必要的,但如果应用于非 Node.js 宿主,可能会过于严格或不正确。

¥It’s worth mentioning again that TypeScript’s behavior in --module node16 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

值得注意的是,输入源文件中看到的输入模块语法在某种程度上与发送到 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.

如今,大多数 TypeScript 文件都是使用 ESM 语法(importexport 语句)编写的,无论输出格式如何。这在很大程度上是 ESM 为获得广泛支持而采取的漫长道路的遗物。ECMAScript 模块于 2015 年实现标准化,到 2017 年在大多数浏览器中得到支持,并于 2019 年登陆 Node.js v12。在此窗口的大部分时间里,很明显 ESM 是 JavaScript 模块的未来,但很少有运行时可以使用它。像 Babel 这样的工具使得 JavaScript 可以在 ESM 中编写,并降级为可在 Node.js 或浏览器中使用的另一种模块格式。TypeScript 紧随其后,添加了对 ES 模块语法的支持,并温和地阻止在 1.5 版本 中使用原始的受 CommonJS 启发的 import fs = require("fs") 语法。

¥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.

这种“创作 ESM,输出任何内容”策略的好处是 TypeScript 可以使用标准 JavaScript 语法,使新手熟悉创作体验,并且(理论上)使项目将来可以轻松开始针对 ESM 输出。有三个显着的缺点,只有在允许 ESM 和 CJS 模块在 Node.js 中共存和互操作后,这些缺点才变得完全明显:

¥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. 早期关于 ESM/CJS 互操作性如何在 Node.js 中工作的假设被证明是错误的,而如今,Node.js 和打包器之间的互操作性规则有所不同。因此,TypeScript 中模块的配置空间很大。

    ¥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. 当输入文件中的语法看起来都像 ESM 时,作者或代码审阅者很容易在运行时忘记文件是什么类型的模块。而且由于 Node.js 的互操作性规则,每个文件是什么类型的模块变得非常重要。

    ¥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. 当输入文件以 ESM 编写时,类型声明输出(.d.ts 文件)中的语法也看起来像 ESM。但由于相应的 JavaScript 文件可以以任何模块格式触发,因此 TypeScript 无法仅通过查看其类型声明的内容来判断文件是什么类型的模块。同样,由于 ESM/CJS 互操作性的本质,TypeScript 必须知道所有模块的类型,以便提供正确的类型并防止导入崩溃。

    ¥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.

在 TypeScript 5.0 中,引入了一个名为 verbatimModuleSyntax 的新编译器选项,以帮助 TypeScript 作者准确了解他们的 importexport 语句将如何触发。启用后,该标志要求输入文件中的导入和导出以在触发之前经历最少转换的形式写入。因此,如果文件将作为 ESM 触发,则导入和导出必须使用 ESM 语法编写;如果文件将作为 CJS 触发,则必须使用 CommonJS 启发的 TypeScript 语法(import fs = require("fs")export = {})编写。对于主要使用 ESM 但具有少量 CJS 文件的 Node.js 项目,特别建议使用此设置。不建议当前针对 CJS、但将来可能希望针对 ESM 的项目。

¥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 模块吗?如果是这样,默认导入链接到 exportsexports.default 吗?CommonJS 模块 require 可以是 ES 模块吗?CommonJS 不是 ECMAScript 规范的一部分,因此自 2015 年 ESM 标准化以来,运行时、打包器和转译器一直可以自由地为这些问题找到自己的答案,因此不存在标准的互操作性规则集。如今,大多数运行时和打包器大致分为以下三类之一:

¥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 模块。

    ¥ESM-only. Some runtimes, like browser engines, only support what’s actually a part of the language: ECMAScript Modules.

  2. 类似打包器。在任何主要的 JavaScript 引擎可以运行 ES 模块之前,Babel 允许开发者通过将它们转译为 CommonJS 来编写它们。这些 ESM 转换为 CJS 文件与手写 CJS 文件交互的方式暗示了一组宽松的互操作性规则,这些规则已成为打包器和转译器的事实上的标准。

    ¥Bundler-like. Before any major JavaScript engine could run ES modules, Babel allowed developers to write them by transpiling them to CommonJS. The way these ESM-transpiled-to-CJS files interacted with hand-written-CJS files implied a set of permissive interoperability rules that have become the de facto standard for bundlers and transpilers.

  3. Node.js。在 Node.js 中,CommonJS 模块无法同步加载 ES 模块(使用 require);他们只能通过动态 import() 调用异步加载它们。ES 模块可以默认导入 CJS 模块,它始终绑定到 exports。(这意味着使用 __esModule 导入类 Babel 的 CJS 输出的默认导入在 Node.js 和某些打包器之间的行为有所不同。)

    ¥Node.js. In Node.js, CommonJS modules cannot load ES modules synchronously (with require); they can only load them asynchronously with dynamic import() calls. ES modules can default-import CJS modules, which always binds to exports. (This means that a default import of a Babel-like CJS output with __esModule behaves differently between Node.js and some bundlers.)

TypeScript 需要知道采用哪些规则集,以便在导入(特别是 default)上提供正确的类型,并在导入时出错,从而在运行时崩溃。当 module 编译器选项设置为 node16nodenext 时,将强制执行 Node.js 的规则。所有其他 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 or nodenext, Node.js’s rules are enforced. 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

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

¥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 always 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"。没有编译器选项可以转换、替换或重写模块说明符。因此,模块说明符必须以适合代码的目标运行时或打包器的方式编写,而 TypeScript 的工作就是理解这些与输出相关的说明符。查找模块说明符引用的文件的过程称为模块解析。

¥depending on the module compiler option, but the module specifier will always be "./math.mjs". There is no compiler option that enables transforming, substituting, or rewriting module specifiers. Consequently, 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.

模块解析

¥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 中弃用。

    ¥classic: TypeScript’s oldest module resolution mode, this is unfortunately the default when module is set to anything other than commonjs, node16, or nodenext. It was probably made to provide best-effort resolution for a wide range of RequireJS configurations. It should not be used for new projects (or even old projects that don’t use RequireJS or another AMD module loader), and is scheduled for deprecation in TypeScript 6.0.

  • node10:以前称为 node,当 module 设置为 commonjs 时,这是不幸的默认值。这是早于 v12 的 Node.js 版本的一个非常好的模型,有时它是大多数打包程序如何进行模块解析的一个还算可以的近似。它支持从 node_modules 查找包、加载目录 index.js 文件以及在相关模块说明符中省略 .js 扩展名。不过,由于 Node.js v12 为 ES 模块引入了不同的模块解析规则,因此它是现代版本 Node.js 的一个非常糟糕的模型。它不应该用于新项目。

    ¥node10: Formerly known as node, this is the unfortunate default when module is set to commonjs. It’s a pretty good model of Node.js versions older than v12, and sometimes it’s a passable approximation of how most bundlers do module resolution. It supports looking up packages from node_modules, loading directory index.js files, and omitting .js extensions in relative module specifiers. Because Node.js v12 introduced different module resolution rules for ES modules, though, it’s a very bad model of modern versions of Node.js. It should not be used for new projects.

  • node16:这是 --module node16 的对应项,默认情况下使用 module 设置进行设置。Node.js v12 及更高版本同时支持 ESM 和 CJS,每种都使用自己的模块解析算法。在 Node.js 中,导入语句和动态 import() 调用中的模块说明符不允许省略文件扩展名或 /index.js 后缀,而 require 调用中的模块说明符可以。此模块解析模式在必要时理解并强制执行此限制,如 --module node16 所设置的 模块格式检测规则 所确定。(对于 node16nodenextmodulemoduleResolution 是齐头并进的:将一个设置为 node16nodenext,同时将另一个设置为其他值会产生不受支持的行为,并且将来可能会出现错误。)

    ¥node16: This is the counterpart of --module node16 and is set by default with that module setting. Node.js v12 and later support both ESM and CJS, each of which uses its own module resolution algorithm. In Node.js, module specifiers in import statements and dynamic import() calls are not allowed to omit file extensions or /index.js suffixes, while module specifiers in require calls are. This module resolution mode understands and enforces this restriction where necessary, as determined by the module format detection rules instated by --module node16. (For node16 and nodenext, module and moduleResolution go hand-in-hand: setting one to node16 or nodenext while setting the other to something else has unsupported behavior and may be an error in the future.)

  • nodenext:当前与 node16 相同,它是 --module nodenext 的对应项,并且默认使用 module 设置进行设置。它旨在成为一种前瞻性模式,支持新添加的 Node.js 模块解析功能。

    ¥nodenext: Currently identical to node16, this is the counterpart of --module nodenext and is set by default with that module setting. It’s intended to be a forward-looking mode that will support new Node.js module resolution features as they’re added.

  • bundler:Node.js v12 引入了一些用于导入 npm 包的新模块解析功能(package.json"exports""imports" 字段),许多打包器采用了这些功能,但没有采用更严格的 ESM 导入规则。此模块解析模式为针对打包器的代码提供了基本算法。默认情况下它支持 package.json "exports""imports",但可以配置为忽略它们。它需要将 module 设置为 esnext

    ¥bundler: Node.js v12 introduced some new module resolution features for importing npm packages—the "exports" and "imports" fields of package.json—and many bundlers adopted those features without also adopting the stricter rules for ESM imports. This module resolution mode provides a base algorithm for code targeting a bundler. It supports package.json "exports" and "imports" by default, but can be configured to ignore them. It requires setting module to esnext.

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

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

还记得 TypeScript job 中关于模块的三个组件吗?

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

  1. 将文件编译为有效的输出模块格式

    ¥Compile files into a valid output module format

  2. 确保这些输出中的导入能够成功解决

    ¥Ensure that imports in those outputs will resolve successfully

  3. 知道要为导入的名称分配什么类型。

    ¥Know what type to assign to imported names.

需要模块解析来完成后两项。但是,当我们将大部分时间花在输入文件上时,很容易忘记(2) - 模块解析的一个关键组成部分是验证输出文件中包含 与输入文件相同的模块说明符 的导入或 require 调用实际上将 在运行时工作。让我们看一个包含多个文件的新示例:

¥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 .*

最后一行表示可以使用 allowArbitraryExtensions 编译器选项输入非 JS 文件,以支持模块系统支持将非 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 吗?” 你可能会反对。确实如此,手写声明文件,甚至移动/复制/重命名它们以表示外部构建工具的输出,都是危险且容易出错的冒险。不使用 tsc 生成 JavaScript 和声明文件的 DefinelyTyped 贡献者和类型化库作者应确保每个 JavaScript 文件都有一个具有相同名称和匹配扩展名的同级声明文件。打破这种结构可能会导致终端用户误报 TypeScript 错误。npm 包 @arethetypeswrong/cli 可以帮助在发布之前捕获并解释这些错误。

¥“But wait! Plenty of declaration files are written by hand, not generated by tsc. Ever heard of DefinitelyTyped?” you might object. And it’s true—hand-writing declaration files, or even moving/copying/renaming them to represent outputs of an external build tool, is a dangerous, error-prone venture. DefinitelyTyped contributors and authors of typed libraries not using tsc to generate both JavaScript and declaration files should ensure that every JavaScript file has a sibling declaration file with the same name and matching extension. Breaking from this structure can lead to false-positive TypeScript errors for end users. The npm package @arethetypeswrong/cli can help catch and explain these errors before they’re published.

打包器、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 文件,并且它最终将消耗并删除你为生成打包包而编写的所有导入。

    ¥You’re bundling this code, the bundler is configured to transpile TypeScript files in-memory, and it will eventually consume and erase all the imports you’ve written to produce a bundle.

  • 你可以直接在 Deno 或 Bun 等 TypeScript 运行时中运行此代码。

    ¥You’re running this code directly in a TypeScript runtime like Deno or Bun.

  • 你正在使用 ts-nodetsx 或其他 Node.js 转译加载器。

    ¥You’re using ts-node, tsx, or another transpiling loader for Node.

在这些情况下,你可以打开 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 的启发,但没有采用 Node.js 应用于导入的 禁用扩展搜索 严格的 ESM 解析算法。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 node16 and nodenext, while always allowing extensionless imports. See Choosing compiler options for more guidance.

库的模块解析

¥Module resolution for libraries

编译应用时,你可以根据模块解析 host 为 TypeScript 项目选择 moduleResolution 选项。编译库时,你不知道输出代码将在哪里运行,但你希望它在尽可能多的地方运行。使用 "module": "nodenext"(以及隐含的 "moduleResolution": "nodenext")是最大化输出 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": "nodenext" (along with the implied "moduleResolution": "nodenext") 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.