寫在前面
[模塊化機制](/articles/模塊-typescript 筆記 13/) 讓我們能夠把代碼拆分成多個模塊(文件),而編譯時需要知道依賴模塊的確切類型,那麼首先要找到它(建立模塊名到模塊文件路徑的映射)
實際上,在 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 自身之外,還要把所有引用到它的文件也都排除掉
暫無評論,快來發表你的看法吧