模块 - 理论

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 都支持它。它使用专门的 import 和 export 语法:

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 一起受到支持。它使用名为 exports 和 require 的普通 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)中检测到的模块类型,使用(3)中确定的语法来引用(5)中检测到的模块类型?
  7. 在 "greetings" 模块被分析之后,该模块的哪一部分被绑定到 sayHello?

注意,所有这些问题都取决于宿主的特性——宿主是指最终消费输出 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 支持对 ECMAScript 模块使用 require。
  • es2015:反映 JavaScript 模块的 ES2015 语言规范(即首次将 import 和 export 引入该语言的版本)。
  • es2020:在 es2015 的基础上增加了对 import.meta 和 export * as ns from "mod" 的支持。
  • es2022:在 es2020 的基础上增加了对顶层 await 的支持。
  • esnext:目前与 es2022 相同,但今后会是一个不断变化的目标,反映最新的 ECMAScript 规范,以及预计将被纳入未来规范版本的、与模块相关的 Stage 3+ 提案。
  • commonjs、system、amd 和 umd:各自将全部内容输出为所指名的模块系统,并假定一切都能成功导入该模块系统。这些设置已不再推荐用于新项目,本文档也不会详细介绍它们。

Node.js 关于模块格式检测和互操作的规则意味着:对于在 Node.js 中运行的项目,把 module 指定为 esnext 或 commonjs 是不正确的,即使 tsc 生成的所有文件分别都是 ESM 或 CJS。对于打算在 Node.js 中运行的项目,唯一正确的 module 设置是 node16 和 nodenext。虽然对于全 ESM 的 Node.js 项目,使用 esnext 和 nodenext 编译生成的 JavaScript 可能看起来完全相同,但类型检查可能不同。更多细节请参见关于 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 的 module 和 require 对象注入该文件的作用域,因此试图使用它们的文件会导致崩溃。反过来,如果判定某个文件是 CJS 模块,该文件中的 import 和 export 声明会导致语法错误并崩溃。

当 module 编译器选项设置为 node16、node18 或 nodenext 时,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 文件——可能是在 pkg 库的作者对自己输入的 .ts 文件运行 tsc 时创建的——由于该文件具有 .js 扩展名,且 /node_modules/pkg/package.json 中存在 "type": "module" 字段,Node.js 必须把它解释为 ES 模块。声明文件将在后面的章节中更详细地介绍。)

TypeScript 使用检测到的输入文件模块格式,来确保其在每个输出文件中输出 Node.js 所期望的语法。如果 TypeScript 输出的 /example.js 中包含 import 和 export 语句,Node.js 在解析该文件时会崩溃。如果 TypeScript 输出的 /main.mjs 中包含 require 调用,Node.js 在求值时会崩溃。除了输出之外,模块格式还用于确定类型检查和模块解析的规则,我们将在后续章节中讨论。

从 TypeScript 5.6 开始,其他 --module 模式(如 esnext 和 commonjs)也会把特定格式的文件扩展名(.mts 和 .cts)视为对输出格式的文件级覆盖。例如,即使 --module 设为 commonjs,名为 main.mts 的文件也会向 main.mjs 输出 ESM 语法。

值得再次强调的是,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 语法(import 和 export 语句)编写。这在很大程度上是 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 输出为目标。但它有三个显著的缺点,这些缺点只有在 Node.js 允许 ESM 和 CJS 模块共存并互操作之后才完全显现:

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

在 TypeScript 5.0 中,引入了一个名为 verbatimModuleSyntax 的新编译器选项,帮助 TypeScript 作者准确了解他们的 import 和 export 语句将如何输出。启用该标志后,输入文件中的导入和导出必须以在输出前经过最少转换的形式编写。因此,如果文件将作为 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 就允许开发者编写 ES 模块,方式是将其转译为 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 编译器选项设置为 node16、node18 或 nodenext 时,将强制执行 Node.js 特定版本的规则。1 其他所有 module 设置与 esModuleInterop 选项组合时,在 TypeScript 中会产生类似打包器的互操作行为。(尽管使用 --module esnext 会阻止你编写 CommonJS 模块,但它并不能阻止你将它们作为依赖导入。目前还没有任何 TypeScript 设置能够阻止 ES 模块导入 CommonJS 模块,而对于直接面向浏览器的代码来说,这样的设置才是合适的。)

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

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

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

可能会被输出为以下两种之一:

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

