模組 - 理論

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 中均受支援。它使用專用的 importexport 語法。

js
// a.js
export default "Hello from a.js";
js
// b.js
import a from "./a.js";
console.log(a); // 'Hello from a.js'

CommonJS (CJS) 是在 ESM 成為語言規範的一部分之前,Node.js 最初採用的模組系統。它在 Node.js 中仍與 ESM 並行支援。它使用名為 exportsrequire 的普通 JavaScript 物件和函式。

js
// a.js
exports.message = "Hello from a.js";
js
// b.js
const a = require("./a");
console.log(a.message); // 'Hello from a.js'

因此,當 TypeScript 檢測到檔案是 CommonJS 或 ECMAScript 模組時,它首先會假設該檔案擁有獨立的作用域。然而除此之外,編譯器的任務會變得稍微複雜一些。

TypeScript 處理模組的任務

TypeScript 編譯器的主要目標是在編譯時捕獲某些型別的執行時錯誤,從而防止它們發生。無論是否涉及模組,編譯器都需要了解程式碼預期的執行時環境——例如,哪些全域性變數可用。當涉及模組時,編譯器需要回答幾個額外的問題才能完成其工作。讓我們用幾行輸入程式碼作為一個示例,來思考分析它所需的所有資訊。

ts
import sayHello from "greetings";
sayHello("world");

為了檢查此檔案,編譯器需要知道 sayHello 的型別(它是一個可以接受一個字串引數的函式嗎?),這引出了相當多的額外問題:

  1. 模組系統會直接載入此 TypeScript 檔案,還是會載入我(或另一個編譯器)從該 TypeScript 檔案生成的 JavaScript 檔案?
  2. 鑑於將要載入的檔名及其在磁碟上的位置,模組系統期望找到的是哪種型別的模組?
  3. 如果輸出了 JavaScript,那麼該檔案中的模組語法將如何在輸出程式碼中轉換?
  4. 模組系統將在哪裡查詢由 "greetings" 指定的模組?查詢會成功嗎?
  5. 該查詢所解析出的檔案屬於哪種型別的模組?
  6. 模組系統是否允許 (2) 中檢測到的模組型別引用 (5) 中檢測到的模組型別,並使用 (3) 中確定的語法?
  7. 一旦 "greetings" 模組被分析完畢,該模組的哪個部分繫結到了 sayHello

請注意,所有這些問題都取決於宿主 (host) 的特性——宿主是最終消費輸出 JavaScript(或原始 TypeScript)以指導其模組載入行為的系統,通常是執行時(如 Node.js)或打包工具(如 Webpack)。

ECMAScript 規範定義了 ESM 匯入和匯出如何相互連結,但並未規定 (4) 中的檔案查詢(稱為模組解析)是如何發生的,也沒有說明 CommonJS 等其他模組系統。因此,執行時和打包工具,特別是那些希望同時支援 ESM 和 CJS 的工具,在設計自己的規則方面擁有很大的自由度。因此,TypeScript 如何回答上述問題會根據程式碼預期執行的位置而發生巨大變化。沒有單一的正確答案,所以必須透過配置選項告知編譯器相關規則。

另一個需要記住的關鍵點是,TypeScript 幾乎總是從其輸出的 JavaScript 檔案(而不是輸入的 TypeScript 或 JavaScript 檔案)的角度來思考這些問題。如今,一些執行時和打包工具支援直接載入 TypeScript 檔案,在這種情況下,區分輸入檔案和輸出檔案已無意義。本文件的大部分內容討論的是 TypeScript 檔案被編譯為 JavaScript 檔案,然後由執行時模組系統載入的情況。檢查這些案例對於理解編譯器的選項和行為至關重要——從這裡開始,在思考 esbuild、Bun 和其他以 TypeScript 為先的執行時和打包工具時,簡化問題會更容易。因此,就目前而言,我們可以根據輸出檔案來總結 TypeScript 在處理模組時的任務:

