TypeScript 模块系统核心原理:从ESM到CJS,彻底搞懂模块格式与解析逻辑

1. 介绍

本部分并非简单介绍 TypeScript 的模块配置,而是帮助你建立一套完整的模块系统心智模型。通过理解 TypeScript 在模块解析、类型检查、代码转换以及运行时适配过程中的核心原理,你将能够准确判断各种配置选项的作用,并理解其背后的设计逻辑。

掌握这些理论后,阅读和使用后续指南将变得更加轻松。更重要的是,当遇到文档未明确说明的复杂问题时,你可以依靠这套通用分析框架,自行推导出正确的解决思路,而不是依赖经验或零散的配置记忆。

2. 模块 - 理论

模块格式:不同模块系统对"模块如何导出、如何导入以及如何解析依赖"有不同规定,这些规定的集合通常称为模块格式(Module Format)。目前 JavaScript 世界最重要的两种模块格式是 ECMAScript Modules(ESM)和 CommonJS(CJS)。

2.1. JavaScript 中的脚本与模块

在 JavaScript 早期,该语言仅在浏览器中运行时,并没有模块的概念。但通过 HTML 中的多个 script 标签,仍然可以将网页的 JavaScript 代码拆分到多个文件中:

html 复制代码
<html>
  <head>
    <script src="a.js"></script>
    <script src="b.js"></script>
  </head>
  <body></body>
</html>

这种方法有一些缺点,尤其是随着网页变得越来越大、越来越复杂。特别地,加载到同一个页面上的所有脚本共享同一个作用域------全局作用域------这意味着脚本的书写必须非常小心,否则会造成彼此的变量和函数的覆盖。

模块系统是一套用于组织代码、隔离作用域以及建立文件间依赖关系的机制 。它通过为每个文件提供独立的作用域,并允许文件间代码实现共享。文件的共享方式主要按照模块化标准将文件导入导出,不同的模块标准,其代码共享的方式也各不相同。

虽然"模块系统中的每个文件都叫'模块'",但之所以要强调"模块"这个叫法,主要是为了区分另一类不在模块格式内、直接在全局作用域中运行的普通脚本文件(如通过 script 加载的脚本文件)。

TypeScript 支持输出多种模块格式。本文档将重点关注当前最重要的两种模块格式:ECMAScript 模块(ESM)和 CommonJS(CJS)。

ECMAScript 模块 (ESM) 是内置于语言中的模块格式,在现代浏览器中得到支持,并且自 v12 起在 Node.js 中得到支持。它使用专用的 import 和 export 语法:

ts 复制代码
// a.js
export default "Hello from a.js";
ts 复制代码
// b.js
import a from "./a.js";
console.log(a); // 'Hello from a.js'

CommonJS (CJS) 是在 ESM 成为语言规范之前,在 Node.js 中推出的模块格式。它仍然在 Node.js中与 ESM 一起获得支持。它使用名为 exports 和 require 的纯 JavaScript 对象和函数:

ts 复制代码
// a.js
exports.message = "Hello from a.js";
ts 复制代码
// b.js
const a = require("./a");
console.log(a.message); // 'Hello from a.js'

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

2.2. 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 文件, 然后再由运行环境的模块系统加载这些 JavaScript 文件。理解这些情况对于掌握编译器的选项和行为至关重要------从这种情况入手会更容易,之后在考虑 esbuild、Bun 以及其他优先支持 TypeScript 的运行环境和打包器时,再在此基础上进行简化理解。 因此,目前可以从输出文件的视角来理解 TypeScript 在模块系统中的职责 。 充分理解宿主模块系统的规则,以便:

  • 将文件编译成有效的输出模块格式;
  • 确保这些输出中的导入能够成功解析;
  • 知道如何为导入的名称分配什么类型。

2.3. 谁是宿主?

