模块解析

状态: 初稿

这一章假定你有关于模块的基础知识.
请阅读模块获得更多信息.

模块解析是编译器用来找出一条导入语句引用什么的过程.
考虑导入语句 import { a } from "moduleA";
为了编译所有用到 a 的地方, 编译器有必要知道 a 具体代表什么, 而它的定义在模块 moduleA 中.

到这, 编译器面对的问题是: “moduleA 是什么样的?”
解释起来颇为容易, moduleA 可能在一个你自己的 .ts/.tsx 文件中定义, 或在一个依赖文件 .d.ts 中定义.

一开始, 编译器尝试定位代表导入模块的文件.
有两种不同的策略, 经典, 和 Node, 会帮助编译器完成工作.
这两种策略指导编译器在何处查找 ModuleA.

假设查找未能成功, 而且模块名是非相对的 (此例 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经典.
设置 --moduleResolution 选项指定一种模块解析策略.
如果没有指定, 缺省策略是 经典, 它针对 --module AMD | System | ES2015, 而 Node 针对其他.

经典

它曾经是 TypeScript 的缺省解析策略.
如今, 该策略主要为向后兼容性服务.

相对导入相对于当前文件解析.
因此, 一条在源文件 /root/src/folder/A.ts 中的导入语句 import { b } from "./moduleB" 会导致下列查找:

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts

对非相对模块导入, 则相反, 编译器从当前目录开始, 向上遍历整个目录树, 尝试找到一个匹配的定义文件.

作为例子:

在源文件 /root/src/folder/A.ts 中对模块 moduleB 非相对导入的语句如 import { b } from "moduleB", 会导致下列查找以定位 "moduleB":

  1. /root/src/folder/moduleB.ts
  2. /root/src/folder/moduleB.d.ts
  3. /root/src/moduleB.ts
  4. /root/src/moduleB.d.ts
  5. /root/moduleB.ts
  6. /root/moduleB.d.ts
  7. /moduleB.ts
  8. /moduleB.d.ts

Node

这种解析策略试图在运行期间模仿 Node.js 的模块解析机制.
完整 Node.js 解析算法在 Node.js 模块文档 列出.

Node.js 如何解析模块

要理解 TypeScript 编译器遵循哪些步骤, 我们先从 Node.js 模块取取经.
传统上, Node.js 中的导入是通过调用 require 函数完成的.
Node.js 采取的行为会因为你传递给 require 一个相对路径或非相对路径而改变.

解析相对路径颇为简单易行.
作为例子, 考虑包含导入语句 var x = require("./moduleB"); 的文件 /root/src/moduleA.js.
Node.js 按下列顺序解析该导入.

  1. 询问文件 /root/src/moduleB.js 是否存在.

  2. 询问文件夹 /root/src/moduleB 是否包含指定了 main 模块的文件 package.json.
    在我们的例子中, 如果 Node.js 发现文件 /root/src/moduleB/package.json 包含 { "main": "lib/mainModule.js" }, 它会把导入定位到 /root/src/moduleB/lib/mainModule.js.

  3. 询问文件夹 /root/src/moduleB 是否包含文件 index.js.
    此文件隐式地被认为是一个文件夹的 “main” 模块.

你可以在 Node.js 文档的 file modulesfolder modules 两节阅读更多内容.

然而, 对非相对模块名的解析不同于相对模块名.
Node.js 会在特殊文件夹 node_modules 查找你的模块.
node_modules 文件夹可位于当前目录, 或当前目录往上任意一级中.
Node.js 向上遍历目录树, 检查每一个 node_modules 文件夹, 直到找到你试图加载的模块.

承接上个例子, 考虑文件 /root/src/moduleA.js, 不同的是, 这次它采用非相对路径导入模块: var x = require("moduleB");.
Node 尝试如下每个位置直到其中之一完成解析.

  1. /root/src/node_modules/moduleB.js
  2. /root/src/node_modules/moduleB/package.json (存在 "main" 属性)
  3. /root/src/node_modules/moduleB/index.js


  4. /root/node_modules/moduleB.js
  5. /root/node_modules/moduleB/package.json (存在 "main" 属性)
  6. /root/node_modules/moduleB/index.js


  7. /node_modules/moduleB.js
  8. /node_modules/moduleB/package.json (存在 "main" 属性)
  9. /node_modules/moduleB/index.js

注意在第 4, 7 步, Node.js 切换到目录树的更高层级.

你可以在 Node.js 文档的 loading modules from node_modules 一节阅读该过程的更多内容.

TypeScript 如何解析模块

TypeScript 模仿 Node.js 运行期解析策略在编译期定位模块源文件.
为达成目的, TypeScript 把自己的源文件文件扩展名 .ts, .tsx, 和 .d.ts 代入 Node.js 解析逻辑.
TypeScript 也在 package.json 设置字段 "types" 以取得 "main" 的用途 - 编译器利用它找到 “主” 定义文件以查阅.

来看例子, 源文件 /root/src/moduleA.ts 一条导入语句如 import { b } from "./moduleB" 会导致尝试如下位置以定位 "./moduleB":

  1. /root/src/moduleB.ts
  2. /root/src/moduleB.tsx
  3. /root/src/moduleB.d.ts
  4. /root/src/moduleB/package.json (存在 "types" 属性)
  5. /root/src/moduleB/index.ts
  6. /root/src/moduleB/index.tsx
  7. /root/src/moduleB/index.d.ts

回顾一下, Node.js 首先查找文件 moduleB.js, 然后是可用的 package.json, 再然后是 index.js.

同样, TypeScript 解析非相对导入会参照 Node.js 解析逻辑, 首先查找一个文件, 再查找可用的文件夹.
因此, /root/src/moduleA.ts 中的 import { b } from "moduleB" 语句导致下列尝试:

  1. /root/src/node_modules/moduleB.ts
  2. /root/src/node_modules/moduleB.tsx
  3. /root/src/node_modules/moduleB.d.ts
  4. /root/src/node_modules/moduleB/package.json (存在 "types" 属性)
  5. /root/src/node_modules/@types/moduleB.d.ts
  6. /root/src/node_modules/moduleB/index.ts
  7. /root/src/node_modules/moduleB/index.tsx
  8. /root/src/node_modules/moduleB/index.d.ts


  9. /root/node_modules/moduleB.ts
  10. /root/node_modules/moduleB.tsx
  11. /root/node_modules/moduleB.d.ts
  12. /root/node_modules/moduleB/package.json (存在 "types" 属性)
  13. /root/node_modules/@types/moduleB.d.ts
  14. /root/node_modules/moduleB/index.ts
  15. /root/node_modules/moduleB/index.tsx
  16. /root/node_modules/moduleB/index.d.ts


  17. /node_modules/moduleB.ts
  18. /node_modules/moduleB.tsx
  19. /node_modules/moduleB.d.ts
  20. /node_modules/moduleB/package.json (存在 "types" 属性)
  21. /node_modules/@types/moduleB.d.ts
  22. /node_modules/moduleB/index.ts
  23. /node_modules/moduleB/index.tsx
  24. /node_modules/moduleB/index.d.ts

别被这里的步骤数吓到了 - TypeScript 仍然只是在第 9, 17 步向上切换了两次目录.
其实没有比 Node.js 所做的更复杂.

附加模块解析标志

一个项目源文件的布局有时与输出不同.
通常, 生成最终输出要经历一系列构建步骤.
它们包括: 把 .ts 文件编译成 .js 文件, 从不同源目录拷贝依赖文件至单一的输出目录.
可以说, 运行期的模块名可能与包含它们定义的源文件名不同.
最终输出模块的路径也可能与编译期对应源文件的路径不同.

TypeScript 有一组附加标志通知编译器为了生成最终输出要对源文件执行的转换过程.

但要注意编译器执行任何具体转换;
它只是利用这些信息指导从模块导入到对应定义文件的解析过程.

baseUrl

使用在运行期把所有模块”部署”到单个文件夹的 AMD 模块加载器的应用经常运用 baseUrl 标志.
这些模块的源文件可以存放在不同目录, 但一个构建脚本会把它们汇集到一起.

设置 baseUrl 通知编译器在哪里寻找模块.
编译器假定所有非相对模块名导入都相对 baseUrl.

baseUrl 的值由下列之一确定:

  1. baseUrl 命令行参数的值 (如果给定路径是相对的, 根据当前目录计算)
  2. ‘tsconfig.json’ 文件中的 baseUrl 属性的值 (如果给定路径是相对的, 根据 ‘tsconfig.json’ 的位置计算)

注: 因为相对模块导入总是相对当前文件解析, 所以不受设置 baseUrl 影响.

你可以在 RequireJSSystemJS 文档找到更多关于 baseUrl 的资料.

路径映射

有时模块不直接位于 baseUrl 目录内.
例如, 一个对模块 "jquery" 的导入可能会在运行期指向 "node_modules/jquery/dist/jquery.slim.min.js".
加载器在运行期根据配置信息把模块名映射至文件, 具体可查看 RequireJs 文档相关章节SystemJS 文档相关章节.

TypeScript 编译器支持通过 tsconfig.json 文件中的 "paths" 属性定义的这类映射.
以下给出一个为 jquery 指定 paths 属性的例子.

1
2
3
4
5
6
7
8
{
"compilerOptions": {
"baseUrl": ".", // This must be specified if "paths" is.
"paths": {
"jquery": ["node_modules/jquery/dist/jquery"] // This mapping is relative to "baseUrl"
}
}
}

特别注意, "paths" 也相对于 "baseUrl" 解析.
如果设置 "baseUrl""." (即 tsconfig.json 所处目录) 以外的值, 定义的映射也必须相应做出调整.
比如, 在上例设置 "baseUrl": "./src", jquery 就应重映射到 "../node_modules/jquery/dist/jquery".

"paths" 的使用还能实现更复杂的映射, 包括多重回退位置.
考虑这样一个项目配置, 一部分模块存放在一个位置, 其他模块存放在另一位置.
一个构建步骤会将它们汇集到一个地方.
项目布局看起来像这样:

1
2
3
4
5
6
7
8
9
projectRoot
├── folder1
│ ├── file1.ts (imports 'folder1/file2' and 'folder2/file3')
│ └── file2.ts
├── generated
│ ├── folder1
│ └── folder2
│ └── file3.ts
└── tsconfig.json

相关的 tsconfig.json 看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"*": [
"*",
"generated/*"
]
}
}
}