充分理解宿主的規則

  1. 從而將檔案編譯為有效的輸出模組格式
  2. 確保這些輸出中的匯入能成功解析,並且
  3. 知道為匯入的名稱分配什麼型別

誰是宿主?

在繼續之前,有必要確保我們對“宿主”一詞達成共識,因為它會頻繁出現。我們之前將其定義為“最終消費輸出程式碼以指導其模組載入行為的系統”。換句話說,它是 TypeScript 之外的、TypeScript 的模組分析試圖模擬的系統。

  • 當輸出程式碼(無論是透過 tsc 還是第三方編譯器產生)直接在 Node.js 等執行時中執行時,該執行時就是宿主。
  • 當沒有“輸出程式碼”因為執行時直接消費 TypeScript 檔案時,該執行時仍然是宿主。
  • 當打包工具消費 TypeScript 輸入或輸出並生成 bundle 時,該打包工具就是宿主,因為它查看了原始的匯入/require,查找了它們引用的檔案,並生成了一個或一組新的檔案,其中原始的匯入和 require 被擦除或轉換得面目全非。(該 bundle 本身可能包含模組,執行它的執行時將是其宿主,但 TypeScript 不知道打包後發生的任何事情。)
  • 如果另一個轉譯器、最佳化器或格式化程式對 TypeScript 的輸出進行處理,只要它保持其看到的匯入和匯出不變,它就不是 TypeScript 所關心的宿主。
  • 在 Web 瀏覽器中載入模組時,TypeScript 需要模擬的行為實際上被分攤在了 Web 伺服器和在瀏覽器中執行的模組系統之間。瀏覽器的 JavaScript 引擎(或像 RequireJS 這樣的基於指令碼的模組載入框架)控制接受哪些模組格式,而 Web 伺服器決定當一個模組觸發載入另一個模組的請求時傳送哪個檔案。
  • 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 模組並存,具有特定的互操作性和檢測規則。
  • node18:反映 Node.js v18+ 的模組系統,它增加了對匯入屬性的支援。
  • nodenext:隨著 Node.js 模組系統的演進而不斷更新的目標,反映最新的 Node.js 版本。截至 TypeScript 5.8,nodenext 支援 require ECMAScript 模組。
  • es2015:反映 JavaScript 模組的 ES2015 語言規範(首次在該語言中引入 importexport 的版本)。
  • es2020:在 es2015 的基礎上增加了對 import.metaexport * as ns from "mod" 的支援。
  • es2022:在 es2020 的基礎上增加了對頂層 await 的支援。
  • esnext:目前與 es2022 相同,但將是一個動態目標,反映最新的 ECMAScript 規範,以及預計將包含在未來規範版本中的與模組相關的第三階段 (Stage 3+) 提案。
  • commonjs, system, amdumd:每個選項都會按其命名的模組系統輸出所有內容,並假設一切都可以成功匯入到該模組系統中。這些不再推薦用於新專案,本文件也不會詳細介紹它們。

Node.js 的模組格式檢測和互操作性規則使得在 Node.js 中執行的專案指定 moduleesnextcommonjs 是不正確的,即使 tsc 生成的所有檔案分別是 ESM 或 CJS。對於打算在 Node.js 中執行的專案,唯一正確的 module 設定是 node16nodenext。雖然全 ESM Node.js 專案的生成 JavaScript 在使用 esnextnodenext 編譯時看起來可能完全相同,但型別檢查可能有所不同。有關詳細資訊,請參閱 關於 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 modulerequire 物件注入到檔案的作用域中,因此嘗試使用它們的檔案會導致崩潰。反之,如果檔案被確定為 CJS 模組,檔案中的 importexport 宣告會導致語法錯誤崩潰。