在我们继续之前,有必要确保我们对宿主这个术语的理解是一致的,因为它会频繁出现。我们之前将其定义为"最终消费输出代码以指导其模块加载行为的系统"。换句话说,它是 TypeScript 试图建模的 TypeScript 外部的系统:

  • 当输出代码(无论是通过tsc还是第三方转译器生成)直接在 Node.js 等运行环境中运行时,该运行环境就是宿主。
  • 当运行环境直接消费 TypeScript 文件,而不存在"输出代码"时,该运行环境仍然是宿主。
  • 当打包器消费 TypeScript 的输入或输出文件并生成一个 bundle 时,该打包器就是宿主。因为打包器会分析原始的import/require语句,查找它们所引用的文件,并生成一个或多个新文件,其中原始的import/require可能被完全删除或被转换得面目全非。(这个 bundle 本身可能也包含模块,而运行该 bundle 的运行环境将成为其宿主,但 TypeScript 对打包器之后发生的任何事都一无所知。)
  • 如果有另一个转译器、优化器或格式化器在 TypeScript 的输出文件上运行,只要它不修改其中的导入和导出语句,它就不是 TypeScript 所关心的宿主。
  • 在 Web 浏览器中加载模块时,TypeScript 需要模拟的是浏览器的模块加载行为。浏览器根据模块标识符确定需要请求的资源,而 Web 服务器负责返回对应文件。
  • TypeScript 编译器本身不是宿主,因为它除了尝试模拟其他宿主的行为之外,并不提供任何与模块相关的自身行为。

2.4. 模块输出格式

在任何项目中,我们首先要确认宿主期望什么样的模块格式,以便 TypeScript 可以为每个文件设置与之匹配的输出格式。有时,宿主只支持一种模块格式------例如,浏览器原生支持 ECMAScript 模块,或 Node.js v11 及更早版本中的 CJS。 Node.js v12 及更高版本同时支持 CJS 与 ESM 两种模块格式,但会通过文件扩展名和package.json 文件来确定每个文件应该是什么格式,并且如果文件内容与预期格式不符,则会抛出错误。