或者:

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

这取决于 module 编译器选项,但无论哪种情况,模块说明符都会是 "./math.mjs"。默认情况下,模块说明符的写法必须适用于代码的目标运行时或打包器,而理解这些相对于输出的说明符是 TypeScript 的职责。找到模块说明符所引用文件的过程称为模块解析。

TypeScript 5.7 引入了 --rewriteRelativeImportExtensions 选项,它会在输出文件中将带有 .ts、.tsx、.mts 或 .cts 扩展名的相对模块说明符转换为对应的 JavaScript 形式。这个选项非常适合创建这样的 TypeScript 文件:在开发时可以直接在 Node.js 中运行,同时仍能编译为 JavaScript 输出,用于分发或生产环境。

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

模块解析

让我们回到第一个示例,回顾一下到目前为止我们了解到的内容:

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

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

模块解析由宿主定义

虽然 ECMAScript 规范定义了如何解析和解释 import 与 export 语句,但它把模块解析留给了宿主。如果你正在打造一个热门的新 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 根本不知道应该给 monkey、cow 和 lion 赋予什么类型。正如 module 告知编译器宿主期望的模块格式一样,moduleResolution 以及一些自定义选项指定了宿主用于将模块说明符解析为文件的算法。这也解释了为什么 TypeScript 在输出时不会修改导入说明符:导入说明符与磁盘上文件(如果存在的话)之间的关系是由宿主定义的,而 TypeScript 并不是宿主。

可用的 moduleResolution 选项有:

  • classic:TypeScript 最古老的模块解析模式;不幸的是,当 module 设置为 commonjs、node16 或 nodenext 以外的任何值时,它就是默认值。它可能是为了给各种 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 语句和动态 import() 调用中的模块说明符不允许省略文件扩展名或 /index.js 后缀,而 require 调用中的模块说明符则可以。此模块解析模式会在必要时理解并强制执行这一限制,具体取决于 --module node16/node18 所确立的模块格式检测规则。(对于 node16 和 nodenext,module 与 moduleResolution 必须一致:将其中一个设为 node16 或 nodenext 而将另一个设为其他值会报错。)
  • 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 赋予类型。”

这并非完全错误,但实际情况要更深一层。"./math" 的解析(以及随后的 add 的类型)需要反映输出文件在运行时发生的真实情况。更稳健的思考方式是这样的:

这个模型清楚地表明,对 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 旁边!它为什么应该解析成功呢?用我们新的心智模型来看,这完全没问题:

理解这个心智模型可能不会立刻消除在输入文件中看到输出文件扩展名的别扭感,而且按捷径来思考是很自然的:"./math.mjs" 指的是输入文件 math.mts。我必须写输出扩展名,但编译器知道我写 .mjs 时会去找 .mts。这个捷径甚至就是编译器内部的工作方式,但更稳健的心智模型解释了 TypeScript 中模块解析为什么要这样做:在输出文件中的模块说明符将与输入文件中的模块说明符相同这一约束下,这是唯一能同时实现验证输出文件和赋予类型这两个目标的过程。

声明文件的作用

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

这时声明文件(.d.ts、.d.mts 等)就派上用场了。理解声明文件如何被解释的最好方式,是理解它们从何而来。对输入文件运行 tsc --declaration 时,你会得到一个输出的 JavaScript 文件和一个输出的声明文件:

由于这种关系,编译器假定:只要它看到声明文件,就存在一个对应的 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 .*

最后一行表明,非 JS 文件可以通过 allowArbitraryExtensions 编译器选项进行类型化,以支持模块系统允许将非 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,而且如果输出 JS 文件中出现 "./math.ts",该导入在运行时无法解析到另一个 JS 文件。TypeScript 确实希望防止你生成不安全的输出 JS 文件。但如果根本没有输出 JS 文件呢?如果你处于以下情形之一呢:

  • 你正在打包这段代码,打包器被配置为在内存中转译 TypeScript 文件,并且它最终会消费并擦除你编写的所有导入,以产出打包产物。
  • 你正在像 Node、Deno 或 Bun 这样的 TypeScript 运行时中直接运行这段代码。
  • 你正在使用 ts-node、tsx 或 Node 的其他转译型加载器。

在这些情况下,你可以开启 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" 编译后,这条 export 语句输出的 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 帮助我们改进这些页面 ❤

最后更新:2026 年 10 月 23 日