模块 - 理论

JavaScript 中的脚本与模块

在 JavaScript 的早期阶段,该语言仅在浏览器中运行,当时还没有模块的概念。不过,通过在 HTML 中使用多个 script 标签,仍然可以将网页的 JavaScript 分割成多个文件。

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

随着网页变得越来越大、越来越复杂,这种方法出现了一些弊端。特别是所有加载到同一页面上的脚本都共享同一个作用域(通常称为“全局作用域”),这意味着脚本必须非常小心,以免相互覆盖变量和函数。

任何通过赋予文件自身作用域,同时提供一种将代码片段提供给其他文件使用的方法来解决此问题的系统,都可以称为“模块系统”。(说模块系统中的每个文件都称为“模块”听起来显而易见,但“模块”这一术语通常用于与“脚本”文件进行对比,后者运行在模块系统之外的全局作用域中。)

现存有许多模块系统,TypeScript 支持生成多种模块,但本文档将重点介绍当今最重要的两个系统:ECMAScript 模块 (ESM) 和 CommonJS (CJS)。

ECMAScript 模块 (ESM) 是内置于语言中的模块系统,在现代浏览器和自 v12 以来的 Node.js 中均受支持。它使用专用的 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) 是在 ESM 成为语言规范的一部分之前,Node.js 最初采用的模块系统。它在 Node.js 中仍与 ESM 并行支持。它使用名为 exportsrequire 的普通 JavaScript 对象和函数。

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 模块时,它首先会假设该文件拥有独立的作用域。然而除此之外,编译器的任务会变得稍微复杂一些。

TypeScript 处理模块的任务

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

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

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

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

请注意,所有这些问题都取决于宿主 (host) 的特性——宿主是最终消费输出 JavaScript(或原始 TypeScript)以指导其模块加载行为的系统,通常是运行时(如 Node.js)或打包工具(如 Webpack)。

ECMAScript 规范定义了 ESM 导入和导出如何相互链接,但并未规定 (4) 中的文件查找(称为模块解析)是如何发生的,也没有说明 CommonJS 等其他模块系统。因此,运行时和打包工具,特别是那些希望同时支持 ESM 和 CJS 的工具,在设计自己的规则方面拥有很大的自由度。因此,TypeScript 如何回答上述问题会根据代码预期运行的位置而发生巨大变化。没有单一的正确答案,所以必须通过配置选项告知编译器相关规则。

另一个需要记住的关键点是,TypeScript 几乎总是从其输出的 JavaScript 文件(而不是输入的 TypeScript 或 JavaScript 文件)的角度来思考这些问题。如今,一些运行时和打包工具支持直接加载 TypeScript 文件,在这种情况下,区分输入文件和输出文件已无意义。本文档的大部分内容讨论的是 TypeScript 文件被编译为 JavaScript 文件,然后由运行时模块系统加载的情况。检查这些案例对于理解编译器的选项和行为至关重要——从这里开始,在思考 esbuild、Bun 和其他以 TypeScript 为先的运行时和打包工具时,简化问题会更容易。因此,就目前而言,我们可以根据输出文件来总结 TypeScript 在处理模块时的任务:

充分理解宿主的规则

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

谁是宿主?

在继续之前,有必要确保我们对“宿主”一词达成共识,因为它会频繁出现。我们之前将其定义为“最终消费输出代码以指导其模块加载行为的系统”。换句话说,它是 TypeScript 之外的、TypeScript 的模块分析试图模拟的系统。

  • 当输出代码(无论是通过 tsc 还是第三方编译器产生)直接在 Node.js 等运行时中运行时,该运行时就是宿主。
  • 当没有“输出代码”因为运行时直接消费 TypeScript 文件时,该运行时仍然是宿主。
  • 当打包工具消费 TypeScript 输入或输出并生成 bundle 时,该打包工具就是宿主,因为它查看了原始的导入/require,查找了它们引用的文件,并生成了一个或一组新的文件,其中原始的导入和 require 被擦除或转换得面目全非。(该 bundle 本身可能包含模块,运行它的运行时将是其宿主,但 TypeScript 不知道打包后发生的任何事情。)
  • 如果另一个转译器、优化器或格式化程序对 TypeScript 的输出进行处理,只要它保持其看到的导入和导出不变,它就不是 TypeScript 所关心的宿主。
  • 在 Web 浏览器中加载模块时,TypeScript 需要模拟的行为实际上被分摊在了 Web 服务器和在浏览器中运行的模块系统之间。浏览器的 JavaScript 引擎(或像 RequireJS 这样的基于脚本的模块加载框架)控制接受哪些模块格式,而 Web 服务器决定当一个模块触发加载另一个模块的请求时发送哪个文件。
  • TypeScript 编译器本身不是宿主,因为它除了试图模拟其他宿主外,不提供任何与模块相关的行为。

