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) 是内置于语言的模块系统,在现代浏览器和 Node.js v12 及更高版本中受支持。它使用专用的
import
和export
语法。js
// a.jsexport default "Hello from a.js";js
// b.jsimport a from "./a.js";console.log(a); // 'Hello from a.js'CommonJS (CJS) 是最初在 Node.js 中发布的模块系统,在 ESM 成为语言规范的一部分之前。它仍然在 Node.js 中与 ESM 一起受支持。它使用名为
exports
和require
的普通 JavaScript 对象和函数。js
// a.jsexports.message = "Hello from a.js";js
// b.jsconst a = require("./a");console.log(a.message); // 'Hello from a.js'
因此,当 TypeScript 检测到文件是 CommonJS 或 ECMAScript 模块时,它首先假设该文件将拥有自己的作用域。除此之外,编译器的任务会变得更加复杂。
TypeScript 关于模块的任务
TypeScript 编译器的主要目标是通过在编译时捕获它们来防止某些类型的运行时错误。无论是否涉及模块,编译器都需要了解代码的预期运行时环境——例如,哪些全局变量可用。当涉及模块时,编译器需要回答几个额外的问题才能完成其工作。让我们使用几行输入代码作为示例来思考分析它所需的所有信息。
ts
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 输入或输出并生成一个捆绑包时,捆绑器就是宿主,因为它查看了原始的导入/导出集合,查找了它们引用的文件,并生成了一个新的文件或一组文件,其中原始的导入和导出被擦除或转换为无法识别的形式。(该捆绑包本身可能包含模块,运行它的运行时环境将是它的宿主,但 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 模块并存,并具有特定的互操作性和检测规则。nodenext
: 目前与node16
相同,但将成为一个移动目标,反映最新的 Node.js 版本,因为 Node.js 的模块系统不断发展。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
。虽然使用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
或 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
时创建的——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 在--module node16
和--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 输出。但是,存在三个重大缺点,只有在 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 规范的一部分,因此自 ESM 在 2015 年标准化以来,运行时、打包器和转译器一直可以自由地为这些问题制定自己的答案,因此不存在标准的互操作性规则集。如今,大多数运行时和打包器大体上分为三类
- 仅 ESM。 一些运行时,如浏览器引擎,只支持语言中实际存在的部分:ECMAScript 模块。
- 打包器式。 在任何主要的 JavaScript 引擎能够运行 ES 模块之前,Babel 允许开发人员通过将它们转译为 CommonJS 来编写它们。这些 ESM 转译为 CJS 的文件与手写 CJS 文件的交互方式暗示了一组宽松的互操作性规则,这些规则已成为打包器和转译器的实际标准。
- Node.js。 在 Node.js 中,CommonJS 模块无法同步加载 ES 模块(使用
require
);它们只能使用动态import()
调用异步加载它们。ES 模块可以默认导入 CJS 模块,它始终绑定到exports
。(这意味着,在 Node.js 和某些打包器之间,对带有__esModule
的 Babel 式 CJS 输出的默认导入行为不同。)
TypeScript 需要知道要假设哪一套规则集,以便在(特别是 default
)导入上提供正确的类型,并在运行时会崩溃的导入上报错。当 module
编译器选项设置为 node16
或 nodenext
时,将强制执行 Node.js 的规则。所有其他 module
设置,结合 esModuleInterop
选项,在 TypeScript 中会导致打包器式的互操作性。(虽然使用 --module esnext
可以阻止你编写 CommonJS 模块,但它不会阻止你导入它们作为依赖项。目前没有 TypeScript 设置可以防止 ES 模块导入 CommonJS 模块,这对于直接到浏览器的代码来说是合适的。)
模块说明符不会被转换
虽然 module
编译器选项可以将输入文件中的导入和导出转换为输出文件中的不同模块格式,但模块说明符(您从中import
的字符串,或传递给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 的工作是理解这些输出相关的说明符。查找模块说明符引用的文件的过程称为模块解析。
模块解析
让我们回到我们的第一个例子,回顾一下我们到目前为止学到的内容
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
设置时默认设置。Node.js v12 及更高版本支持 ESM 和 CJS,它们各自使用自己的模块解析算法。在 Node.js 中,导入语句和动态import()
调用中的模块说明符不允许省略文件扩展名或/index.js
后缀,而require
调用中的模块说明符则允许。此模块解析模式理解并强制执行此限制(如果需要),这由--module node16
制定的 模块格式检测规则 决定。(对于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` 调用,这些输出文件包含 与输入文件相同的模块说明符,在运行时实际上是否有效。让我们看一个包含多个文件的新示例
ts
// @Filename: math.tsexport function add(a: number, b: number) {return a + b;}// @Filename: main.tsimport { add } from "./math";add(1, 2);
当我们看到从 `"./math"` 导入时,我们可能会想,“这就是一个 TypeScript 文件引用另一个文件的方式。编译器会遵循这条(无扩展名)路径来为 `add` 分配一个类型。”
这并不完全错误,但现实更深。`"./math"` 的解析(以及随后 `add` 的类型)需要反映运行时对输出文件所发生情况的现实。一个更稳健的思考方式应该是这样的
这个模型清楚地表明,对于 TypeScript 来说,模块解析主要是一个准确地模拟主机在输出文件之间模块解析算法的问题,并应用一些重映射来查找类型信息。让我们看另一个例子,从简单模型的角度来看似乎不直观,但用稳健模型解释就非常合理了
ts
// @moduleResolution: node16// @rootDir: src// @outDir: dist// @Filename: src/math.mtsexport function add(a: number, b: number) {return a + b;}// @Filename: src/main.mtsimport { 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 通常会让你使用输出文件扩展名
ts
// @Filename: src/math.tsexport function add(a: number, b: number) {return a + b;}// @Filename: src/main.tsimport { 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 文件,并且最终会消耗并删除你编写的用于生成包的所有导入。
- 你正在 Deno 或 Bun 等 TypeScript 运行时中直接运行此代码。
- 你正在使用
ts-node
、tsx
或其他用于 Node 的转译加载器。
在这些情况下,你可以打开 noEmit
(或 emitDeclarationOnly
)和 allowImportingTsExtensions
来禁用生成不安全的 JavaScript 文件并消除 .ts
扩展名导入的错误。
无论是否使用 allowImportingTsExtensions
,为模块解析宿主选择最合适的 moduleResolution
设置仍然很重要。对于打包器和 Bun 运行时,最佳选择是 bundler
。这些模块解析器受 Node.js 启发,但没有采用 Node.js 应用于导入的 禁用扩展搜索 的严格 ESM 解析算法。bundler
模块解析设置反映了这一点,它支持 package.json
中的 "exports"
,就像 node16
和 nodenext
一样,同时始终允许无扩展名导入。有关更多指导,请参阅 选择编译器选项。
库的模块解析
在编译应用程序时,您需要根据模块解析 宿主 来为 TypeScript 项目选择 moduleResolution
选项。在编译库时,您不知道输出代码将在哪里运行,但您希望它尽可能多地运行在各种地方。使用 "module": "nodenext"
(以及隐含的 "moduleResolution": "nodenext"
)是最大限度地提高输出 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
无法再参与此任务,因为它无法知道运行时将存在哪些模块代码。