module 編譯器選項設定為 node16node18nodenext 時,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 檔案的存在——這可能是庫作者在自己的輸入 .ts 檔案上執行 tsc 時建立的——由於其 .js 副檔名以及 /node_modules/pkg/package.json 中存在 "type": "module" 欄位,Node.js 必須將其解釋為 ES 模組。宣告檔案將在稍後的章節中詳細介紹。)

TypeScript 使用輸入檔案的檢測到的模組格式來確保它輸出 Node.js 在每個輸出檔案中所期望的語法。如果 TypeScript 生成帶有 importexport 語句的 /example.js,Node.js 在解析該檔案時會崩潰。如果 TypeScript 生成帶有 require 呼叫的 /main.mjs,Node.js 在評估時會崩潰。除了輸出之外,模組格式還用於確定型別檢查和模組解析的規則,我們將在接下來的章節中討論。

截至 TypeScript 5.6,其他 --module 模式(如 esnextcommonjs)也尊重格式特定的副檔名(.mts.cts),將其作為輸出格式的檔案級覆蓋。例如,名為 main.mts 的檔案會將 ESM 語法輸出到 main.mjs,即使 --module 設定為 commonjs

值得再次提到的是,TypeScript 在 --module node16--module node18--module nodenext 中的行為完全是由 Node.js 的行為決定的。由於 TypeScript 的目標是在編譯時捕獲潛在的執行時錯誤,因此它需要一個關於執行時將會發生什麼的非常精確的模型。這套相當複雜的模組型別檢測規則對於檢查將在 Node.js 中執行的程式碼是必要的,但如果應用於非 Node.js 宿主,則可能過於嚴格或直接是不正確的。

輸入模組語法

需要注意的是,輸入原始檔中看到的輸入模組語法與輸出到 JS 檔案的輸出模組語法在某種程度上是解耦的。也就是說,一個帶有 ESM 匯入的檔案:

ts
import { sayHello } from "greetings";
sayHello("world");

可能完全按原樣以 ESM 格式輸出,也可能以 CommonJS 格式輸出,

ts
Object.defineProperty(exports, "__esModule", { value: true });
const greetings_1 = require("greetings");
(0, greetings_1.sayHello)("world");

這取決於 module 編譯器選項(以及任何適用的模組格式檢測規則,如果 module 選項支援多種模組型別)。通常,這意味著僅檢視輸入檔案的內容不足以確定它是 ES 模組還是 CJS 模組。

如今,大多數 TypeScript 檔案在編寫時都使用 ESM 語法(importexport 語句),而不考慮輸出格式。這在很大程度上是 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 中被允許共存和互操作後才完全顯現出來:

  1. 關於 Node.js 中 ESM/CJS 互操作性將如何工作的早期假設被證明是錯誤的,如今,Node.js 和打包工具之間的互操作性規則各不相同。因此,TypeScript 中的模組配置空間非常大。
  2. 當輸入檔案中的語法看起來全是 ESM 時,作者或程式碼審查者很容易忘記檔案在執行時到底屬於哪種模組。而由於 Node.js 的互操作性規則,每個檔案屬於哪種模組變得非常重要。
  3. 當輸入檔案以 ESM 編寫時,型別宣告輸出(.d.ts 檔案)中的語法看起來也像 ESM。但是,由於相應的 JavaScript 檔案可能以任何模組格式輸出,TypeScript 無法僅透過檢視其型別宣告的內容來判斷檔案屬於哪種模組。同樣,由於 ESM/CJS 互操作性的本質,TypeScript 必須知道每個檔案屬於哪種模組,才能提供正確的型別並防止會導致崩潰的匯入。