模块输出格式

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

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

可用的 module 设置包括:

  • node16:反映 Node.js v16+ 的模块系统,它支持 ES 模块和 CJS 模块并存,具有特定的互操作性和检测规则。
  • node18:反映 Node.js v18+ 的模块系统,它增加了对导入属性的支持。
  • nodenext:随着 Node.js 模块系统的演进而不断更新的目标,反映最新的 Node.js 版本。截至 TypeScript 5.8,nodenext 支持 require ECMAScript 模块。
  • es2015:反映 JavaScript 模块的 ES2015 语言规范(首次在该语言中引入 importexport 的版本)。
  • es2020:在 es2015 的基础上增加了对 import.metaexport * as ns from "mod" 的支持。
  • es2022:在 es2020 的基础上增加了对顶层 await 的支持。
  • esnext:目前与 es2022 相同,但将是一个动态目标,反映最新的 ECMAScript 规范,以及预计将包含在未来规范版本中的与模块相关的第三阶段 (Stage 3+) 提案。
  • commonjs, system, amdumd:每个选项都会按其命名的模块系统输出所有内容,并假设一切都可以成功导入到该模块系统中。这些不再推荐用于新项目,本文档也不会详细介绍它们。

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

模块格式检测

Node.js 同时理解 ES 模块和 CJS 模块,但每个文件的格式由其文件扩展名以及在搜索文件目录及其所有祖先目录时找到的第一个 package.json 文件的 type 字段决定。

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

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

module 编译器选项设置为 node16node18nodenext 时,TypeScript 会将此相同算法应用于项目的输入文件,以确定每个对应输出文件的模块类型。让我们看看在一个使用 --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 文件的存在——这可能是库作者在自己的输入 .ts 文件上运行 tsc 时创建的——由于其 .js 扩展名以及 /node_modules/pkg/package.json 中存在 "type": "module" 字段,Node.js 必须将其解释为 ES 模块。声明文件将在稍后的章节中详细介绍。)

TypeScript 使用输入文件的检测到的模块格式来确保它输出 Node.js 在每个输出文件中所期望的语法。如果 TypeScript 生成带有 importexport 语句的 /example.js,Node.js 在解析该文件时会崩溃。如果 TypeScript 生成带有 require 调用的 /main.mjs,Node.js 在评估时会崩溃。除了输出之外,模块格式还用于确定类型检查和模块解析的规则,我们将在接下来的章节中讨论。

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

值得再次提到的是,TypeScript 在 --module node16--module node18--module nodenext 中的行为完全是由 Node.js 的行为决定的。由于 TypeScript 的目标是在编译时捕获潜在的运行时错误,因此它需要一个关于运行时将会发生什么的非常精确的模型。这套相当复杂的模块类型检测规则对于检查将在 Node.js 中运行的代码是必要的,但如果应用于非 Node.js 宿主,则可能过于严格或直接是不正确的。

输入模块语法

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

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

可能完全按原样以 ESM 格式输出,也可能以 CommonJS 格式输出,

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

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

如今,大多数 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") 语法。

