JavaScript 中的指令碼和模組
在 JavaScript 的早期,當此語言僅在瀏覽器中執行時,沒有模組,但仍可透過在 HTML 中使用多個 script 標籤,將網頁的 JavaScript 分割成多個檔案
html<html><head><script src="a.js"></script><script src="b.js"></script></head><body></body></html>
這種方法有一些缺點,特別是在網頁變得更大且更複雜時。特別是,載入到同一個網頁上的所有指令碼共享相同的範圍(適當地稱為「全域範圍」),這表示指令碼必須非常小心,不要覆寫彼此的變數和函式。
任何透過提供檔案自己的範圍,同時仍提供方法讓其他檔案可以使用程式碼片段來解決此問題的系統,都可以稱為「模組系統」。(在模組系統中,每個檔案都稱為「模組」這聽起來很明顯,但這個術語通常用於與在全域範圍中,在模組系統之外執行的 指令碼 檔案形成對比。)
有 許多模組系統,而 TypeScript 支援發出多個模組系統,但本文件將重點放在當今最重要的兩個系統:ECMAScript 模組 (ESM) 和 CommonJS (CJS)。
ECMAScript 模組 (ESM) 是內建於語言中的模組系統,在現代瀏覽器和 v12 以後的 Node.js 中受到支援。它使用專用的
import和export語法js// a.jsexport default "Hello from a.js";js// b.jsimport a from "./a.js";console.log(a); // 'Hello from a.js'CommonJS (CJS) 是最初在 Node.js 中發布的模組系統,當時 ESM 尚未成為語言規範的一部分。它仍與 ESM 一起在 Node.js 中受到支援。它使用稱為
exports和require的純 JavaScript 物件和函式js// a.jsexports.message = "Hello from a.js";js// b.jsconst a = require("./a");console.log(a.message); // 'Hello from a.js'
因此,當 TypeScript 偵測到檔案是 CommonJS 或 ECMAScript 模組時,它會先假設該檔案將有自己的範圍。不過,除此之外,編譯器的任務會變得更複雜一些。
TypeScript 的模組相關工作
TypeScript 編譯器的主要目標是透過在編譯時捕捉特定的執行時期錯誤,來防止這些錯誤發生。無論是否涉及模組,編譯器都需要知道程式碼預期的執行時期環境,例如有哪些全域變數可用。當涉及模組時,編譯器需要回答幾個額外的問題才能執行其工作。我們使用幾行輸入程式碼作為範例,來思考分析它所需的所有資訊
tsimport sayHello from "greetings";sayHello("world");
為了檢查這個檔案,編譯器需要知道 sayHello 的類型(它是否是一個可以接受一個字串引數的函式?),這會開啟許多額外的問題
- 模組系統會直接載入這個 TypeScript 檔案,還是會載入我(或其他編譯器)從這個 TypeScript 檔案產生的 JavaScript 檔案?
- 考量到模組系統將載入的檔案名稱和其在磁碟上的位置,模組系統預期會找到什麼類型的模組?
- 如果要發出輸出的 JavaScript,這個檔案中存在的模組語法將如何在輸出程式碼中轉換?
- 模組系統將從哪裡尋找
"greetings"指定的模組?尋找會成功嗎? - 由該尋找解析的檔案是什麼類型的模組?
- 模組系統是否允許在 (2) 中偵測到的模組類型使用在 (3) 中決定的語法來參照在 (5) 中偵測到的模組類型?
- 分析完
"greetings"模組後,模組的哪個部分會繫結到sayHello?
請注意,這些問題都取決於 主機 的特性,也就是最終使用輸出 JavaScript(或原始 TypeScript,視情況而定)來引導其模組載入行為的系統,通常是執行時期(例如 Node.js)或套件管理工具(例如 Webpack)。
ECMAScript 規範定義了 ESM 的匯入和匯出如何相互連結,但它沒有說明(4)中的檔案查詢(稱為 模組解析)是如何發生的,也沒有說明其他模組系統(例如 CommonJS)。因此,執行時期和套件管理工具(特別是那些想要同時支援 ESM 和 CJS 的工具)有很大的自由度來設計自己的規則。因此,TypeScript 回答上述問題的方式可能會根據程式碼預計執行的環境而有很大的不同。沒有單一的正確答案,因此必須透過組態選項告知編譯器這些規則。
另一個要記住的重要概念是,TypeScript 幾乎總是根據其 輸出 JavaScript 檔案來思考這些問題,而不是其 輸入 TypeScript(或 JavaScript!)檔案。如今,有些執行時期和套件管理工具支援直接載入 TypeScript 檔案,在這些情況下,思考分開的輸入和輸出檔案沒有意義。本文件的大部分內容討論 TypeScript 檔案編譯成 JavaScript 檔案的情況,而這些檔案又由執行時期模組系統載入。檢視這些情況對於建立對編譯器選項和行為的了解至關重要,從這裡開始並在思考 esbuild、Bun 和其他 優先使用 TypeScript 的執行時期和套件管理工具 時簡化會比較容易。因此,就目前而言,我們可以根據輸出檔案來總結 TypeScript 在模組方面的任務
充分了解主機規則
- 以編譯檔案成有效的輸出模組格式,
- 以確保這些輸出中的匯入會順利解析,以及
- 知道要將類型指定給匯入名稱。
誰是主機?
在我們繼續之前,值得確定我們對術語主機有相同的認知,因為它會經常出現。我們之前將其定義為「最終使用輸出程式碼來引導其模組載入行為的系統」。換句話說,它是 TypeScript 模組分析嘗試建模的 TypeScript 外部系統
- 當輸出程式碼(由
tsc或第三方轉譯器產生)直接在 Node.js 等執行時期中執行時,執行時期就是主機。 - 當沒有「輸出程式碼」是因為執行時期直接使用 TypeScript 檔案時,執行時期仍然是主機。
- 當打包器使用 TypeScript 輸入或輸出並產生一個套件時,打包器就是主機,因為它查看了原始的匯入/需要集合,查閱了它們引用的檔案,並產生一個新檔案或一組檔案,其中原始的匯入和需要被抹除或轉換到無法辨識的程度。(那個套件本身可能包含模組,而執行它的執行時期將會是它的主機,但 TypeScript 不知道打包器之後發生的事情。)
- 如果另一個轉譯器、最佳化器或格式化器在 TypeScript 的輸出上執行,只要它不修改它看到的匯入和匯出,它就不是 TypeScript 關心的主機。
- 在網頁瀏覽器中載入模組時,TypeScript 需要模擬的行為實際上會在網頁伺服器和瀏覽器中執行的模組系統之間分割。瀏覽器的 JavaScript 引擎(或像 RequireJS 這樣的基於腳本的模組載入架構)控制接受哪些模組格式,而網頁伺服器則決定當一個模組觸發載入另一個模組的請求時要傳送哪個檔案。
- TypeScript 編譯器本身不是主機,因為除了嘗試模擬其他主機之外,它不提供任何與模組相關的行為。
模組輸出格式
在任何專案中,我們需要回答的第一個關於模組的問題是,主機預期哪種類型的模組,因此 TypeScript 可以設定每個檔案的輸出格式以進行比對。有時候,主機只支援一種模組類型,例如瀏覽器中的 ESM,或 Node.js v11 及更早版本中的 CJS。Node.js v12 及更新版本接受 CJS 和 ES 模組,但使用檔案副檔名和 package.json 檔案來判斷每個檔案應為哪種格式,如果檔案內容與預期的格式不符,則會擲回錯誤。
module 編譯器選項會將這些資訊提供給編譯器。其主要目的是控制編譯期間發出的任何 JavaScript 的模組格式,但它也會告知編譯器如何偵測每個檔案的模組類型,不同模組類型如何互相匯入,以及 import.meta 和頂層 await 等功能是否可用。因此,即使 TypeScript 專案使用 noEmit,選擇 module 的正確設定仍然很重要。正如我們先前確定的,編譯器需要對模組系統有準確的了解,才能對匯入進行類型檢查(並提供 IntelliSense)。請參閱 選擇編譯器選項 以取得為專案選擇正確 module 設定的指南。
可用的 module 設定為
node16:反映 Node.js v16+ 的模組系統,它支援 ES 模組和 CJS 模組並存,並具有特定的互通性和偵測規則。nodenext:目前與node16相同,但會隨著 Node.js 模組系統的演進,成為反映最新 Node.js 版本的移動目標。es2015:反映 JavaScript 模組的 ES2015 語言規範(首次將import和export引入語言的版本)。es2020:在es2015中新增對import.meta和export * as ns from "mod"的支援。es2022:在es2020中新增對頂層await的支援。esnext:目前與es2022相同,但會隨著最新 ECMAScript 規範,以及預計會包含在未來規範版本中的與模組相關的第 3 階段以上的提案,成為反映這些規範的移動目標。commonjs、system、amd和umd:每個都發射模組系統中命名的一切,並假設一切都可以成功匯入該模組系統。這些不再建議用於新專案,而且本文件不會詳細介紹。
Node.js 的模組格式偵測和互通性規則,使得將專案中執行於 Node.js 的
module指定為esnext或commonjs是不正確的,即使tsc發出的所有檔案分別為 ESM 或 CJS 也是如此。唯一正確的module設定,適用於打算執行於 Node.js 的專案,為node16和nodenext。儘管對於全 ESM Node.js 專案發出的 JavaScript,在使用esnext和nodenext編譯時可能看起來完全相同,但類型檢查可能有所不同。請參閱nodenext參考部分,以取得更多詳細資訊。
模組格式偵測
Node.js 理解 ES 模組和 CJS 模組,但每個檔案的格式由其檔案副檔名和在搜尋檔案目錄和所有祖先目錄時找到的第一個 package.json 檔案的 type 欄位決定
.mjs和.cjs檔案分別始終被詮釋為 ES 模組和 CJS 模組。- 如果最近的
package.json檔案包含值為"module"的type欄位,則.js檔案會被詮釋為 ES 模組。如果沒有package.json檔案,或如果type欄位不存在或有任何其他值,則.js檔案會被詮釋為 CJS 模組。
如果檔案根據這些規則被判定為 ES 模組,Node.js 就不會在評估期間將 CommonJS module 和 require 物件注入檔案的範圍中,因此嘗試使用它們的檔案會導致當機。相反地,如果檔案被判定為 CJS 模組,檔案中的 import 和 export 宣告會導致語法錯誤當機。
當 module 編譯器選項設為 node16 或 nodenext 時,TypeScript 會將相同的演算法套用至專案的輸入檔案,以判定每個對應的輸出檔案的模組種類。讓我們看看在使用 --module nodenext 的範例專案中,模組格式是如何被偵測的
| 輸入檔案名稱 | 內容 | 輸出檔案名稱 | 模組種類 | 原因 |
|---|---|---|---|---|
/package.json |
{} |
|||
/main.mts |
/main.mjs |
ESM | 檔案副檔名 | |
/utils.cts |
/utils.cjs |
CJS | 檔案副檔名 | |
/example.ts |
/example.js |
CJS | package.json 中沒有 "type": "module" |
|
/node_modules/pkg/package.json |
{ "type": "module" } |
|||
/node_modules/pkg/index.d.ts |
ESM | package.json 中有 "type": "module" |
||
/node_modules/pkg/index.d.cts |
CJS | 檔案副檔名 |
當輸入檔案副檔名為 .mts 或 .cts 時,TypeScript 會將該檔案分別視為 ES 模組或 CJS 模組,因為 Node.js 會將輸出的 .mjs 檔案視為 ES 模組,或將輸出的 .cjs 檔案視為 CJS 模組。當輸入檔案副檔名為 .ts 時,TypeScript 必須參照最近的 package.json 檔案來判定模組格式,因為這是 Node.js 在遇到輸出的 .js 檔案時會執行的動作。(請注意,相同的規則適用於 pkg 相依項中的 .d.cts 和 .d.ts 宣告檔案:儘管它們不會在此編譯中產生輸出檔案,但 .d.ts 檔案的存在暗示存在對應的 .js 檔案(可能是 pkg 函式庫的作者在自己的輸入 .ts 檔案上執行 tsc 時建立的),而 Node.js 必須將其解譯為 ES 模組,因為它的副檔名是 .js,而且 /node_modules/pkg/package.json 中有 "type": "module" 欄位。宣告檔案會在 後面的章節 中更詳細地說明。)
TypeScript 使用偵測到的輸入檔案模組格式,以確保它發出的輸出語法符合 Node.js 在每個輸出檔案中的預期。如果 TypeScript 發出包含 import 和 export 陳述式的 /example.js,Node.js 會在剖析檔案時當機。如果 TypeScript 發出包含 require 呼叫的 /main.mjs,Node.js 會在評估期間當機。除了發出之外,模組格式也用於決定類型檢查和模組解析的規則,我們會在以下各節中討論。
值得再次提到,TypeScript 在 --module node16 和 --module nodenext 中的行為完全是由 Node.js 的行為所驅動。由於 TypeScript 的目標是在編譯時捕捉潛在的執行時間錯誤,因此它需要非常精確地模擬執行時間會發生什麼事。這套相當複雜的模組類型偵測規則對於檢查將在 Node.js 中執行的程式碼而言是必要的,但如果套用於非 Node.js 主機,則可能過於嚴格或只是不正確。
輸入模組語法
請務必注意,輸入原始碼檔案中看到的輸入模組語法與發出到 JS 檔案的輸出模組語法有些許脫鉤。也就是說,包含 ESM 匯入的檔案
tsimport { sayHello } from "greetings";sayHello("world");
可能以 ESM 格式按原樣發射,或可能以 CommonJS 發射
tsObject.defineProperty(exports, "__esModule", { value: true });const greetings_1 = require("greetings");(0, greetings_1.sayHello)("world");
取決於 module 編譯器選項(以及任何適用的 模組格式偵測 規則,如果 module 選項支援多於一種模組)。一般來說,這表示檢視輸入檔案的內容不足以判斷它是 ES 模組還是 CJS 模組。
現今,大多數 TypeScript 檔案都使用 ESM 語法(
import和export陳述式)撰寫,而與輸出格式無關。這在很大程度上是 ESM 走向廣泛支援的漫長道路的遺留問題。ECMAScript 模組在 2015 年標準化,在 2017 年獲得大多數瀏覽器的支援,並在 2019 年出現在 Node.js v12 中。在此期間的大部分時間,很明顯 ESM 是 JavaScript 模組的未來,但很少有執行環境可以使用它。像 Babel 這樣的工具讓 JavaScript 能夠以 ESM 撰寫,並降級到可以在 Node.js 或瀏覽器中使用的另一種模組格式。TypeScript 隨後也支援 ES 模組語法,並在 1.5 版本 中輕微阻止使用原始 CommonJS 啟發的import fs = require("fs")語法。這種「撰寫 ESM,輸出任何東西」策略的優點是 TypeScript 可以使用標準 JavaScript 語法,讓撰寫體驗對新手來說很熟悉,而且(理論上)讓專案在未來輕鬆鎖定 ESM 輸出。有三個顯著的缺點,只有在 ESM 和 CJS 模組被允許在 Node.js 中共存和互操作後才完全顯現出來
- 關於 ESM/CJS 互操作性如何在 Node.js 中運作的早期假設被證明是錯誤的,而且今天,互操作性規則在 Node.js 和打包器之間有所不同。因此,TypeScript 中模組的組態空間很大。
- 當輸入檔案中的語法看起來都像 ESM 時,作者或程式碼審閱者很容易忘記檔案在執行時是什麼類型的模組。而且由於 Node.js 的互操作性規則,每個檔案是什麼類型的模組變得非常重要。
- 當輸入檔案以 ESM 撰寫時,類型宣告輸出(
.d.ts檔案)中的語法看起來也像 ESM。但由於對應的 JavaScript 檔案可能以任何模組格式發出,因此 TypeScript 無法僅透過檢視其類型宣告的內容來判斷檔案的模組類型。而且,由於 ESM/CJS 互通性的本質,TypeScript 必須知道每個模組的類型才能提供正確的類型並防止會導致崩潰的匯入。在 TypeScript 5.0 中,引入了名為
verbatimModuleSyntax的新編譯器選項,以協助 TypeScript 作者確切了解其import和export陳述式將如何發出。啟用時,此旗標要求輸入檔案中的匯入和匯出以最少轉換量進行發出前的形式撰寫。因此,如果檔案將以 ESM 發出,則匯入和匯出必須以 ESM 語法撰寫;如果檔案將以 CJS 發出,則必須以受 CommonJS 啟發的 TypeScript 語法(import fs = require("fs")和export = {})撰寫。此設定特別建議用於主要使用 ESM 但有少數 CJS 檔案的 Node.js 專案。不建議用於目前鎖定 CJS 但可能想在未來鎖定 ESM 的專案。
ESM 和 CJS 互通性
ES 模組可以 import CommonJS 模組嗎?如果是,預設匯入會連結到 exports 或 exports.default 嗎?CommonJS 模組可以 require ES 模組嗎?CommonJS 並非 ECMAScript 規範的一部分,因此自 2015 年 ESM 標準化以來,執行時期、套件管理工具和轉譯器可以自由決定這些問題的答案,因此不存在標準的互通性規則集。目前,大多數執行時期和套件管理工具大致可分為三種類別
- 僅 ESM。某些執行時期(例如瀏覽器引擎)僅支援語言實際的一部分:ECMAScript 模組。
- 類似套件管理工具。在任何主要 JavaScript 引擎可以執行 ES 模組之前,Babel 允許開發人員透過將其轉譯為 CommonJS 來撰寫 ES 模組。這些 ESM 轉譯為 CJS 檔案與手寫 CJS 檔案互動的方式暗示了一組寬容的互通性規則,這些規則已成為套件管理工具和轉譯器的實際標準。
- Node.js。在 Node.js 中,CommonJS 模組無法同步載入 ES 模組(使用
require);它們只能使用非同步動態import()呼叫載入 ES 模組。ES 模組可以預設匯入 CJS 模組,這些模組總是繫結到exports。(這表示使用__esModule的類似 Babel 的 CJS 輸出的預設匯入在 Node.js 和某些套件管理工具之間的行為不同。)
TypeScript 需要知道假設哪一個規則集才能提供正確的型別(特別是 default)匯入,並針對執行時期會崩潰的匯入產生錯誤。當 module 編譯器選項設為 node16 或 nodenext 時,將強制執行 Node.js 的規則。所有其他 module 設定與 esModuleInterop 選項結合使用,將在 TypeScript 中產生類似套件管理工具的互通性。(雖然使用 --module esnext 可以防止您撰寫 CommonJS 模組,但無法防止您將其匯入為相依項。目前沒有 TypeScript 設定可以防止 ES 模組匯入 CommonJS 模組,這對於直接瀏覽器程式碼來說是適當的。)
模組指定符不會轉換
雖然 module 編譯器選項可以將輸入檔案中的匯入和匯出轉換成輸出檔案中的不同模組格式,但模組指定符 (您從中 import 或傳遞給 require 的字串 from) 始終會以原樣發出。例如,類似這樣的輸入
tsimport { add } from "./math.mjs";add(1, 2);
可能會發出為
tsimport { add } from "./math.mjs";add(1, 2);
或
tsconst math_1 = require("./math.mjs");math_1.add(1, 2);
視 module 編譯器選項而定,但模組指定符將始終為 "./math.mjs"。沒有任何編譯器選項可以啟用轉換、替換或改寫模組指定符。因此,模組指定符必須以適用於程式碼目標執行時間或套件管理工具的方式撰寫,而 TypeScript 的工作就是了解這些輸出相關指定符。尋找模組指定符所引用的檔案的程序稱為模組解析。
模組解析
讓我們回到我們的 第一個範例,並檢閱我們到目前為止對它的了解
tsimport sayHello from "greetings";sayHello("world");
到目前為止,我們已經討論了主機的模組系統和 TypeScript 的 module 編譯器選項如何影響此程式碼。我們知道輸入語法看起來像 ESM,但輸出格式取決於 module 編譯器選項、可能的檔案副檔名和 package.json "type" 欄位。我們也知道 sayHello 繫結到什麼,甚至是否允許匯入,可能會因這個檔案和目標檔案的模組種類而異。但我們還沒有討論過如何尋找目標檔案。
模組解析是由主機定義的
雖然 ECMAScript 規格定義了如何解析和詮釋 import 和 export 陳述式,但它將模組解析留給主機。如果您正在建立一個新的 JavaScript 執行時間,您可以自由建立一個模組解析架構,例如
tsimport monkey from "🐒"; // Looks for './eats/bananas.js'import cow from "🐄"; // Looks for './eats/grass.js'import lion from "🦁"; // Looks for './eats/you.js'
,並聲稱實作「符合標準的 ESM」。不用說,TypeScript 沒有內建這個執行時期模組解析演算法的知識,所以不知道要將什麼型別指定給 monkey、cow 和 lion。就像 module 會告知編譯器主機預期的模組格式,moduleResolution 會連同一些自訂選項,指定主機用於將模組指定項解析為檔案的演算法。這也說明了為什麼 TypeScript 在發射期間不會修改匯入指定項:匯入指定項和磁碟上的檔案之間的關係(如果存在的話)是由主機定義的,而 TypeScript 不是主機。
可用的 moduleResolution 選項為
classic:TypeScript 最早的模組解析模式,不幸的是,當module設定為commonjs、node16或nodenext以外的任何值時,它就是預設值。它可能是為了提供各種 RequireJS 組態的最佳解析。不應將它用於新專案(甚至是不使用 RequireJS 或其他 AMD 模組載入器的舊專案),並且預計會在 TypeScript 6.0 中棄用。node10:以前稱為node,這是當module設為commonjs時的不幸預設值。它相當符合舊於 v12 的 Node.js 版本,有時它也能勉強模擬大多數綑綁器執行模組解析的方式。它支援從node_modules查詢套件、載入目錄index.js檔案,以及在相對模組指定符中省略.js副檔名。不過,由於 Node.js v12 為 ES 模組引入了不同的模組解析規則,因此它非常不適合用於現代版本的 Node.js。不應將它用於新專案。node16:這是--module node16的對應項,並在該module設定中設為預設值。Node.js v12 和更新版本同時支援 ESM 和 CJS,它們各自使用自己的模組解析演算法。在 Node.js 中,匯入陳述式和動態import()呼叫中的模組指定符不允許省略檔案副檔名或/index.js字尾,但require呼叫中的模組指定符則允許。此模組解析模式會根據--module node16訂定的模組格式偵測規則,了解並強制執行此限制(視需要而定)。(對於node16和nodenext,module和moduleResolution是相輔相成的:將其中一個設定為node16或nodenext,同時將另一個設定為其他值,會產生不受支援的行為,而且未來可能會出錯。)nodenext:目前與node16相同,這是--module nodenext的對應項,並在該module設定中設為預設值。它旨在成為一個前瞻性的模式,將在新增時支援新的 Node.js 模組解析功能。bundler:Node.js v12 引入了部分新的模組解析功能,用於匯入 npm 套件,即package.json的"exports"和"imports"欄位,而且許多 bundler 也採用了這些功能,但並未同時採用 ESM 匯入的更嚴格規則。此模組解析模式提供了一個基礎演算法,用於針對 bundler 的程式碼。它預設支援package.json"exports"和"imports",但可以設定為忽略它們。它需要將module設定為esnext。
TypeScript 模仿主機的模組解析,但有類型
還記得 TypeScript 關於模組的 工作 的三個組成部分嗎?
- 將檔案編譯成有效的輸出模組格式
- 確保這些輸出中的匯入會順利解析
- 知道要將類型指定給匯入的名稱。
模組解析是完成後兩項的必要條件。但是,當我們花大部分時間處理輸入檔案時,很容易忘記 (2),也就是模組解析的一個關鍵組成部分,在於驗證輸出檔案中的匯入或 require 呼叫,其中包含與輸入檔案相同的模組指定符號,實際上會在執行階段運作。我們來看一個包含多個檔案的新範例
ts// @Filename: math.tsexport function add(a: number, b: number) {return a + b;}// @Filename: main.tsimport { add } from "./math";add(1, 2);
當我們看到從 "./math" 匯入時,可能會忍不住想:「這就是一個 TypeScript 檔案如何參照另一個檔案。編譯器會遵循這個(沒有副檔名的)路徑來為 add 指定型別。」
這並非完全錯誤,但實際上更為深入。"./math"(以及隨後的 add 型別)的解析需要反映執行時期對輸出檔案所發生事件的實際情況。思考這個流程的更穩健方式如下
這個模型清楚說明,對於 TypeScript,模組解析主要是在輸出檔案之間準確建模主機的模組解析演算法,並套用一些重新對應來尋找型別資訊。讓我們來看另一個範例,它透過簡單模型來看似乎不直觀,但透過穩健模型來看卻非常合理
ts// @moduleResolution: node16// @rootDir: src// @outDir: dist// @Filename: src/math.mtsexport function add(a: number, b: number) {return a + b;}// @Filename: src/main.mtsimport { add } from "./math.mjs";add(1, 2);
Node.js ESM import 宣告使用嚴格的模組解析演算法,它需要相對路徑包含檔案副檔名。當我們只考慮輸入檔案時,"./math.mjs" 似乎解析為 math.mts 有點奇怪。由於我們使用 outDir 將編譯輸出放入不同的目錄中,因此 math.mjs 甚至不存在於 main.mts 旁邊!為什麼這會解析?有了我們新的心智模型,這不成問題
了解此心智模式可能無法立即消除在輸入檔案中看到輸出檔案副檔名的陌生感,而且以捷徑思考是很自然的:"./math.mjs" 指的是輸入檔案 math.mts。我必須寫入輸出副檔名,但編譯器知道在我寫入 .mjs 時要尋找 .mts。這個捷徑甚至就是編譯器在內部運作的方式,但更強大的心智模式說明了 為什麼 TypeScript 中的模組解析會這樣運作:考量到輸出檔案中的模組識別符會 與 輸入檔案中的模組識別符「相同」這個限制,這是達成驗證輸出檔案和指派型別這兩個目標的唯一流程。
宣告檔案的角色
在先前的範例中,我們看到了模組解析的「重新對應」部分在輸入和輸出檔案之間運作。但是當我們匯入函式庫程式碼時會發生什麼事?即使函式庫是用 TypeScript 編寫的,它可能沒有發布其原始程式碼。如果我們無法依賴將函式庫的 JavaScript 檔案對應回 TypeScript 檔案,我們可以在執行階段驗證我們的匯入是否運作,但是我們要如何達成指派型別的第二個目標呢?
這是宣告檔案 (.d.ts、.d.mts 等) 發揮作用的地方。了解宣告檔案如何被詮釋的最佳方式是了解它們的來源。當您對輸入檔案執行 tsc --declaration 時,您會取得一個輸出 JavaScript 檔案和一個輸出宣告檔案
由於這種關係,編譯器假設不論在何處看到宣告檔案,都會有一個對應的 JavaScript 檔案,而宣告檔案中的類型資訊會對其進行完美描述。基於效能考量,在每個模組解析模式中,編譯器總是會先尋找 TypeScript 和宣告檔案,如果找到一個,它就不會繼續尋找對應的 JavaScript 檔案。如果找到 TypeScript 輸入檔案,它知道在編譯後將存在一個 JavaScript 檔案,如果找到宣告檔案,它知道編譯(可能是其他人執行的)已經發生,並在宣告檔案的同時建立一個 JavaScript 檔案。
宣告檔案不僅告訴編譯器存在一個 JavaScript 檔案,還會告訴編譯器它的名稱和副檔名
| 宣告檔案副檔名 | JavaScript 檔案副檔名 | TypeScript 檔案副檔名 |
|---|---|---|
.d.ts |
.js |
.ts |
.d.ts |
.js |
.tsx |
.d.mts |
.mjs |
.mts |
.d.cts |
.cjs |
.cts |
.d.*.ts |
.* |
最後一列表示,可以使用 allowArbitraryExtensions 編譯器選項來為非 JS 檔案輸入類型,以支援模組系統支援將非 JS 檔案作為 JavaScript 物件進行匯入的情況。例如,可以透過名為 styles.d.css.ts 的宣告檔案來表示名為 styles.css 的檔案。
「等等!很多宣告檔案都是手寫的,不是由
tsc產生的。聽過 DefinitelyTyped 嗎?」你可能會反對。的確如此,手寫宣告檔案,甚至移動/複製/重新命名它們以表示外部建置工具的輸出,是一種危險且容易出錯的冒險。DefinitelyTyped 貢獻者和未使用tsc來產生 JavaScript 和宣告檔案的類型化函式庫作者,應確保每個 JavaScript 檔案都有同名的兄弟宣告檔案,並符合副檔名。偏離此結構可能會導致最終使用者出現 TypeScript 誤判錯誤。npm 套件@arethetypeswrong/cli可以協助在發佈前找出並說明這些錯誤。
套件管理程式、TypeScript 執行時期和 Node.js 載入器的模組解析度
到目前為止,我們確實強調了輸入檔案和輸出檔案之間的區別。請記住,當在相對模組規格說明中指定檔案副檔名時,TypeScript 通常會讓你使用輸出檔案副檔名
ts// @Filename: src/math.tsexport function add(a: number, b: number) {return a + b;}// @Filename: src/main.tsimport { add } from "./math.ts";// ^^^^^^^^^^^// An import path can only end with a '.ts' extension when 'allowImportingTsExtensions' is enabled.
此限制適用於 TypeScript 不會將副檔名重寫 為 .js,如果 "./math.ts" 出現在輸出 JS 檔案中,該匯入在執行階段不會解析為另一個 JS 檔案。TypeScript 真的想要防止您產生不安全的輸出 JS 檔案。但如果沒有輸出 JS 檔案呢?如果您處於以下情況之一
- 您正在綑綁此程式碼,綑綁器已設定為在記憶體中轉譯 TypeScript 檔案,並且最終會使用並清除您編寫的所有匯入以產生綑綁。
- 您正在 Deno 或 Bun 等 TypeScript 執行階段中直接執行此程式碼。
- 您正在為 Node 使用
ts-node、tsx或其他轉譯載入器。
在這些情況下,您可以開啟 noEmit(或 emitDeclarationOnly)和 allowImportingTsExtensions 以停用發出不安全的 JavaScript 檔案,並取消對 .ts 副檔名匯入的錯誤。
無論是否使用 allowImportingTsExtensions,為模組解析主機選擇最合適的 moduleResolution 設定仍然很重要。對於綑綁器和 Bun 執行階段,它是 bundler。這些模組解析器是以 Node.js 為靈感,但沒有採用嚴格的 ESM 解析演算法,該演算法會 停用副檔名搜尋,而 Node.js 會將其套用於匯入。bundler 模組解析設定反映了這一點,啟用了類似 node16 和 nodenext 的 package.json "exports" 支援,同時總是允許無副檔名匯入。請參閱 選擇編譯器選項 以取得更多指導。
函式庫的模組解析
編譯應用程式時,您會根據模組解析主機是誰,為 TypeScript 專案選擇 moduleResolution 選項。編譯函式庫時,您不知道輸出程式碼會在哪裡執行,但您希望它可以在儘可能多的位置執行。使用 "module": "nodenext"(以及隱含的 "moduleResolution": "nodenext")是最佳化輸出 JavaScript 模組識別符相容性的最佳選擇,因為它會強制您遵守 Node.js 較嚴格的 import 模組解析規則。讓我們看看如果函式庫使用 "moduleResolution": "bundler"(或更糟的是,"node10")編譯會發生什麼事
tsexport * from "./utils";
假設 ./utils.ts(或 ./utils/index.ts)存在,打包器會接受此程式碼,因此 "moduleResolution": "bundler" 沒有抱怨。使用 "module": "esnext" 編譯後,此匯出陳述式的輸出 JavaScript 會與輸入完全相同。如果該 JavaScript 發布到 npm,則使用打包器的專案可以使用它,但在 Node.js 中執行時會導致錯誤
Error [ERR_MODULE_NOT_FOUND]: Cannot find module '.../node_modules/dependency/utils' imported from .../node_modules/dependency/index.js Did you mean to import ./utils.js?
另一方面,如果我們寫
tsexport * from "./utils.js";
這會產生在 Node.js 和 打包器中都能運作的輸出。
簡而言之,"moduleResolution": "bundler" 具有傳染性,允許產生僅在 bundler 中運作的程式碼。同樣地,"moduleResolution": "nodenext" 僅檢查輸出是否在 Node.js 中運作,但在大多數情況下,在 Node.js 中運作的模組程式碼也會在其他執行時期和 bundler 中運作。
當然,此指引僅適用於程式庫從 tsc 運送輸出的情況。如果程式庫在運送之前已打包,則 "moduleResolution": "bundler" 可能可以接受。任何變更模組格式或模組指定符號以產生程式庫最終建置的建置工具,都有責任確保產品模組程式碼的安全性和相容性,而 tsc 無法再協助此任務,因為它無法得知執行時期會存在哪些模組程式碼。