模块 - 理论
JavaScript 中的脚本与模块
在 JavaScript 的早期,当这门语言只在浏览器中运行时,并没有模块的概念,但仍然可以通过在 HTML 中使用多个 script 标签来将网页的 JavaScript 代码分割到多个文件中:
<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 成为语言规范的一部分之前就已经存在。它仍然与 ESM 一起在 Node.js 中得到支持。它使用普通的 JavaScript 对象和名为
exports和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 模块时,它首先假设该文件将拥有自己的作用域。但除此之外,编译器的工作会变得更复杂一些。
TypeScript 在模块方面的工作
TypeScript 编译器的主要目标是通过在编译时捕获某些类型的运行时错误来防止它们发生。无论是否涉及模块,编译器都需要了解代码预期的运行时环境——例如,有哪些全局变量可用。当涉及模块时,编译器为了完成其工作还需要回答几个额外的问题。让我们用几行输入代码作为示例,来思考分析它所需的所有信息:
import sayHello from "greetings";
sayHello("world");为了检查这个文件,编译器需要知道 sayHello 的类型(它是一个可以接受一个字符串参数的函数吗?),这又引出了许多额外的问题:
- 模块系统将直接加载这个 TypeScript 文件,还是将加载我(或其他编译器)从这个 TypeScript 文件生成的 JavaScript 文件?
- 给定模块系统将加载的文件名及其在磁盘上的位置,它期望找到什么种类的模块?
- 如果要输出 JavaScript,此文件中出现的模块语法将在输出代码中如何转换?
- 模块系统将在哪里查找由
"greetings"指定的模块?查找会成功吗? - 通过该查找解析到的文件是什么种类的模块?
- 模块系统是否允许在 (2) 中检测到的模块种类使用在 (3) 中确定的语法引用在 (5) 中检测到的模块种类?
- 一旦分析了
"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 在模块方面的工作总结为以下几点:
充分理解宿主规则,以便
- 将文件编译成有效的输出模块格式,
- 确保这些输出文件中的导入能够成功解析,以及
- 知道要为导入的名称赋予什么类型。
谁是宿主?
在我们继续之前,有必要确保我们对宿主这个术语的理解是一致的,因为它将频繁出现。我们之前将其定义为“最终消耗输出代码以指导其模块加载行为的系统”。换句话说,它是 TypeScript 试图建模的 TypeScript 之外的系统:
- 当输出代码(无论是由
tsc还是第三方转译器生成)直接在 Node.js 等运行时中运行时,运行时就是宿主。 - 当不存在“输出代码”因为运行时直接消耗 TypeScript 文件时,运行时仍然是宿主。
- 当打包器消耗 TypeScript 输入或输出并生成一个打包文件时,打包器就是宿主,因为它查看了原始的 import/require 集合,查找它们引用的文件,并生成一个新的文件或一组文件,其中原始的导入和导出已被擦除或转换得面目全非。(该打包文件本身可能包含模块,运行它的运行时将是它的宿主,但 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 选择正确的设置仍然很重要。正如我们之前所说,编译器需要准确理解模块系统,以便对导入进行类型检查(并提供智能感知)。关于如何为项目选择正确的 module 设置,请参阅选择编译器选项。
可用的 module 设置包括:
node16:反映了 Node.js v16+ 的模块系统,它支持 ES 模块和 CJS 模块并存,并具有特定的互操作性和检测规则。node18:反映了 Node.js v18+ 的模块系统,它增加了对导入属性的支持。nodenext:一个动态目标,随着 Node.js 模块系统的发展而反映最新的 Node.js 版本。在 TypeScript 5.8 中,nodenext支持requireECMAScript 模块。es2015:反映了 JavaScript 模块的 ES2015 语言规范(该版本首次将import和export引入语言)。es2020:在es2015基础上增加了对import.meta和export * as ns from "mod"的支持。es2022:在es2020基础上增加了对顶层await的支持。esnext:目前与es2022相同,但将是一个动态目标,反映最新的 ECMAScript 规范,以及预期将包含在即将到来的规范版本中的与模块相关的第 3 阶段提案。commonjs、system、amd和umd:每个都以指定的模块系统输出所有内容,并假设所有内容都可以成功导入到该模块系统中。这些不再推荐用于新项目,本文档将不详细讨论。
Node.js 的模块格式检测和互操作性规则使得为在 Node.js 中运行的项目指定
module为esnext或commonjs是不正确的,即使tsc发出的所有文件分别是 ESM 或 CJS。对于打算在 Node.js 中运行的项目,唯一正确的module设置是node16和nodenext。虽然使用esnext和nodenext编译的全 ESM Node.js 项目发出的 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 库的作者在自己运行 tsc 处理自己的 .ts 输入文件时创建的——Node.js 必须将其解释为 ES 模块,因为它的 .js 扩展名以及 /node_modules/pkg/package.json 中存在 "type": "module" 字段。声明文件将在后面的部分中更详细地介绍。)
TypeScript 使用输入文件检测到的模块格式来确保它为每个输出文件输出 Node.js 期望的输出语法。如果 TypeScript 要输出带有 import 和 export 语句的 /example.js,Node.js 在解析该文件时会崩溃。如果 TypeScript 要输出带有 require 调用的 /main.mjs,Node.js 在求值时会崩溃。除了输出,模块格式还用于确定类型检查和模块解析的规则,我们将在以下部分讨论。
从 TypeScript 5.6 开始,其他 --module 模式(如 esnext 和 commonjs)也尊重格式特定的文件扩展名(.mts 和 .cts),作为输出格式的文件级覆盖。例如,即使 --module 设置为 commonjs,名为 main.mts 的文件也会将 ESM 语法输出到 main.mjs。
值得再次提及的是,TypeScript 在 --module node16、--module node18 和 --module nodenext 下的行为完全是由 Node.js 的行为驱动的。由于 TypeScript 的目标是在编译时捕获潜在的运行时错误,它需要对运行时发生的情况有一个非常准确的模型。这套相当复杂的模块种类检测规则对于检查将在 Node.js 中运行的代码是必要的,但如果应用于非 Node.js 宿主,可能过于严格或完全错误。
输入模块语法
重要的是要注意,输入源文件中看到的输入模块语法与输出到 JS 文件的输出模块语法有些解耦。也就是说,一个带有 ESM 导入的文件:
import { sayHello } from "greetings";
sayHello("world");可能完全按原样以 ESM 格式输出,也可能输出为 CommonJS:
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 这样的工具使得可以用 ESM 编写 JavaScript,并将其降级为可以在 Node.js 或浏览器中使用的另一种模块格式。TypeScript 紧随其后,在 1.5 版本中添加了对 ES 模块语法的支持,并温和地劝阻使用原始的受 CommonJS 启发的import fs = require("fs")语法。这种“用 ESM 编写,输出任意格式”策略的好处是,TypeScript 可以使用标准的 JavaScript 语法,使编写体验对新用户熟悉,并且(理论上)使项目将来很容易开始以 ESM 输出为目标。有三个显著的缺点,直到 ESM 和 CJS 模块被允许在 Node.js 中共存和互操作后,这些缺点才完全显现出来:
- 关于 ESM/CJS 互操作在 Node.js 中如何工作的早期假设被证明是错误的,如今,互操作规则在 Node.js 和打包器之间存在差异。因此,TypeScript 中模块的配置空间很大。
- 当输入文件中的语法看起来都像 ESM 时,作者或代码审阅者很容易忘记文件在运行时是什么种类的模块。并且由于 Node.js 的互操作规则,每个文件是什么种类的模块变得非常重要。
- 当输入文件用 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 标准化以来,运行时、打包器和转译器可以自由地给出这些问题的答案,因此不存在标准的互操作规则集。如今,大多数运行时和打包器大致分为以下三类之一:
- 仅 ESM。 某些运行时,如浏览器引擎,仅支持语言实际的一部分:ECMAScript 模块。
- 类打包器。 在任何主要的 JavaScript 引擎能够运行 ES 模块之前,Babel 允许开发者通过将它们转译为 CommonJS 来编写它们。这些由 ESM 转译为 CJS 的文件与手写的 CJS 文件交互的方式暗示了一组宽松的互操作规则,这些规则已成为打包器和转译器的实际标准。
- 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 或传递给 require 的字符串)会按原样输出。例如,输入:
import { add } from "./math.mjs";
add(1, 2);可能输出为:
import { add } from "./math.mjs";
add(1, 2);或:
const math_1 = require("./math.mjs");
math_1.add(1, 2);取决于 module 编译器选项,但模块说明符将是 "./math.mjs" 无论哪种情况。默认情况下,模块说明符必须以适用于代码目标运行时或打包器的方式编写,而 TypeScript 的工作是理解这些输出相对的说明符。查找模块说明符引用的文件的过程称为模块解析。
TypeScript 5.7 引入了
--rewriteRelativeImportExtensions选项,它将具有.ts、.tsx、.mts或.cts扩展名的相对模块说明符转换为输出文件中对应的 JavaScript 扩展名。此选项对于创建可以在开发期间直接在 Node.js 中运行并且仍可编译为 JavaScript 输出以供分发或生产使用的 TypeScript 文件非常有用。本文档是在引入
--rewriteRelativeImportExtensions之前编写的,它呈现的思维模型是围绕对宿主模块系统在其输入文件上的行为进行建模构建的,无论是打包器处理 TypeScript 文件,还是运行时处理.js输出。有了--rewriteRelativeImportExtensions,应用该思维模型的方式是应用两次:一次用于直接处理 TypeScript 输入文件的运行时或打包器,再次用于处理转换后输出的运行时或打包器。本文档大部分内容假设仅输入文件或仅输出文件将被加载,但其呈现的原理可以扩展到两者都被加载的情况。
模块解析
让我们回到第一个示例,回顾一下到目前为止我们所学到的内容:
import sayHello from "greetings";
sayHello("world");到目前为止,我们已经讨论了宿主的模块系统和 TypeScript 的 module 编译器选项可能如何影响此代码。我们知道输入语法看起来像 ESM,但输出格式取决于 module 编译器选项,可能还有文件扩展名和 package.json "type" 字段。我们还知道 sayHello 绑定到什么,甚至导入是否被允许,都可能根据此文件和目标文件的模块种类而变化。但我们还没有讨论如何找到目标文件。
模块解析由宿主定义
虽然 ECMAScript 规范定义了如何解析和解释 import 和 export 语句,但它将模块解析留给了宿主。如果你正在创建一个热门的新 JavaScript 运行时,你可以自由地创建一个像这样的模块解析方案:
import monkey from "🐒"; // 查找 './eats/bananas.js'
import cow from "🐄"; // 查找 './eats/grass.js'
import lion from "🦁"; // 查找 './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时,这是不幸的默认值。它对 Node.js v12 之前的版本是一个相当好的模型,有时也是大多数打包器如何进行模块解析的一个可接受的近似。它支持从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 关于模块的工作的三个组成部分吗?
- 将文件编译成有效的输出模块格式
- 确保这些输出文件中的导入能够成功解析
- 知道要为导入的名称赋予什么类型。
模块解析需要完成后两个目标。但是当我们大部分时间都在处理输入文件时,很容易忘记 (2)——模块解析的一个关键组成部分是验证输出文件中的导入或 require 调用(包含与输入文件相同的模块说明符)实际上会在运行时工作。让我们看一个包含多个文件的新示例:
// @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,模块解析主要是准确建模宿主在输出文件之间的模块解析算法,并应用一点重新映射来查找类型信息。让我们看另一个通过简单模型看起来不直观,但通过健壮模型却完全合理的示例:
// @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 | .* |
最后一行表示,可以使用 allowArbitraryExtensions 编译器选项为非 JS 文件提供类型,以支持模块系统将非 JS 文件作为 JavaScript 对象导入的情况。例如,名为 styles.css 的文件可以由名为 styles.d.css.ts 的声明文件表示。
“但是等等!很多声明文件是手写的,不是由
tsc生成的。你听说过 DefinitelyTyped 吗?”你可能会反对。确实如此——手写声明文件,甚至移动/复制/重命名它们以表示外部构建工具的输出,是一项危险且容易出错的工作。DefinitelyTyped 的贡献者和不使用tsc同时生成 JavaScript 和声明文件的有类型库的作者,应确保每个 JavaScript 文件都有一个具有相同名称和匹配扩展名的同级声明文件。偏离这种结构可能导致最终用户出现误报的 TypeScript 错误。npm 包@arethetypeswrong/cli可以帮助在发布前捕获和解释这些错误。
用于打包器、TypeScript 运行时和 Node.js 加载器的模块解析
到目前为止,我们非常强调输入文件和输出文件之间的区别。回想一下,在相对模块说明符上指定文件扩展名时,TypeScript 通常让你使用输出文件扩展名:
// @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 文件,它最终将消耗并擦除你编写的所有导入以生成一个打包文件。
- 你正在像 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")编译会发生什么:
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?另一方面,如果我们写成:
export * from "./utils.js";这将产生在 Node.js 和打包器中都能工作的输出。
简而言之,"moduleResolution": "bundler" 具有传染性,允许生成只能在打包器中工作的代码。同样,"moduleResolution": "nodenext" 只检查输出是否在 Node.js 中工作,但在大多数情况下,在 Node.js 中工作的模块代码也将在其他运行时和打包器中工作。
当然,此指导仅适用于库通过 tsc 提供输出的情况。如果库在发布前被打包,"moduleResolution": "bundler" 可能是可以接受的。任何更改模块格式或模块说明符以生成库最终构建的构建工具都承担确保产品模块代码安全性和兼容性的责任,而 tsc 无法再为此任务做出贡献,因为它无法知道运行时将存在什么模块代码。
在 Node.js v20.19.0 及更高版本中,允许
requireES 模块,但仅当解析的模块及其顶层导入不使用顶层await时。TypeScript 不尝试强制执行此规则,因为它无法从声明文件中判断对应的 JavaScript 文件是否包含顶层await。 ↩︎