这种“编写 ESM,输出任意格式”策略的优势在于 TypeScript 可以使用标准的 JavaScript 语法,使编写体验对新手来说很熟悉,并且(理论上)使项目将来很容易开始以 ESM 为目标进行输出。这有三个重大缺点,这些缺点直到 ESM 和 CJS 模块在 Node.js 中被允许共存和互操作后才完全显现出来:

  1. 关于 Node.js 中 ESM/CJS 互操作性将如何工作的早期假设被证明是错误的,如今,Node.js 和打包工具之间的互操作性规则各不相同。因此,TypeScript 中的模块配置空间非常大。
  2. 当输入文件中的语法看起来全是 ESM 时,作者或代码审查者很容易忘记文件在运行时到底属于哪种模块。而由于 Node.js 的互操作性规则,每个文件属于哪种模块变得非常重要。
  3. 当输入文件以 ESM 编写时,类型声明输出(.d.ts 文件)中的语法看起来也像 ESM。但是,由于相应的 JavaScript 文件可能以任何模块格式输出,TypeScript 无法仅通过查看其类型声明的内容来判断文件属于哪种模块。同样,由于 ESM/CJS 互操作性的本质,TypeScript 必须知道每个文件属于哪种模块,才能提供正确的类型并防止会导致崩溃的导入。

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

ESM 和 CJS 互操作性

ES 模块可以 import CommonJS 模块吗?如果是这样,默认导入是链接到 exports 还是 exports.default?CommonJS 模块可以 require ES 模块吗?CommonJS 不是 ECMAScript 规范的一部分,因此自 2015 年 ESM 标准化以来,运行时、打包工具和转译器可以自由地对这些问题做出自己的回答,因此不存在标准的互操作性规则集。如今,大多数运行时和打包工具大致分为三类:

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

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

模块说明符默认不会被转换

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

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

可能会根据 module 编译器选项被输出为:

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

或者

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

但无论哪种方式,模块说明符都将是 "./math.mjs"。默认情况下,模块说明符必须以适用于代码的目标运行时或打包工具的方式编写,而理解这些相对于输出的说明符是 TypeScript 的工作。寻找模块说明符引用的文件的过程称为模块解析

TypeScript 5.7 引入了 --rewriteRelativeImportExtensions 选项,它将带有 .ts.tsx.mts.cts 扩展名的相对模块说明符转换为输出文件中的相应 JavaScript 等效项。此选项对于创建可以在开发过程中直接在 Node.js 中运行可以编译为 JavaScript 输出以供分发或生产使用的 TypeScript 文件非常有用。

本文档是在引入 --rewriteRelativeImportExtensions 之前编写的,它所提出的思维模型围绕着对操作其输入文件的宿主模块系统的行为进行建模,无论是对 TypeScript 文件进行操作的打包工具,还是对 .js 输出进行操作的运行时。使用 --rewriteRelativeImportExtensions,应用该思维模型的方法是应用两次:一次是对直接处理 TypeScript 输入文件的运行时或打包工具,另一次是对处理转换后输出的运行时或打包工具。本文档的大部分内容假设加载输入文件或加载输出文件,但其提出的原则可以扩展到两者都被加载的情况。

模块解析

让我们回到我们的第一个示例,回顾一下我们目前学到的关于它的内容:

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

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

模块解析由宿主定义

虽然 ECMAScript 规范定义了如何解析和解释 importexport 语句,但它将模块解析留给了宿主。如果你正在创建一个热门的新 JavaScript 运行时,你可以自由创建一种模块解析方案,例如:

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 不是宿主。

可用的 moduleResolution 选项包括:

  • classic:TypeScript 最古老的模块解析模式,不幸的是,当 module 设置为 commonjsnode16nodenext 以外的任何值时,这是默认值。它可能是为了为各种 RequireJS 配置提供尽力而为的解析而创建的。它不应被用于新项目(甚至是不使用 RequireJS 或其他 AMD 模块加载器的旧项目),并计划在 TypeScript 6.0 中弃用。
  • node10:以前称为 node,当 module 设置为 commonjs 时,这是不幸的默认值。它是 v12 之前 Node.js 版本的一个相当好的模型,有时它是大多数打包工具如何进行模块解析的平庸近似。它支持从 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 处理模块的任务的三个组成部分吗?

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

完成最后两项需要模块解析。但当我们大部分时间在输入文件中工作时,很容易忽略 (2) —— 模块解析的关键组成部分是验证输出文件中的导入或 require 调用是否能真正在运行时工作,而这些输出文件包含与输入文件相同的模块说明符。让我们看一个包含多个文件的新示例。

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 分配一个类型。”

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