在 TypeScript 5.0 中,引入了一個名為 verbatimModuleSyntax 的新編譯器選項,旨在幫助 TypeScript 作者確切地瞭解他們的 importexport 語句將如何被輸出。啟用後,該標誌要求輸入檔案中的匯入和匯出以在輸出前經歷最少量轉換的形式編寫。因此,如果檔案將作為 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 標準化以來,執行時、打包工具和轉譯器可以自由地對這些問題做出自己的回答,因此不存在標準的互操作性規則集。如今,大多數執行時和打包工具大致分為三類:

  1. 僅 ESM。 一些執行時(如瀏覽器引擎)只支援語言中實際存在的內容:ECMAScript 模組。
  2. 打包工具類。 在任何主流 JavaScript 引擎能夠執行 ES 模組之前,Babel 允許開發者透過將 ESM 轉譯為 CommonJS 來編寫 ESM。這些轉譯為 CJS 的 ESM 檔案與手寫的 CJS 檔案互動的方式,隱含了一套寬鬆的互操作性規則,這些規則已成為打包工具和轉譯器的事實標準。
  3. Node.js。 在 Node.js v20.19.0 之前,CommonJS 模組無法同步(使用 require)載入 ES 模組;它們只能透過動態 import() 呼叫非同步載入。ES 模組可以預設匯入 CJS 模組,這總是繫結到 exports。(這意味著類似於 Babel 的帶有 __esModule 的 CJS 輸出的預設匯入在 Node.js 和一些打包工具之間的行為不同。)

TypeScript 需要知道要採用哪一組規則,以便為匯入(特別是 default 匯入)提供正確的型別,並對在執行時會導致崩潰的匯入發出錯誤。當 module 編譯器選項設定為 node16node18nodenext 時,Node.js 的版本特定規則會被強制執行。1 所有其他 module 設定,結合 esModuleInterop 選項,在 TypeScript 中都會產生類似於打包工具的互操作性。(雖然使用 --module esnext 確實會阻止你編寫 CommonJS 模組,但它不會阻止你將它們作為依賴項匯入。目前沒有 TypeScript 設定可以防止 ES 模組匯入 CommonJS 模組,這對於直接執行在瀏覽器中的程式碼是合適的。)

模組說明符預設不會被轉換

雖然 module 編譯器選項可以將輸入檔案中的匯入和匯出轉換為輸出檔案中的不同模組格式,但模組說明符(你從中 import 或傳遞給 require 的字串)會按原樣輸出。例如,像這樣的輸入:

ts
import { add } from "./math.mjs";
add(1, 2);

可能會根據 module 編譯器選項被輸出為:

ts
import { add } from "./math.mjs";
add(1, 2);

或者

ts
const math_1 = require("./math.mjs");
math_1.add(1, 2);

但無論哪種方式,模組說明符都將是 "./math.mjs"。預設情況下,模組說明符必須以適用於程式碼的目標執行時或打包工具的方式編寫,而理解這些相對於輸出的說明符是 TypeScript 的工作。尋找模組說明符引用的檔案的過程稱為模組解析

TypeScript 5.7 引入了 --rewriteRelativeImportExtensions 選項,它將帶有 .ts.tsx.mts.cts 副檔名的相對模組說明符轉換為輸出檔案中的相應 JavaScript 等效項。此選項對於建立可以在開發過程中直接在 Node.js 中執行可以編譯為 JavaScript 輸出以供分發或生產使用的 TypeScript 檔案非常有用。

本文件是在引入 --rewriteRelativeImportExtensions 之前編寫的,它所提出的思維模型圍繞著對操作其輸入檔案的宿主模組系統的行為進行建模,無論是對 TypeScript 檔案進行操作的打包工具,還是對 .js 輸出進行操作的執行時。使用 --rewriteRelativeImportExtensions,應用該思維模型的方法是應用兩次:一次是對直接處理 TypeScript 輸入檔案的執行時或打包工具,另一次是對處理轉換後輸出的執行時或打包工具。本文件的大部分內容假設載入輸入檔案或載入輸出檔案,但其提出的原則可以擴充套件到兩者都被載入的情況。

模組解析

讓我們回到我們的第一個示例,回顧一下我們目前學到的關於它的內容:

ts
import sayHello from "greetings";
sayHello("world");

