跳到主要內容
黯羽輕揚每天積累一點點

模塊解析機制_TypeScript 筆記 14

免費2019-04-07#TypeScript#TypeScript Module Resolution#TypeScript模块查找#TypeScript模块解析规则#TypeScript exclude not working#TypeScript忽略

import 究竟是怎樣引入一個模塊的?

寫在前面

[模塊化機制](/articles/模塊-typescript 筆記 13/) 讓我們能夠把代碼拆分成多個模塊(文件),而編譯時需要知道依賴模塊的確切類型,那麼首先要找到它(建立模塊名到模塊文件路徑的映射)

實際上,在 TypeScript 裡,一個模塊名可能對應一個.ts/.tsx.d.ts 文件(開啟 --allowJs 的話,還可能對應.js/.jsx 文件)

基本思路是:

  1. 先嘗試尋找模塊對應的文件(.ts/.tsx
  2. 如果沒有找到,並且不是相對模塊引入(non-relative),就嘗試尋找外部模塊聲明(ambient module declaration),即 d.ts
  3. 如果還沒找到,報錯 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";

二者區別在於:

  • 相對模塊引入:相對於要引入的文件去尋找模塊,並且不會被解析為外部模塊聲明。用來引入(能在運行時保持相對位置的)自定義模塊

  • 非相對模塊引入:相對於 baseUrl 或根據 路徑映射 去尋找模塊,可能被解析為外部模塊聲明。用來引入外部依賴模塊

二.模塊解析策略

具體的,有 2 種模塊解析策略:

  • Classic:TypeScript 默認的解析策略,目前僅用作向後兼容

  • Node:與 NodeJS 模塊機制一致的解析策略

這 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");

匹配順序如下:

  1. 嘗試匹配 /root/src/moduleB.js

  2. 再嘗試匹配 /root/src/moduleB/package.json,接著尋找主模塊(例如指定了 { "main": "lib/mainModule.js" } 的話,就引入 /root/src/moduleB/lib/mainModule.js

  3. 否則嘗試匹配 /root/src/moduleB/index.js,因為 index.js 會被隱式地當作該目錄下的主模塊

P.S.具體參考 NodeJS 文檔:File ModulesFolders 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.jsonbaseUrl 字段(相對路徑的話,根據 tsconfig.json 所在目錄計算)

注意,相對模塊引入不受 baseUrl 影響,因為總是相對於引入它們的文件去解析

路徑映射

某些模塊並不在 baseUrl 下,比如 jquery 模塊在運行時可能來自 node_modules/jquery/dist/jquery.slim.min.js,此時,模塊加載器通過路徑映射將模塊名對應到運行時的文件

TypeScript 同樣支持類似的映射配置(tsconfig.jsonpaths 字段),例如:

{
  "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')

假設構建工具會把它們整合到同一輸出目錄中(也就是說,運行時 view1template1 是在一起的),因此能夠通過 ./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 項目目錄,不指定 filesexclude 的話,該目錄及其子孫目錄下的所有文件都會被添加到編譯過程中。可以通過 exclude 選項排除某些文件(黑名單),或者用 files 選項指定想要編譯的源文件(白名單)

此外,編譯過程中遇到被引入的模塊,也會被添加進來,無論是否被 exclude 掉。因此,要在編譯時徹底排除一個文件的話,除了 exclude 自身之外,還要把所有引用到它的文件也都排除掉

參考資料

評論

暫無評論,快來發表你的看法吧

提交評論