这并不完全错误,但现实更深刻。"./math" 的解析(以及随之而来的 add 的类型)需要反映输出文件在运行时真正发生的情况。思考这个过程的一个更稳健的方法是这样的:

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 而言,模块解析主要是准确模拟输出文件之间宿主的模块解析算法的问题,并应用一点点重映射来查找类型信息。让我们看另一个通过简单模型看起来不直观,但通过稳健模型却完全合理的例子。

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 旁边!为什么这能解析?使用我们新的思维模型,这没问题。

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 中的模块解析是这样工作的:鉴于输出文件中的模块说明符将与输入文件中的模块说明符相同的约束,这是唯一能完成我们验证输出文件和分配类型这两个目标的流程。

声明文件的作用

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

这就是声明文件(.d.ts, .d.mts 等)发挥作用的地方。理解声明文件如何被解释的最好方法是理解它们来自哪里。当你在一个输入文件上运行 tsc --declaration 时,你会得到一个输出的 JavaScript 文件和一个输出的声明文件。

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 文件。

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

声明文件扩展名 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 的声明文件表示。

“但是等一下!很多声明文件是手写的,不是tsc 生成的。听说过 DefinitelyTyped 吗?”你可能会反驳。这是真的——手写声明文件,甚至移动/复制/重命名它们以代表外部构建工具的输出,都是一项危险且容易出错的尝试。DefinitelyTyped 贡献者和不使用 tsc 同时生成 JavaScript 和声明文件的类型化库作者应确保每个 JavaScript 文件都有一个名称相同且扩展名匹配的姊妹声明文件。打破这种结构可能会导致最终用户的误报 TypeScript 错误。npm 包 @arethetypeswrong/cli 可以帮助在错误发布前捕获并解释这些错误。

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

到目前为止,我们真正强调了输入文件输出文件之间的区别。回想一下,在相对模块说明符上指定文件扩展名时,TypeScript 通常会强制你使用输出文件扩展名

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 文件呢?如果你处于以下情况之一:

  • 你正在打包此代码,打包工具配置为在内存中转译 TypeScript 文件,它最终将消费并擦除你编写的所有导入以生成一个 bundle。
  • 你正在像 Node、Deno 或 Bun 这样的 TypeScript 运行时中直接运行此代码。
  • 你正在为 Node 使用 ts-nodetsx 或其他转译加载器。

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

无论是否使用 allowImportingTsExtensions,为模块解析宿主选择最合适的 moduleResolution 设置仍然很重要。对于打包工具和 Bun 运行时,它是 bundler。这些模块解析器受到 Node.js 的启发,但没有采用 Node.js 对导入应用的禁用扩展名搜索的严格 ESM 解析算法。bundler 模块解析设置反映了这一点,像 node16-nodenext 一样默认启用 package.json "exports" 支持,同时始终允许无扩展名的导入。有关更多指导,请参阅 选择编译器选项

库的模块解析

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

ts
export * from "./utils";

假设 ./utils.ts(或 ./utils/index.ts)存在,打包工具对这段代码没问题,所以 "moduleResolution": "bundler" 不会报错。使用 "module": "esnext" 编译后,此导出语句的输出 JavaScript 将看起来与输入完全相同。如果该 JavaScript 被发布到 npm,它将被使用打包工具的项目使用,但它在 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?

另一方面,如果我们这样写:

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

这将产生在 Node.js 打包工具中都能运行的输出。

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

当然,此指导仅适用于库从 tsc 输出代码的情况。如果库在发布之前被打包,"moduleResolution": "bundler" 可能是可以接受的。任何更改模块格式或模块说明符以生成库最终构建的构建工具,都有责任确保产品模块代码的安全性和兼容性,而 tsc 不再能为此任务做出贡献,因为它不知道运行时将会存在什么模块代码。


  1. 在 Node.js v20.19.0 及更高版本中,允许 require ES 模块,但前提是已解析的模块及其顶层导入不使用顶层 await。TypeScript 不尝试强制执行此规则,因为它无法从声明文件中判断相应的 JavaScript 文件是否包含顶层 await

TypeScript 文档是一个开源项目。欢迎提交 Pull Request 来帮助我们改进这些页面 ❤

此页面的贡献者
ABAndrew Branch (7)
RPRob Palmer (1)
MSMax Schwenk (1)
FSFilip Sodić (1)

最后更新:2026 年 3 月 27 日