到目前為止,我們已經討論了宿主的模組系統和 TypeScript 的 module 編譯器選項可能會如何影響這段程式碼。我們知道輸入語法看起來像 ESM,但輸出格式取決於 module 編譯器選項、潛在的副檔名以及 package.json"type" 欄位。我們還知道 sayHello 繫結到什麼,甚至是否允許匯入,都可能根據該檔案和目標檔案的模組型別而變化。但我們還沒有討論如何找到目標檔案。

模組解析由宿主定義

雖然 ECMAScript 規範定義瞭如何解析和解釋 importexport 語句,但它將模組解析留給了宿主。如果你正在建立一個熱門的新 JavaScript 執行時,你可以自由建立一種模組解析方案,例如:

ts
import 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 將不知道為 monkeycowlion 分配什麼型別。正如 module 向編譯器告知宿主預期的模組格式一樣,moduleResolution 以及一些自定義選項,指定了宿主用於將模組說明符解析為檔案的演算法。這也解釋了為什麼 TypeScript 不會在輸出過程中修改匯入說明符:匯入說明符與磁碟上的檔案(如果存在的話)之間的關係是由宿主定義的,而 TypeScript 不是宿主。

可用的 moduleResolution 選項包括:

  • classic:TypeScript 最古老的模組解析模式,不幸的是,當 module 設定為 commonjsnode16nodenext 以外的任何值時,這是預設值。它可能是為了為各種 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 node18 的對應項,並在使用該 module 設定時預設設定。Node.js v12 及更高版本同時支援 ESM 和 CJS,每個都使用自己的模組解析演算法。在 Node.js 中,匯入語句和動態 import() 呼叫中的模組說明符不允許省略副檔名或 /index.js 字尾,而 require 呼叫中的模組說明符則可以。這種模組解析模式理解並根據需要執行此限制,具體取決於由 --module node16/node18 強制實施的模組格式檢測規則。(對於 node16nodenextmodulemoduleResolution 是相輔相成的:將一個設定為 node16nodenext 而將另一個設定為其他值是一個錯誤。)
  • nodenext:目前與 node16 相同,這是 --module nodenext 的對應項,並在使用該 module 設定時預設設定。它旨在成為一種前瞻性模式,隨著新 Node.js 模組解析功能的新增,將支援這些功能。
  • bundler:Node.js v12 引入了一些用於匯入 npm 包的新模組解析功能——package.json"exports""imports" 欄位——許多打包工具採用了這些功能,但並沒有同時採用更嚴格的 ESM 匯入規則。這種模組解析模式為以打包工具為目標的程式碼提供了一種基礎演算法。預設情況下,它支援 package.json"exports""imports",但可以配置為忽略它們。它需要將 module 設定為 esnext

TypeScript 模仿宿主的模組解析,但帶有型別

還記得 TypeScript 處理模組的任務的三個組成部分嗎?

  1. 將檔案編譯為有效的 輸出模組格式
  2. 確保這些輸出中的匯入能成功解析
  3. 知道為匯入的名稱分配什麼型別

完成最後兩項需要模組解析。但當我們大部分時間在輸入檔案中工作時,很容易忽略 (2) —— 模組解析的關鍵組成部分是驗證輸出檔案中的匯入或 require 呼叫是否能真正在執行時工作,而這些輸出檔案包含與輸入檔案相同的模組說明符。讓我們看一個包含多個檔案的新示例。

ts
// @Filename: math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: main.ts
import { add } from "./math";
add(1, 2);

當我們看到來自 "./math" 的匯入時,很容易想到:“這就是一個 TypeScript 檔案引用另一個檔案的方式。編譯器遵循這個(沒有副檔名的)路徑來為 add 分配一個型別。”

A simple flowchart diagram. A file (rectangle node) main.ts resolves (labeled arrow) through module specifier './math' to another file math.ts.