它告诉编译器, 针对所有匹配模式 "*" (即: 所有值) 的模块导入, 都去如下两个位置寻找:

  1. "*": 表示模块名不变, 有映射 <moduleName> => <baseUrl>/<moduleName>
  2. "generated/*" 表示模块名附加前缀 “generated”, 有映射 <moduleName> => <baseUrl>/generated/<moduleName>

遵照此逻辑, 编译器按如下步骤尝试解析 file1.ts 的两个导入:

导入 ‘folder1/file2’:

  1. 模式 ‘*’ 被匹配, 通配符捕获整个模块名
  2. 尝试列表中第一种替换: ‘*’ -> folder1/file2
  3. 替换结果是一个非相对模块名 — 将它与 baseUrl 组合 -> projectRoot/folder1/file2.ts.
  4. 文件存在, 解析完成.

导入 ‘folder2/file3’:

  1. 模式 ‘*’ 被匹配, 通配符捕获整个模块名
  2. 尝试列表中第一种替换: “*” -> folder2/file3
  3. 替换结果是非相对模块名 - 将它与 baseUrl 组合 -> projectRoot/folder2/file3.ts.
  4. 文件不存在, 移动到第二种替换
  5. 第二种替换 ‘generated/*’ -> generated/folder2/file3
  6. 替换结果是非相对模块名 - 将它与 baseUrl 组合 -> projectRoot/generated/folder2/file3.ts.
  7. 文件存在, 解析完成.

rootDirs 建立虚拟目录

有时候来自多个目录的项目源文件在编译期合并组成一个单一输出目录.
可以视作一组源目录共同创建了一个”虚拟”目录.

借助 ‘rootDirs’, 你可以告知编译器组成该”虚拟”目录的所有根目录;
是以, 编译器可以在这些”虚拟”目录内解析相对模块导入, 好像它们是一个事实上的整体.

考虑这个项目结构:

1
2
3
4
5
6
7
8
9
src
└── views
└── view1.ts (imports './template1')
└── view2.ts

generated
└── templates
└── views
└── template1.ts (imports './view2')

src/views 中的文件是一些界面控件的用户代码.
generated/templates 中的文件是由模板生成器作为构建的一部分自动生成的界面模板绑定代码.
一个构建步骤会把 /src/views/generated/templates/views 中的文件拷贝到同一个输出目录.
在运行期间, 一个视图就能期待其模板与它同目录, 所以应该采用相对模块名 "./template 导入.

向编译器说明这种关系, 使用 "rootDirs".
"rootDirs" 指定一个根目录名的列表, 这些目录的内容会在运行期合并.
接续我们的例子, tsconfig.json 文件看起来像:

1
2
3
4
5
6
7
8
{
"compilerOptions": {
"rootDirs": [
"src/views",
"generated/templates/views"
]
}
}

每当编译器看到一个 rootDirs 其中一项的子目录中的相对模块导入, 它会试图在 rootDirs 每个入口查找该导入.

rootDirs 的灵活性不局限于指定一个逻辑上是一个整体的物理源文件目录的列表. 用户提供的数组可以包含若干特设, 不存在的目录名. 这使得编译器可以类型安全地获得复杂打包技术以及运行时特性, 例如条件包含和项目特有加载器插件.

考虑一个国际化方案, 构建工具为了自动生成地区特有安装包, 会向路径插入特殊符号, 如 #{locale}, 作为相对模块路径如 ./#{locale}/messages 的一部分. 在这个假想设定中, 该工具遍历受支持的地区, 把抽象路径映射至 ./zh/messages, ./de/messages, 等等.

假定每个模块都导出一个字符串数组. 那么 ./zh/messages 可能包含:

1
2
3
4
export default [
"您好吗",
"很高兴认识你"
];

借助 rootDirs 配置此映射, 即使目录不存在, 编译器也可以安全地解析 ./#{locale}/messages. 例如, 使用以下 tsconfig.json:

1
2
3
4
5
6
7
8
9
{
"compilerOptions": {
"rootDirs": [
"src/zh",
"src/de",
"src/#{locale}"
]
}
}

编译器现在为工具用途把 import messages from './#{locale}/messages' 解析为 import messages from './zh/messages', 允许以地区无关的方式进行开发, 而不损失设计时间支持.

追踪解析过程

如前所述, 在解析模块时编译器可以访问当前文件夹外的文件.
这使诊断模块未能解析或解析到不正确的定义等问题变得困难.
--traceResolution 启用编译器的模块解析追踪, 它能提供模块解析过程中发生了什么的见解.

假设我们有一个使用 typescript 模块的示例应用.
app.ts 包含导入语句如 import * as ts from "typescript".

1
2
3
4
5
6
7
│   tsconfig.json
├───node_modules
│ └───typescript
│ └───lib
│ typescript.d.ts
└───src
app.ts

--traceResolution 运行编译器

1
tsc --traceResolution

会产生下列输出:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
======== 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'. ========

可供监视的信息

  • 导入名和位置

    ======== Resolving module ‘typescript’ from ‘src/app.ts’. ========

  • 编译器采用的解析策略

    Module resolution kind is not specified, using ‘NodeJs’.

  • 从 npm 包载入类型

    ‘package.json’ has ‘types’ field ‘./lib/typescript.d.ts’ that references ‘node_modules/typescript/lib/typescript.d.ts’.

  • 最终结果

    ======== Module name ‘typescript’ was successfully resolved to ‘node_modules/typescript/lib/typescript.d.ts’. ========

使用 --noResolve

通常, 在编译正式开始前, 编译器尝试解析所有导入模块.
每当它成功解析一个 import 到文件, 就把该文件添加到编译器随后将处理的文件列表.

--noResolve 编译器选项指示编译器不要添加任何不是从命令行传入的文件参加编译.
编译器依然要解析模块文件, 但如果一个文件没在命令行指定, 就不会被包括在内.

举例:

app.ts

1
2
import * as A from "moduleA" // OK, 'moduleA' passed on the command-line
import * as B from "moduleB" // Error TS2307: Cannot find module 'moduleB'.
1
tsc app.ts moduleA.ts --noResolve

--noResolve 编译 app.ts 的结果是:

  • moduleA 由命令行传入, 正确解析.
  • moduleB 未由命令行传入, 解析错误.

常见问题

为什么编译器仍会处理排除列表中的模块?

tsconfig.json 把普通文件夹转换成”项目”.
不指定任何 "exclude""files" 属性, 所有 tsconfig.json 所属目录中的文件和子目录都参与编译.
如果你想用排除一些文件, 设置 "exclude" 属性, 如果你想手动指定所有文件, 而不是让编译器搜索, 设置 "files" 属性.

这即是 tsconfig.json 自动包含.
它不影响以上讨论的模块解析.
如果编译器确定一个文件是模块导入的目标, 无论它有没有事先被排除, 都会加入编译.

因此, 要从编译排除一个文件, 你不仅要排除它, 还有排除采用 import 关键字导入或 /// <reference path="..." /> 指令引用它的所有文件.

如果这篇文章对您有用,可以考虑打赏:)