写在前面
模块化机制让我们能够把代码拆分成多个模块(文件),而编译时需要知道依赖模块的确切类型,那么首先要找到它(建立模块名到模块文件路径的映射)
实际上,在 TypeScript 里,一个模块名可能对应一个.ts/.tsx
或.d.ts
文件(开启--allowJs
的话,还可能对应.js/.jsx
文件)
基本思路是:
- 先尝试寻找模块对应的文件(
.ts/.tsx
) - 如果没有找到,并且不是相对模块引入(
non-relative
),就尝试寻找外部模块声明(ambient module declaration),即d.ts
- 如果还没找到,报错
Cannot find module 'ModuleA'.
一.相对与非相对模块引入
相对模块引入(relative import)以/
、./
或../
开头,例如:
import Entry from "./components/Entry";
import { DefaultHeaders } from "../constants/http";
import "/mod";
注意,相对模块引入并不等价于相对路径引入(例如/mod
)
其它形式的都是非相对模块引入(non-relative import),例如:
import * as $ from "jquery";
import { Component } from "@angular/core";
二者区别在于:
相对模块引入:相对于要引入的文件去寻找模块,并且不会被解析为外部模块声明。用来引入(能在运行时保持相对位置的)自定义模块
二.模块解析策略
具体的,有 2 种模块解析策略:
这 2 种策略可以通过--moduleResolution
编译选项来指定,默认根据目标模块形式来定(module === "AMD" or "System" or "ES6" ? "Classic" : "Node"
)
Classic
在 Classic 策略下,相对模块引入会相对于要引入的文件来解析,例如:
// 源码文件 /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"
会尝试查找以下文件:
/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
NodeJS 模块解析
NodeJS 中通过require
来引入模块,模块解析的具体行为取决于参数是相对路径还是非相对路径
相对路径的处理策略相当简单,对于:
// 源码文件 /root/src/moduleA.js
var x = require("./moduleB");
匹配顺序如下:
尝试匹配
/root/src/moduleB.js
再尝试匹配
/root/src/moduleB/package.json
,接着寻找主模块(例如指定了{ "main": "lib/mainModule.js" }
的话,就引入/root/src/moduleB/lib/mainModule.js
)否则尝试匹配
/root/src/moduleB/index.js
,因为index.js
会被隐式地当作该目录下的主模块
P.S.具体参考 NodeJS 文档:File Modules和Folders as Modules
而非相对模块引入会从node_modules
里找(node_modules
可能位于当前文件的平级目录,也可能在祖先目录),NodeJS 会向上查找每个node_modules
,寻找要引入的模块,例如:
// 源码文件 /root/src/moduleA.js
var x = require("moduleB");
NodeJS 会依次尝试匹配:
/root/src/node_modules/moduleB.js
/root/src/node_modules/moduleB/package.json
/root/src/node_modules/moduleB/index.js
/root/node_modules/moduleB.js
/root/node_modules/moduleB/package.json
/root/node_modules/moduleB/index.js
/node_modules/moduleB.js
/node_modules/moduleB/package.json
/node_modules/moduleB/index.js
P.S.对于package.json
,实际上是加载其main
字段指向的模块
P.S.关于 NodeJS 如何从node_modules
加载模块的更多信息,见Loading from node_modules Folders
TypeScript 仿 NodeJS 策略
(模块解析策略为"Node"
时)TypeScript 也会模拟NodeJS 运行时的模块解析机制,以便在编译时找到模块的定义文件
具体的,会把 TypeScript 源文件后缀名加到 NodeJS 的模块解析逻辑上,还会通过package.json
中的types
字段来查找声明文件(相当于模拟 NodeJS 的main
字段),例如:
// 源码文件 /root/src/moduleA.ts
import { b } from "./moduleB"
会尝试匹配:
/root/src/moduleB.ts
/root/src/moduleB.tsx
/root/src/moduleB.d.ts
/root/src/moduleB/package.json
/root/src/moduleB/index.ts
/root/src/moduleB/index.tsx
/root/src/moduleB/index.d.ts
P.S.对于package.json
,TypeScript 加载其types
字段指向的模块
这个过程与 NodeJS 非常相似(先moduleB.js
,再package.json
,最后index.js
),只是换上了 TypeScript 的源文件后缀名
类似地,非相对模块引入也同样遵循 NodeJS 的解析逻辑,先找文件,再找适用的文件夹:
// 源码文件 /root/src/moduleA.ts
import { b } from "moduleB
模块查找顺序如下:
/root/src/node_modules/moduleB.ts|tsx|d.ts
/root/src/node_modules/moduleB/package.json
/root/src/node_modules/@types/moduleB.d.ts
/root/src/node_modules/moduleB/index.ts|tsx|d.ts
/root/node_modules/moduleB.ts|tsx|d.ts
/root/node_modules/moduleB/package.json
/root/node_modules/@types/moduleB.d.ts
/root/node_modules/moduleB/index.ts|tsx|d.ts
/node_modules/moduleB.ts|tsx|d.ts
/node_modules/moduleB/package.json
/node_modules/@types/moduleB.d.ts
/node_modules/moduleB/index.ts|tsx|d.ts
与 NodeJS 查找逻辑几乎一致,只是会额外地从node_modules/@types
里寻找d.ts
声明文件
三.附加模块解析标记
构建时会把.ts
编译成.js
,并从不同的源位置把依赖拷贝到同一个输出位置。因此,在运行时模块可能具有不同于源文件的命名,或者编译时最后输出的模块路径与对应的源文件不匹配
针对这些问题,TypeScript 提供了一系列标记用来告知编译器期望发生在源路径上的转换,以生成最终输出
P.S.注意,编译器并不会进行任何转换,只用这些信息来指导解析模块引入到其定义文件的过程
Base URL
baseUrl
在遵循AMD
模块的应用中很常见,模块的源文件可以位于不同的目录,由构建脚本把它们放到一起。在运行时,这些模块会被“部署”到单个目录下
TypeScript 里通过设置baseUrl
来告知编译器该去哪里找模块,所有非相对模块引入都是相对于baseUrl
的,有两种指定方式:
命令行参数
--baseUrl
(指定相对路径的话,根据当前目录计算)tsconfig.json
中baseUrl
字段(相对路径的话,根据tsconfig.json
所在目录计算)
注意,相对模块引入不受 baseUrl 影响,因为总是相对于引入它们的文件去解析
路径映射
某些模块并不在baseUrl
下,比如jquery
模块在运行时可能来自node_modules/jquery/dist/jquery.slim.min.js
,此时,模块加载器通过路径映射将模块名对应到运行时的文件
TypeScript 同样支持类似的映射配置(tsconfig.json
的paths
字段),例如:
{
"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
变了,paths
也要跟着改
实际上,还支持更复杂的映射规则,比如多个备选位置,具体见Path mapping
rootDirs 指定虚拟目录
在编译时,有时会把来自多个目录的项目源码整合起来生成到单个输出目录中,相当于用一组源目录创建一个“虚拟”目录
rootDirs
能够告知编译器组成“虚拟”目录的那些“根”路径,让编译器能够解析那些指向“虚拟”目录的相对模块引入,就像它们已经被整合到同一目录了一样,例如:
src # 源码
└── views
└── view1.ts (imports './template1')
└── view2.ts
generated # 自定生成的模板文件
└── templates
└── views
└── template1.ts (imports './view2')
假设构建工具会把它们整合到同一输出目录中(也就是说,运行时view1
与template1
是在一起的),因此能够通过./xxx
的方式引入。可以通过rootDirs
将这种关系告知编译器,把源目录都列出来:
{
"compilerOptions": {
"rootDirs": [
"src/views",
"generated/templates/views"
]
}
}
此后只要遇到指向rootDirs
子目录的相对模块引入,都会尝试在rootDirs
的每一项中查找
实际上,rootDirs
非常灵活,数组中可以含有任意多个目录名称,无论目录是否真实存在。这让编译器能够以类型安全的方式,“捕捉”复杂的构建/运行时特性,比如条件引入以及项目特定的加载器插件
比如国际化的场景,构建工具通过插入特殊的路径标识(如#{locale}
)来自动生成当地特定 bundle,例如把./#{locale}/messages
映射到具体的./zh/messages
、./de/messages
等等。通过rootDirs
也很容易解决:
{
"compilerOptions": {
"rootDirs": [
"src/zh",
"src/de",
"src/#{locale}"
]
}
}
如果本地有zh
语言包的话,编译时将会引入src/zh
下的文件,例如import messages from './#{locale}/messages
会被解析成import messages from './zh/messages'
四.追踪解析过程
模块能够引用到当前目录之外的文件,如果要定位模块解析相关的问题(比如找不到模块、或者找错了),就不太容易了
此时可以开启--traceResolution
选项追踪编译器内部的模块解析过程,例如:
$ tsc --traceResolution
# 引入的模块名及所在位置
======== Resolving module './math-lib' from '/proj/src/index.ts'. ========
# 模块解析策略
Explicitly specified module resolution kind: 'NodeJs'.
# 具体过程
Loading module as file / folder, candidate module location '/proj/src/math-lib', target file type 'TypeScript'.
File '/proj/src/math-lib.ts' does not exist.
File '/proj/src/math-lib.tsx' does not exist.
File '/proj/src/math-lib.d.ts' exist - use it as a name resolution result.
# 最终结果
======== Module name './math-lib' was successfully resolved to '/proj/src/math-lib.d.ts'. ========
五.相关选项
--noResolve
正常情况下,编译器在开始之前会尝试解析所有模块引入,每成功解析一个模块引入,就把对应的文件添加到将要处理的源文件集里
而--noResolve
编译选项能够禁止编译器添加任何文件(通过命令行传入的除外),此时仍会尝试解析模块对应的文件,但不再添加进来,例如源文件:
// 源码文件 app.ts
import * as A from "moduleA"
import * as B from "moduleB"
tsc app.ts moduleA.ts --noResolve
将能正确引入moduleA
,而moduleB
则会报错找不到(因为--noResolve
不允许添加其它文件)
exclude
默认情况下,tsconfig.json
所在目录即 TypeScript 项目目录,不指定files
或exclude
的话,该目录及其子孙目录下的所有文件都会被添加到编译过程中。可以通过exclude
选项排除某些文件(黑名单),或者用files
选项指定想要编译的源文件(白名单)
此外,编译过程中遇到被引入的模块,也会被添加进来,无论是否被 exclude 掉。因此,要在编译时彻底排除一个文件的话,除了exclude
自身之外,还要把所有引用到它的文件也都排除掉
总结的蛮好,比官网的清除一点。我看你学习的知识面挺全面的。
PS:小哥的主题不错,可否分享一份?