這並不完全錯誤,但現實更深刻。"./math" 的解析(以及隨之而來的 add 的型別)需要反映輸出檔案在執行時真正發生的情況。思考這個過程的一個更穩健的方法是這樣的:

A flowchart diagram with two groups of files: Input files and Output files. main.ts (an input file) maps to output file main.js, which resolves through the module specifier "./math" to math.js (another output file), which maps back to the input file math.ts.

這個模型清楚地表明,對於 TypeScript 而言,模組解析主要是準確模擬輸出檔案之間宿主的模組解析演算法的問題,並應用一點點重對映來查詢型別資訊。讓我們看另一個透過簡單模型看起來不直觀,但透過穩健模型卻完全合理的例子。

ts
// @moduleResolution: node16
// @rootDir: src
// @outDir: dist
// @Filename: src/math.mts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.mts
import { add } from "./math.mjs";
add(1, 2);

Node.js ESM import 宣告使用嚴格的模組解析演算法,要求相對路徑包含副檔名。當我們只考慮輸入檔案時,"./math.mjs" 似乎解析為 math.mts 有點奇怪。由於我們使用 outDir 將編譯後的輸出放在不同的目錄中,math.mjs 甚至不存在於 main.mts 旁邊!為什麼這能解析?使用我們新的思維模型,這沒問題。

A flowchart diagram with identical structure to the one above. There are two groups of files: Input files and Output files. src/main.mts (an input file) maps to output file dist/main.mjs, which resolves through module specifier "./math.mjs" to dist/math.mjs (another output file), which maps back to input file src/math.mts.

理解這個思維模型可能不會立即消除在輸入檔案中看到輸出副檔名的奇怪感覺,而且自然會想到捷徑:"./math.mjs" 引用了輸入檔案 math.mts。我必須寫輸出副檔名,但當我寫 .mjs 時,編譯器知道要查詢 .mts 這個捷徑甚至就是編譯器內部的工作方式,但更穩健的思維模型解釋了為什麼 TypeScript 中的模組解析是這樣工作的:鑑於輸出檔案中的模組說明符將與輸入檔案中的模組說明符相同的約束,這是唯一能完成我們驗證輸出檔案和分配型別這兩個目標的流程。

宣告檔案的作用

在前面的示例中,我們看到了模組解析的“重對映”部分在輸入檔案和輸出檔案之間工作。但是,當我們匯入庫程式碼時會發生什麼呢?即使該庫是用 TypeScript 編寫的,它也可能沒有釋出其原始碼。如果我們不能依賴將庫的 JavaScript 檔案映射回 TypeScript 檔案,我們可以驗證我們的匯入在執行時是否有效,但我們如何實現分配型別的第二個目標呢?

這就是宣告檔案(.d.ts, .d.mts 等)發揮作用的地方。理解宣告檔案如何被解釋的最好方法是理解它們來自哪裡。當你在一個輸入檔案上執行 tsc --declaration 時,你會得到一個輸出的 JavaScript 檔案和一個輸出的宣告檔案。

A diagram showing the relationship between different file types. A .ts file (top) has two arrows labeled 'generates' flowing to a .js file (bottom left) and a .d.ts file (bottom right). Another arrow labeled 'implies' points from the .d.ts file to the .js file.

由於這種關係,編譯器假設只要它看到宣告檔案,就存在一個相應的 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.css 的檔案可以由名為 styles.d.css.ts 的宣告檔案表示。

“但是等一下!很多宣告檔案是手寫的,不是tsc 生成的。聽說過 DefinitelyTyped 嗎?”你可能會反駁。這是真的——手寫宣告檔案,甚至移動/複製/重新命名它們以代表外部構建工具的輸出,都是一項危險且容易出錯的嘗試。DefinitelyTyped 貢獻者和不使用 tsc 同時生成 JavaScript 和宣告檔案的型別化庫作者應確保每個 JavaScript 檔案都有一個名稱相同且副檔名匹配的姊妹宣告檔案。打破這種結構可能會導致終端使用者的誤報 TypeScript 錯誤。npm 包 @arethetypeswrong/cli 可以幫助在錯誤釋出前捕獲並解釋這些錯誤。

