TypeScript Office - 模块解析
# 模块解析
模块解析是编译器用来分析一个导入什么的过程。考虑一个导入语句,如 import { a } from "moduleA"
;为了检查对 a 的任何使用,编译器需要知道它到底代表什么,并需要检查它的定义 moduleA。
在这一点上,编译器会问 moduleA 的形状是什么?虽然这听起来很简单,但 moduleA 可能被定义在你自己的一个 .ts
/ .tsx
文件中,或者在你的代码所依赖的一个 .d.ts
中。
首先,编译器将试图找到一个代表导入模块的文件。为了做到这一点,编译器遵循两种不同的策略之一。 Classic or Node。这些策略告诉编译器去哪里寻找模块 A。
如果这没有用,并且如果模块名称是非相对的(在 moduleA
的情况下,它是相对的),那么编译器将尝试定位一个环境模块的声明。我们接下来会讨论非相对导入。
最后,如果编译器不能解决该模块,它将记录一个错误。在这种情况下,错误会是这样的:error TS2307: Cannot find module 'moduleA'
。
# 相对与非相对的模块导入
模块导入是根据模块引用是相对的还是非相对的来解析的。
相对导入是以 /
、/
或 ./
开头的导入。一些例子包括:
import Entry from "./components/Entry";
import { DefaultHeaders } from "../constants/http";
import "/mod";
任何其他的导入都被认为是 不相关的。一些例子包括:
import * as $ from "jquery";
import { Component } from "@angular/core";
相对导入是相对于导入文件进行解析的,不能解析为环境模块的声明。你应该为你自己的模块使用相对导入,以保证在运行时保持其相对位置。
非相对导入可以相对于 baseUrl 来解析,也可以通过路径映射来解析,我们将在下面介绍。它们也可以解析为环境模块声明。当导入你的任何外部依赖时,使用非相对路径。
# 模块解析策略
有两种可能的模块解析策略。Node 和 Classic。你可以使用 moduleResolution 选项来指定模块解析策略。如果没有指定,对于 --module commonjs
,默认为 Node,否则为 Classic(包括 module 设置为 amd、system、umd、es2015、esnext 等时)。
注意: node 模块解析是 TypeScript 社区中最常用的,并被推荐用于大多数项目。如果你在 TypeScript 的导入和导出中遇到解析问题,可以尝试设置
moduleResolution:"node"
,看看是 否能解决这个问题。
# Classic
这曾经是 TypeScript 的默认解析策略。现在,这个策略主要是为了向后兼容而存在。
一个相对导入将被解析为相对于导入文件。所以在源文件 /root/src/folder/A.ts
中从 ./moduleB
导入 { b }
会导致以下查找。
所以在源文件 /root/src/folder/A.ts
中的 import { b } from "./moduleB"
查找路径如下:
/root/src/folder/moduleB.ts
/root/src/folder/moduleB.d.ts
然而,对于非相对的模块导入,编译器从包含导入文件的目录开始沿着目录树向上走,试图找到一个匹配的定义文件。
例如:
在源文件 /root/src/folder/A.ts
中,对于 import { b } from "moduleB"
,会导致尝试在以下位置找到 "moduleB":
- /root/src/folder/moduleB.ts
- /root/src/folder/moduleB.d.ts
- /root/src/moduleB.ts
- /root/src/moduleB.d.ts
- /root/moduleB.ts
- /root/moduleB.d.ts
- /moduleB.ts
- /moduleB.d.ts
# Node
这种解析策略试图在运行时模仿 Node.js 的模块解析机制。完整的 Node.js 解析算法在 Node.js 模块文档中概述。
# Node 如何解析模块
为了理解 TS 编译器将遵循哪些步骤,有必要对 Node.js 模块进行一些说明。传统上,Node.js 的导入是通过调用一个名为 require 的函数来完成的。Node.js 采取的行为会有所不同,这取决于 require 是给出相对路径还是非相对路径。
相对路径是相当直接的。举个例子,让我们考虑一个位于 /root/src/moduleA.js
的文件,其中包含 import var x = require("./moduleB");
的模块导入,Node.js 按照以下顺序解析:
- 询问名为
/root/src/moduleB.js
的是否存在 - 询问文件夹
/root/src/moduleB
是否包含一个名为 package.json 的文件,其中指定了一个main
模块。在我们的例子中,如果 Node.js 发现文件/root/src/moduleB/package.json
包含{ "main": "lib/mainModule.js"}
,那么 Node.js 将引用/root/src/moduleB/lib/mainModule.js
- 询问文件夹
/root/src/moduleB
是否包含一个名为 index.js 的文件。该文件被隐含地视为该文件夹的 主模块
你可以在Node.js文档中阅读更多关于 file 模块 (opens new window) 和 folder 模块 (opens new window) 的内容。
然而,非相关模块名称的解析是以不同方式进行的。Node 将在名为 node_modules 的特殊文件夹中寻 找你的模块。一个 node_modules 文件夹可以和当前文件在同一级别,也可以在目录链中更高的位置。Node将沿着目录链向上走,寻找每个 node_modules,直到找到你试图加载的模块。
继续我们上面的例子,考虑一下如果 /root/src/moduleA.js
使用了一个非相对路径,并且有导入 var x = require("moduleB");
。然后,Node 会尝试将 moduleB 解析到每一个位置,直到有一个成功:
- /root/src/node_modules/moduleB.js
- /root/src/node_modules/moduleB/package.json (如果
main
属性存在) - /root/src/node_modules/moduleB/index.js
- /root/node_modules/moduleB.js
- /root/node_modules/moduleB/package.json (如果
main
属性存在) - /root/node_modules/moduleB/index.js
- /node_modules/moduleB.js
- /node_modules/moduleB/package.json (如果
main
属性存在) - /node_modules/moduleB/index.js
注意,Node.js 在步骤(4)和(7)中跳出了本目录。
你可以在 Node.js 文档中阅读更多关于 从 node_modules 加载模块的过程 (opens new window)。
# TypeScript 如何解决模块
TypeScript 将模仿 Node.js 的运行时解析策略,以便在编译时找到模块的定义文件。为了实现这一点,TypeScript 在 Node 的解析逻辑上叠加了 TypeScript 源文件扩展名(.ts
、.tsx
和 .d.ts
)。TypeScript 还将使用 package.json 中一个名为 types 的字段来达到 main
的目的——编译器将使用 它来找到 main
定义文件来查阅。
例如,在 /root/src/moduleA.ts
中的 import { b } from "./moduleB"
,这样的导入语句会导致尝试在以下位置定位 "./moduleB"
。
- /root/src/moduleB.ts
- /root/src/moduleB.tsx
- /root/src/moduleB.d.ts
- /root/src/moduleB/package.json (如果 types 属性存在)
- /root/src/moduleB/index.ts
- /root/src/moduleB/index.tsx
- /root/src/moduleB/index.d.ts
回顾一下,Node.js 寻找一个名为 moduleB.js 的文件,然后寻找一个适用的 package.json,然后寻找一个 index.js。
同样地,一个非相对的导入将遵循 Node.js 的解析逻辑,首先查找一个文件,然后查找一个适用的文件夹。因此,在源文件 /root/src/moduleA.ts
中的 import { b } from "moduleB"
导致以下查找:
- /root/src/node_modules/moduleB.ts
- /root/src/node_modules/moduleB.tsx
- /root/src/node_modules/moduleB.d.ts
- /root/src/node_modules/moduleB/package.json (如果 types 属性存在)
- /root/src/node_modules/@types/moduleB.d.ts
- /root/src/node_modules/moduleB/index.ts
- /root/src/node_modules/moduleB/index.tsx
- /root/src/node_modules/moduleB/index.d.ts
- /root/node_modules/moduleB.ts
- /root/node_modules/moduleB.tsx
- /root/node_modules/moduleB.d.ts
- /root/node_modules/moduleB/package.json (如果 types 属性存在)
- /root/node_modules/@types/moduleB.d.ts
- /root/node_modules/moduleB/index.ts
- /root/node_modules/moduleB/index.tsx
- /root/node_modules/moduleB/index.d.ts
- /node_modules/moduleB.ts
- /node_modules/moduleB.tsx
- /node_modules/moduleB.d.ts
- /node_modules/moduleB/package.json (如果 types 属性存在)
- /node_modules/@types/moduleB.d.ts
- /node_modules/moduleB/index.ts
- /node_modules/moduleB/index.tsx
- /node_modules/moduleB/index.d.ts
不要被这里的步骤数量所吓倒,TypeScript 仍然只是在步骤(9)和(17)上跳了两次目录。这其实并不比 Node.js 本身所做的更复杂。
# 额外的模块解析标志
一个项目的源代码内容有时与输出的内容不一致。通常情况下,一组构建步骤会产生最终的输出。这些步骤包括将 .ts
文件编译成 .js
,并将不同的源文件位置的依赖关系复制到一个单一的输出位置。最终的结果是,模块在运行时的名称可能与包含其定义的源文件不同。或者最终输出中的模块路径可能与编译时对应的源文件路径不一致。
TypeScript 编译器有一组额外的标志,以告知编译器预计将发生在源文件上的转换,以生成最终的输出。
值得注意的是,编译器不会执行任何这些转换;它只是使用这些信息来指导解析模块,导入到其定义文件的过程。
# Base URL
在使用 AMD 模块加载器的应用程序中,使用 baseUrl 是一种常见的做法,模块在运行时被「部署」到一个文件夹。这些模块的来源可以在不同的目录中,但构建脚本会把它们放在一起。
设置 baseUrl 会通知编译器在哪里找到模块。所有非相对名称的模块导入都被认为是相对于 baseUr 的。
baseUrl 的值由以下两种情况决定:
- baseUrl 命令行参数的值(如果给定的路径是相对的,它是基于当前目录计算的)
- tsconfig.json 中的 baseUrl 属性值(如果给定的路径是相对的,则根据 tsconfig.json 的位置计算)
请注意,相对模块的导入不受设置 baseUrl 的影响,因为它们总是相对于其导入文件进行解析。
你可以在 RequireJS (opens new window) 和 SystemJS (opens new window) 文档中找到更多关于 baseUrl 的文档。
# 路径映射
有时模块并不直接位于 baseUrl 下。例如,对模块 jquery 的导入会在运行时被翻译成 node_modules/jquery/dist/jquery.slim.min.js
。装载器使用映射配置在运行时将模块名称映射到文件,见 RequireJs 文档和 SystemJS 文档。
TypeScript 编译器支持使用 tsconfig.json 文件中的 paths 属性来声明这种映射关系。下面是一个例子,说明如何为 jquery 指定 paths 属性。
{
"compilerOptions": {
"baseUrl": ".", // 如果设置 "paths",这个必须指定。
"paths": {
"jquery": ["node_modules/jquery/dist/jquery"] // 这种映射是相对于 "baseUrl"而
指定的。
}
}
}
2
3
4
5
6
7
8
9
请注意,paths 是相对于 baseUrl 解析的。当设置 baseUrl 为 .
以外的其他值时,即 tsconfig.json 的目录,映射必须相应改变。比如,你把 baseUrl
设置为 ./src
,那么 jquery 应该被映射到 ../node_modules/jquery/dist/jquery
。
使用 paths 还可以实现更复杂的映射,包括多个回退位置。考虑一个项目的配置,其中只有一些模块在一个地方可用,而其他的在另一个地方。一个构建步骤会把它们放在一个地方。项目布局可能看起来像:
projectRoot
├── folder1
│ ├── file1.ts (imports 'folder1/file2' and 'folder2/file3')
│ └── file2.ts
├── generated
│ ├── folder1
│ └── folder2
│ └── file3.ts
└── tsconfig.json
2
3
4
5
6
7
8
9
相应的 tsconfig.json 将看起来像:
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": ["*", "generated/*"]
}
}
}
2
3
4
5
6
7
8
这告诉编译器对于任何符合 *
模式的模块导入(即所有值),要在两个地方寻找:
"*"
: 意思是相同的名字不变,所以映射<moduleName> => <baseUrl> / <moduleName>
"generated/*"
:意思是模块名称有一个附加的前缀generated
,所以<moduleName> => <baseUrl> / generated / <moduleName>
按照这个逻辑,编译器将试图将这两个导入解析为这样:
import 'folder1/file2'
- 模式 '*' 被匹配,通配符捕获了整个模块的名称
- 尝试列表中的第一个替换:
'*' -> folder1/file2
- 替换的结果是非相对名称,与 baseUrl 结合 ->
projectRoot/folder1/file2.ts
- 文件存在。完成了
import 'folder2/file3'
- 模式 '*' 被匹配,通配符捕获了整个模块的名称
- 尝试列表中的第一个替换。
'*' -> folder2/file3
- 替换的结果是非相对名称 - 与 baseUrl 结合 ->
projectRoot/folder2/file3.ts
- 文件不存在,移到第二个替换项
- 第二个替换
'generated/*'
->generated/folder2/file3
- 替换的结果是非相对名称,与 baseUrl 结合 ->
projectRoot/generated/folder2/file3.ts
- 文件存在。完成了
# 带有 rootDirs 的虚拟目录
有时,在编译时来自多个目录的项目源都会被合并,以生成一个单一的输出目录。这可以被看作是一组源目录创建了一个「虚拟」目录。
使用 rootDirs,你可以告知编译器构成这个「虚拟」目录的根;因此,编译器可以在这些「虚拟」目录中 解决相对模块的导入,就像它们被合并在一个目录中一样。
例如,考虑这个项目结构:
src
└── views
└── view1.ts (imports './template1')
└── view2.ts
generated
└── templates
└── views
└── template1.ts (imports './view2')
2
3
4
5
6
7
8
src/views
中的文件是一些UI控件的用户代码。enerated/templates
中的文件是由模板生成器作为构建的一部分,自动生成的 UI 模板绑定代码。构建步骤会将 /src/views
和 /generated/templates/views
中的文件复制到输出的同一个目录中。在运行时,一个视图可以期望它的模板存在于它的旁边,因此应该使用 ./template
这样的相对名称来导入它。
为了向编译器指定这种关系,可以使用 rootDirs。rootDirs 指定了一个根的列表,这些根的内容在运行时被期望合并。所以按照我们的例子,tsconfig.json 文件应该看起来像:
{
"compilerOptions": {
"rootDirs": ["src/views", "generated/templates/views"]
}
}
2
3
4
5
每当编译器在其中一个 rootDirs 的子文件夹中看到一个相对的模块导入,它就会尝试在 rootDirs 的每个条目中寻找这个导入。
rootDirs 的灵活性并不局限于,指定一个在逻辑上合并的物理源代码目录的列表。提供的数组可以包括任何数量的特别的、任意的目录名称,不管它们是否存在。这允许编译器以类型安全的方式捕获复杂的捆绑和运行时特征,如条件性包含和项目特定的加载器插件。
考虑一个国际化的场景,构建工具通过插值一个特殊的路径标记,例如 #{locale}
,作为相对模块路径的一部分,如 ./#{locale}/messages
,自动生成特定地域的捆绑。在这个假设的设置中,工具列举了支持的语言,将抽象的路径映射为 ./zh/messages
,./de/messages
,等等。
假设这些模块中的每一个都导出一个字符串数组。例如,./zh/messages
可能包含:
export default ["您好吗", "很高兴认识你"];
通过利用 rootDirs,我们可以告知编译器这种映射,从而允许它安全地解析 ./# {locale}/messages
,即使该目录永远不存在。例如,在下面的 tsconfig.json 中:
{
"compilerOptions": {
"rootDirs": ["src/zh", "src/de", "src/#{locale}"]
}
}
2
3
4
5
编译器现在会将 import messages from './#{locale}/messages'
解析为 import messages from './zh/messages'
,以便于在不影响设计时间支持的情况下,以与地区无关的方式开发。
# 追踪模块的解析
如前所述,编译器在解析一个模块时可以访问当前文件夹以外的文件。这在诊断为什么一个模块没有被解析,或者被解析为一个不正确的定义时可能会很困难。使用 traceResolution 启用编译器模块解析跟踪,可以深入了解模块解析过程中发生了什么。
假设我们有一个使用 typescript 模块的示例应用程序。app.ts 有一个类似 import * as ts from "typescript"
的导入。
│ tsconfig.json
├───node_modules
│ └───typescript
│ └───lib
│ typescript.d.ts
└───src
app.ts
2
3
4
5
6
7
用 traceResolution 调用编译器
tsc --traceResolution
输出结果如下:
======== Resolving module 'typescript' from 'src/app.ts'. ========
Module resolution kind is not specified, using 'NodeJs'.
Loading module 'typescript' from 'node_modules' folder.
File 'src/node_modules/typescript.ts' does not exist.
File 'src/node_modules/typescript.tsx' does not exist.
File 'src/node_modules/typescript.d.ts' does not exist.
File 'src/node_modules/typescript/package.json' does not exist.
File 'node_modules/typescript.ts' does not exist.
File 'node_modules/typescript.tsx' does not exist.
File 'node_modules/typescript.d.ts' does not exist.
Found 'package.json' at 'node_modules/typescript/package.json'.
'package.json' has 'types' field './lib/typescript.d.ts' that references
'node_modules/typescript/lib/typescript.d.ts'.
File 'node_modules/typescript/lib/typescript.d.ts' exist - use it as a module
resolution result.
======== Module name 'typescript' was successfully resolved to
'node_modules/typescript/lib/typescript.d.ts'. ========
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
需要注意的事项:
- 导入的名称和位置:从
src/app.ts
中解析模块typescript
- 编译器所遵循的策略是:未指定模块解析种类,使用
NodeJs
- 从 npm 包中加载类型:package.json 有 typescript 字段
./lib/typescript.d.ts
,引用node_modules/typescript/lib/typescript.d.ts
- 最终结果:模块名称 typescript 已成功解析为
node_modules/typescript/lib/typescript.d.ts
# 应用 noResolve
通常情况下,编译器在开始编译过程之前会尝试解析所有模块的导入。每当它成功地解析了一个文件的导入,该文件就被添加到编译器以后要处理的文件集合中。
noResolve 编译器选项指示编译器不要「添加」任何未在命令行中传递的文件到编译中。它仍然会尝试将模块解析为文件,但如果没有指定文件,它将不会被包括在内。
举个例子:
app.ts
import * as A from "moduleA"; // 正确,'moduleA'在命令行上通过了
import * as B from "moduleB"; // 错误 TS2307: 无法找到模块'moduleB'
2
tsc app.ts moduleA.ts --noResolve
使用 noResolve 编译 app.t 将导致:
- 正确地找到模块 A,因为它是在命令行上传递的
- 没有找到模块 B,因为它没有被传递,所以出现错误
# 常见问题
为什么排除列表中的模块仍然会被编译器选中?
tsconfig.json 将一个文件夹变成一个「项目」。如果不指定任何 exclude
或 files
条目,包含 tsconfig.json 的文件夹及其所有子目录中的所有文件都会包括在你的编译中。如果你想排除某些文件,使用 exclude
,如果你想指定所有的文件,而不是让编译器去查找它们,使用 files
。
那是 tsconfig.json 的自动包含。这并没有嵌入上面讨论的模块解析。如果编译器将一个文件识别为模块导入的目标,它将被包含在编译中,不管它是否在前面的步骤中被排除。
所以要从编译中排除一个文件,你需要排除它和所有有 import
或 /// <reference path="..." />
指令的文件。