module 选项用于告诉 TypeScript 应按照哪套模块系统规则分析代码,并决定最终输出的模块格式。它的主要目的是控制编译输出的 JavaScript 使用什么样模块格式,同时也用于告知编译器,如何检测每个文件的模块格式、不同模块格式之间如何允许相互导入,以及 import.meta 和顶级 await 等特性是否可用。因此,即使 TypeScript 项目开启了 noEmit,但为module选择正确的设置仍然很重要。正如我们之前所述,编译器需要准确理解模块格式,才能对导入进行正确的类型检查(并提供智能提示)。有关如何为项目选择合适的module设置,请参阅"选择编译器选项"相关指南。可用的 module 设置包括:

  • node16:反映 Node.js v16+ 的模块格式,支持 ESM和 CJS 模块并存,具有特定的互操作性和检测规则。
  • node18:反映 Node.js v18+ 的模块格式,增加了对导入属性的支持。
  • nodenext:一个动态更新的目标,随 Node.js 模块格式的发展而同步更新。 截至 TypeScript 5.8,nodenext 已反映 Node.js 的最新行为,允许在满足条件时通过 require 加载 ECMAScript 模块。
  • es2015:反映 ES2015 语言规范中定义的 JavaScript 模块(首次将 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 中运行的项目,即使tsc编译输出的所有文件是 ESM 或 CJS,将module指定为esnextcommonjs也是不正确的。对于打算在 Node.js 中运行的项目,唯一正确的module设置是 node16nodenext。需要注意的是,一个纯 ESM 的 Node.js 项目,分别使用esnextnodenext 编译时,输出的 JavaScript 代码可能看起来完全相同,但类型检查的结果可能会有所不同 。更多详细信息请参阅关于nodenext的参考章节。

2.4.1. 模块格式检测

Node.js 同时支持两种模块格式:ESM 和 CJS。但每个文件的格式由其文件扩展名 以及在文件目录及其所有祖先目录中搜索找到的第一个package.json文件的 type 字段共同决定:

  • .mjs 和 .cjs 文件始终分别被解释为 ESM 和 CJS 模块。
  • 如果最近的 package.json 文件中包含 "type": "module",则 .js 文件会被解释为 ESM。如果没有 package.json 文件,或者 type 字段缺失或取其他值,则 .js 文件被解释为 CJS 模块, 即当未显式指定时,Node.js 默认按 CommonJS 处理。

如果根据上述规则确定一个文件是 ESM(即 package.json 选项参数 type 的值为 module),Node.js 在运行该文件时就不会将 CommonJS 的modulerequire对象注入到其作用域中。因此,如果在该文件中尝试使用modulerequire,会导致程序崩溃。相反,如果一个文件被确定为 CJS 模块,而文件中却使用了importexport 声明,则会因语法错误而崩溃。

module编译器选项设置为node16node18nodenext时,TypeScript 会将相同的算法 应用于项目的输入文件 , 以确定每个对应输出文件的模块格式 。下面我们通过一个使用 --module nodenext 的示例项目,来看一下模块格式具体是如何被检测的:

当输入文件扩展名是 .mts 或 .cts 时,TypeScript 会分别将其视为 ES 模块或 CJS 模块,因为 Node.js 会将编译输出的 .mjs 文件视为 ES 模块,或将编译输出的 .cjs 文件视为 CJS 模块。当输入文件扩展名是 .ts 时,TypeScript 需要查阅 package.json 文件(距离当前模块最近)中的 type 字段来确定模块格式,因为这也是 Node.js 在遇到编译输出的 .js 文件时所会做的事情。(请注意,相同的规则也适用于 pkg 依赖项(第三方依赖库)中的 .d.cts 和 .d.ts 声明文件:虽然它们不会作为本次编译的一部分生成输出文件,但 .d.ts 文件的存在意味着存在一个相应的 .js 文件------这个 .js 文件可能是 pkg 库的作者对自己编写的 .ts 输入文件运行 tsc 时创建的。由于该文件扩展名为 .js,且 /node_modules/pkg/package.json 中包含 "type": "module"字段,Node.js 必须将其解释为 ES 模块)。

TypeScript 会根据模块格式检测结果(文件扩展名与 package.json 中的 type 配置),确保为每个输出文件生成符合 Node.js 预期模块格式的代码。如果 TypeScript 在/example.js中生成了importexport 语句,Node.js 在解析该文件时就会崩溃。同样,如果 TypeScript 在/main.mjs 中生成了require调用,Node.js 在运行该文件时也会崩溃。除了影响输出代码之外,模块格式还用于确定类型检查和模块解析的规则,我们将在接下来的章节中讨论。

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

值得再次强调的是,TypeScript 在 --module node16--module node18--module nodenext 下的行为完全由 Node.js 的行为所驱动。由于 TypeScript 的目标是在编译时捕获潜在的运行时错误,它需要非常准确地模拟运行时的实际行为。因此,这套相当复杂的模块类型检测规则对于检查将在 Node.js 中运行的代码是必要的。但如果将其应用于非 Node.js 的运行环境,则可能显得过于严格,甚至完全错误。

因此,在 node16/node18/nodenext 模式下,一个 .ts 文件究竟属于 ESM 还是 CJS,并不能仅通过文件内容判断,还必须结合其所在目录的 package.json 中的 type 字段。

2.4.2. 输入模块语法

需要特别注意的是,输入源文件中的模块语法与最终输出到 JS 文件中的模块语法在某种程度上是解耦的。也就是说,一个使用了 ESM 导入语法的文件:

ts 复制代码
import { sayHello } from "greetings";
sayHello("world");

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

js 复制代码
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 这样的工具使得开发者可以用 ESM 编写 JavaScript,然后将其降级转换为另一种可以在 Node.js 或浏览器中使用的模块格式。TypeScript 也紧随其后,在 1.5 版本中增加了对 ES 模块语法的支持,并温和地引导开发者避免使用原始的、受 CommonJS 启发的 import fs = require("fs") 语法。

这种"使用 ESM 语法编写,输出任意模块格式"的策略有其优点:TypeScript 可以使用标准的 JavaScript 语法,让新开发者获得熟悉的编写体验,并且(理论上)使项目将来能够轻松地切换到 ESM 输出。但这个策略也存在三个显著的缺点------这些缺点直到 ESM 和 CJS 模块被允许在 Node.js 中共存并相互操作之后才完全显现出来:

  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 开发者确切了解importexport语句会以何种方式输出。启用该选项后,它要求输入文件中的导入和导出必须按照输出前转换最少的形式来编写。也就是说,如果一个文件将以 ESM 格式输出,那么导入和导出必须使用 ESM 语法编写;如果一个文件将以 CJS 格式输出,则必须使用受 CommonJS 启发的 TypeScript 语法(import fs = require("fs")export = {})编写。这个选项特别推荐用于那些主要使用 ESM 但包含少量 CJS 文件的 Node.js 项目。不推荐用于当前以 CJS 为目标但未来可能希望迁移到 ESM 的项目。

2.4.3. ESM 和 CJS 互操作性

ES 模块可以导入 CommonJS 模块吗?如果可以,默认导入是链接到 exports 还是 exports.default?CommonJS 模块可以 require 一个 ES 模块吗?

CommonJS 不是 ECMAScript 规范的一部分,因此自 2015 年 ESM 标准化以来,每个运行时、打包器和转译器都可以自行决定这些规则。也就是说,并不存在一套标准的互操作性规则集。如今,大多数运行时和打包器大致可分为以下三类:

  • 纯 ESM:一些运行时(如浏览器引擎)只支持语言规范中实际定义的部分,即只支持 ECMAScript 模块。
  • 类打包器 :在主流 JavaScript 引擎能够原生运行 ES 模块之前,Babel 就已经允许开发者编写 ES 模块,并将其转译为 CommonJS。这种转译后的文件与手写 CJS 文件之间的交互方式,衍生出一套较为宽松的互操作性规则,如今已成为打包器和转译器的事实标准
  • Node.js :在 Node.js v20.19.0 之前,CommonJS 模块无法同步 加载 ES 模块(即不能使用require),只能通过动态import()调用进行异步 加载。而 ES 模块可以默认导入 CJS 模块,并且这个默认导入始终绑定到 CJS 模块的exports对象。(这意味着,对于带有 __esModule 标记的、由 Babel 转换生成的 CJS 输出,在 Node.js 与某些打包器环境下,其默认导入的行为会有所不同。)

TypeScript 需要知道采用哪套规则集,以便在导入(尤其是 default 导入)上提供正确的类型,并对那些将在运行时导致崩溃的导入报错。当 module 编译器选项设置为 node16node18nodenext 时,TypeScript 会强制执行 Node.js 特定版本的规则。而对于所有其他 module 设置,TypeScript 会结合 esModuleInterop 选项( 是一个编译期选项,它会同时影响 TypeScript 的类型检查行为以及生成的 CommonJS 互操作代码,但不会修改实际运行时环境),采用类似打包器的互操作性规则。(需要注意的是,虽然使用 --module esnext 可以防止你编写 CommonJS 模块,但并不能防止你将它们作为依赖项导入。目前 TypeScript 中没有哪个设置能够禁止 ES 模块导入 CommonJS 模块------对于直接运行在浏览器中的代码来说,这反而是合适的。)

2.4.4. 模块标识符默认不会被转换

虽然module编译器选项可以将输入文件中的导入和导出转换为输出文件中的不同模块格式,但模块标识符 (从中导入或传递给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 输出文件用于分发或生产环境。

本文档的核心分析思路,是围绕"模拟宿主模块系统如何处理其输入文件"这一心智模型构建的。这里的输入文件,可能指打包器直接处理的 TypeScript 源文件,也可能指运行环境直接加载的 JavaScript 输出文件。在此基础上,TypeScript 5.7 引入了--rewriteRelativeImportExtensions选项,它允许在源文件中使用 .ts 扩展名进行导入,并在编译时自动重写为 .js。使用该选项时,上述心智模型需要应用两次:第一次应用于直接处理 TypeScript 源文件的运行环境或打包器,第二次应用于处理编译转换后得到的 JavaScript 输出文件的运行环境或打包器。本文档的大部分内容为了简化,假设只加载输入文件或只加载输出文件,但所阐述的原则完全可以扩展到两种文件同时被加载的场景。

2.5. 模块解析

让我们回到第一个例子,回顾一下我们到目前为止对它的了解:

ts 复制代码
import sayHello from "greetings";
sayHello("world");

到目前为止,我们已经讨论了宿主的模块系统以及 TypeScript 的 module 编译器选项会如何影响这段代码。我们知道,代码中的导入语法看起来是 ESM(例如使用了 import 关键字),但最终编译输出的模块格式取决于 module 编译器选项,同时还可能受到文件扩展名(如 .mts.cts.js)以及 package.json 中的 "type" 字段的影响。

我们还知道,sayHello 这个变量最终会被绑定成什么内容------比如它是一个函数、一个对象,还是一个类的实例------以及这条导入语句本身是否合法、是否会被 TypeScript 允许通过,这些都可能会因为当前文件目标模块文件各自的模块格式(是 ESM 还是 CJS)不同而有所差异。

但是,到目前为止,我们还没有讨论一个关键问题:TypeScript 是如何根据 "greetings" 这个模块标识符,找到对应文件的? 这个查找过程,就是下一节要介绍的"模块解析"。

2.5.1. 模块解析是由宿主定义的

虽然 ECMAScript 规范定义了 importexport 语句的语法解析规则以及运行时的执行行为,但它将模块解析(即根据模块标识符查找对应文件的过程)完全留给了宿主环境去实现。如果你正在创建一个热门的新 JavaScript 运行时,你可以自由地创建一个模块解析方案,例如:

ts 复制代码
import monkey from "🐒"; // 查找 './eats/bananas.js'
import cow from "🐄";    // 查找 './eats/grass.js'
import lion from "🦁";   // 查找 './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时,默认采用的就是这个模式(这同样不太理想)。对于 Node.js v12 之前的版本,这个模式能够很好地模拟 其模块解析行为;有时,它能很好地模拟多数打包工具的模块解析的方式。 支持从node_modules中查找包、加载目录中的 index.js文件,以及允许在相对模块标识符中省略.js扩展名。然而,由于 Node.js v12 为 ES 模块引入了不同的模块解析规则,这个模式对于现代版本的 Node.js 来说是一个非常糟糕的模式不应用于新项目。

node16

这个选项与--module node16--module node18是配对使用的。当module设置为node16node18时,moduleResolution会默认为node16。Node.js v12 及更高版本同时支持 ESM 和 CJS,并且各自使用不同的模块解析算法。在 Node.js 中,无论是静态import,还是动态import()中的模块标识符,都不允许省略文件扩展名或/index.js后缀,而require则允许。node16解析模式会根据--module node16--module node18设定的模块格式检测规则,在判定文件为 ES 模块时,强制要求模块标识符中不能省略文件扩展名或 /index.js 后缀。(注意:对于 node16nodenextmodulemoduleResolution 是强绑定的------将其中一个设置为 node16nodenext,而将另一个设置为其他值,会导致错误。)

nodenext

这个选项与--module nodenext是配对使用的。当module设置为nodenext时,moduleResolution会默认为nodenext。目前,它的行为与node16相同。它的目标是成为一个前瞻性的模式,能够随着 Node.js 模块解析新功能的引入而同步更新。

bundler

Node.js v12 在package.json中引入了"exports""imports"字段,作为导入 npm 包时的模块解析新特性。许多打包器采用了这些特性,但并没有同时采用针对 ESM 导入的更严格规则 (例如禁止省略扩展名)。此模块解析模式为针对打包器的代码提供了基本的解析算法。它默认支持package.json"exports""imports"字段,但可以通过配置忽略它们。使用此模式需要将module设置为esnext

2.5.2. TypeScript 会模拟宿主环境的模块解析行为,同时为模块添加类型信息

让我们回忆一下 TypeScript 在处理模块时需要完成三项核心任务:

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

要完成后两项任务,就需要用到模块解析。但是,当我们大部分时间都在处理输入文件时,很容易忽略第(2)点。模块解析的一个关键作用就是:验证输出文件中的那些 import 或 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 的类型),必须能够反映输出文件在运行时的真实情况。一个更严谨的思考过程如下:

输入文件 (main.ts)

→ 包含标识符 "./math" 的 import

→ TypeScript 编译器:

  1. 按宿主规则解析模块标识符
  2. 找到对应输出文件
  3. 回溯到源文件或声明文件
  4. 获取类型信息
    → 最终得到 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 的 ESMimport声明使用严格的模块解析算法,要求相对路径必须包含文件扩展名。当我们只考虑输入文件时,"./math.mjs"似乎解析到了math.mts,这看起来有点奇怪。而且由于我们使用了outDir将编译输出放到另一个目录中,math.mjs甚至与main.mts不在同一个目录下!为什么这个导入能够被正确解析?用我们的新思维方式来看,这完全没有问题:

输入文件 src/main.mts

→ 包含一条导入语句,标识符为"./math.mjs"

TypeScript 编译器的任务

  1. 解析到输出文件 :假设输出文件是dist/main.mjs,那么"./math.mjs"会解析成什么?→ dist/math.mjs
  2. 映射回输入文件 :哪个输入文件生成了dist/math.mjs?→src/math.mts
  3. 加载类型 :从src/math.mts中加载类型信息
    → 最终得到add的类型

理解这个思维方式 ,可能不会立刻消除在输入文件中看到输出文件扩展名(如 .mjs)的那种奇怪感觉。人们很自然地会想到一些捷径:"./math.mjs"实际上指向的是输入文件math.mts,我必须在源代码中写输出文件的扩展名,但编译器知道,当写.mjs时它应该去查找 .mts。这种捷径甚至就是编译器内部实际工作的方式。

但是,更严谨的理解模型能够解释为什么 TypeScript 的模块解析要这样工作:考虑到输出文件中的模块标识符必须与输入文件中的模块标识符保持一致这一约束,这是唯一能够同时完成"验证输出文件"和"分配类型"这两个目标的过程。

TypeScript 并不是先寻找类型,再推导运行时行为;恰恰相反,它首先模拟宿主环境的运行时模块解析过程,然后再沿着解析结果回溯获取类型信息。

2.5.3. 声明文件的作用

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

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

输入 math.ts

tsc --declaration

输出 math.js (JavaScript 文件) 和 math.d.ts (声明文件)

由于这种关系,编译器假设无论在哪里看到声明文件,都存在一个相应的 JavaScript 文件,该文件被声明文件中的类型信息完美地描述。出于性能原因,在每种模块解析模式下,编译器总是首先查找 TypeScript 文件和声明文件,如果找到,它不会继续查找相应的 JavaScript 文件。如果找到 TypeScript 输入文件,它知道编译后会存在一个 JavaScript 文件;如果找到一个声明文件,它知道编译(也许是其他人的)已经发生,并在创建声明文件的同时创建了一个 JavaScript 文件。

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

最后一行表示,可以使用allowArbitraryExtensions编译器选项为非 JS 文件添加类型,以支持模块格式将非 JS 文件作为 JavaScript 对象导入的情况。例如,一个名为styles.css的文件可以由一个名为 styles.d.css.ts 的声明文件来表示。

"但是等等!很多声明文件是手写的,不是由tsc生成的。你听说过 DefinitelyTyped 吗?"你可能会反对。事实确实如此------手写声明文件,甚至移动/复制/重命名它们以表示外部构建工具的输出,都是一项危险且容易出错的工作。DefinitelyTyped 的贡献者和不使用tsc同时生成 JavaScript 和声明文件的类型化库的作者,应确保每个 JavaScript 文件都有一个同名的、扩展名匹配的声明文件作为兄弟文件。偏离这种结构可能导致最终用户出现误报的 TypeScript 错误。npm 包 @arethetypeswrong/cli 可以帮助在发布前捕获并解释这些错误。

对于 TypeScript 来说,.d.ts 文件的存在意味着:"这里应该存在一个与之对应的 JavaScript 模块"。

2.5.4. 针对打包器、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";
//                  ^^^^^^^^^^^
// 只有在启用 'allowImportingTsExtensions' 时,导入路径才能以 '.ts' 扩展名结尾。

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

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

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

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

2.5.5. 针对库的模块解析

在编译应用程序时,你需要根据模块解析宿主 是谁,来为 TypeScript 项目选择合适的 moduleResolution 选项。而在编译库时,你并不知道输出代码最终会在什么环境中运行,但你希望它能在尽可能多的环境中正常运行。此时,使用 "module": "node18"(以及它隐含的 "moduleResolution": "node16")是最大化输出 JavaScript 模块标识符兼容性的最佳选择,因为它会强制你遵守 Node.js 对模块导入解析的更严格规则。下面我们来看一下,如果一个库使用 "moduleResolution": "bundler"(甚至更糟的 "node10")进行编译,会发生什么:

ts 复制代码
export * from "./utils";

假设 ./utils.ts(或 ./utils/index.ts)存在,打包器能够正常处理这段代码,因此"moduleResolution": "bundler"不会报错。使用"module": "esnext"进行编译时,这条export语句输出的 JavaScript 代码将与输入代码完全相同。如果将这段 JavaScript 代码发布到 npm,它能够被使用打包器的项目正常使用,但在 Node.js 中运行时却会导致错误:

ts 复制代码
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 已无法再为此任务做出贡献,因为它无法预知运行时实际会存在什么样的模块代码。


自 Node.js v20.19.0 起,允许使用require加载一个 ES 模块,但前提是:被解析的模块及其顶级导入语句中都没有使用顶级await。TypeScript 不会尝试强制执行这条规则,因为它无法仅通过查看声明文件来判断对应的 JavaScript 文件中是否包含了顶级await

相关推荐
Lear1 小时前
CSR、SSR、SSG 到底怎么选?一文讲透现代前端三大渲染模式
前端
এ慕ོ冬℘゜1 小时前
前端分页组件完整实现:样式 + 交互 + 逻辑全优化
前端·交互
Ajie'Blog1 小时前
Claude Opus 4.8 发布:Claude Code 能不能接住复杂项目
服务器·前端·javascript·人工智能·ai编程
San813_LDD1 小时前
[后端开发]GET/POST_带参/不带参
前端·后端·计算机网络·json
问心无愧05131 小时前
ctf show web入门101
android·前端·笔记
且听风吟_xincell1 小时前
从零用 TypeScript 写一个 TCP 聊天室(下)——数据持久化、登录验证与管理指令
jvm·tcp/ip·typescript
AI周红伟2 小时前
事件分析:FDE标准,“OpenClaw+RAG+Agent” 应用实战的标准
前端·人工智能·chrome·chatgpt·aigc
Mike_jia2 小时前
Databasus:开源数据库备份革命的里程碑,企业级数据安全的守护神
前端
恋猫de小郭2 小时前
真正的跨平台 AI 自动化框架,甚至还支持鸿蒙
android·前端·flutter