用於打包工具、TypeScript 執行時和 Node.js 載入器的模組解析

到目前為止,我們真正強調了輸入檔案輸出檔案之間的區別。回想一下,在相對模組說明符上指定副檔名時,TypeScript 通常會強制你使用輸出副檔名

ts
// @Filename: src/math.ts
export function add(a: number, b: number) {
return a + b;
}
// @Filename: src/main.ts
import { 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 檔案,它最終將消費並擦除你編寫的所有匯入以生成一個 bundle。
  • 你正在像 Node、Deno 或 Bun 這樣的 TypeScript 執行時中直接執行此程式碼。
  • 你正在為 Node 使用 ts-nodetsx 或其他轉譯載入器。

在這些情況下,你可以開啟 noEmit(或 emitDeclarationOnly)和 allowImportingTsExtensions,以停用生成不安全的 JavaScript 檔案,並使 .ts 副檔名的匯入錯誤靜默。

無論是否使用 allowImportingTsExtensions,為模組解析宿主選擇最合適的 moduleResolution 設定仍然很重要。對於打包工具和 Bun 執行時,它是 bundler。這些模組解析器受到 Node.js 的啟發,但沒有采用 Node.js 對匯入應用的停用副檔名搜尋的嚴格 ESM 解析演算法。bundler 模組解析設定反映了這一點,像 node16-nodenext 一樣預設啟用 package.json "exports" 支援,同時始終允許無副檔名的匯入。有關更多指導,請參閱 選擇編譯器選項

庫的模組解析

編譯應用時,你根據模組解析宿主是誰來選擇 TypeScript 專案的 moduleResolution 選項。編譯庫時,你不知道輸出程式碼會在哪裡執行,但你希望它儘可能多地執行。使用 "module": "node18"(連同隱含的 "moduleResolution": "node16")是最大化輸出 JavaScript 模組說明符相容性的最佳選擇,因為它會強制你遵守 Node.js 對 import 模組解析的更嚴格規則。讓我們看看如果一個庫使用 "moduleResolution": "bundler"(或更糟,"node10")編譯會發生什麼:

ts
export * 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?

另一方面,如果我們這樣寫:

ts
export * from "./utils.js";

這將產生在 Node.js 打包工具中都能執行的輸出。

簡而言之,"moduleResolution": "bundler" 具有傳染性,允許生成僅在打包工具中執行的程式碼。同樣,"moduleResolution": "nodenext" 僅檢查輸出是否在 Node.js 中執行,但在大多數情況下,在 Node.js 中執行的模組程式碼也會在其他執行時和打包工具中執行。

當然,此指導僅適用於庫從 tsc 輸出程式碼的情況。如果庫在釋出之前被打包,"moduleResolution": "bundler" 可能是可以接受的。任何更改模組格式或模組說明符以生成庫最終構建的構建工具,都有責任確保產品模組程式碼的安全性和相容性,而 tsc 不再能為此任務做出貢獻,因為它不知道執行時將會存在什麼模組程式碼。


  1. 在 Node.js v20.19.0 及更高版本中,允許 require ES 模組,但前提是已解析的模組及其頂層匯入不使用頂層 await。TypeScript 不嘗試強制執行此規則,因為它無法從宣告檔案中判斷相應的 JavaScript 檔案是否包含頂層 await

TypeScript 文件是一個開源專案。歡迎提交 Pull Request 來幫助我們改進這些頁面 ❤

此頁面的貢獻者
ABAndrew Branch (7)
RPRob Palmer (1)
MSMax Schwenk (1)
FSFilip Sodić (1)

最後更新:2026 年 3 月 27 日