模組 - ESM/CJS 互通性

現在是 2015 年,而你正在撰寫一個 ESM 到 CJS 的轉譯器。沒有任何關於如何執行的規範;你只有 ES 模組如何彼此互動的規範、CommonJS 模組如何彼此互動的知識,以及找出問題的訣竅。考慮一個匯出的 ES 模組

ts
export const A = {};
export const B = {};
export default "Hello, world!";

你如何將它轉換成一個 CommonJS 模組?回想預設匯出僅是具備特殊語法的命名匯出,似乎只有一個選擇

ts
exports.A = {};
exports.B = {};
exports.default = "Hello, world!";

這是一個很好的類比,讓你可以在匯入端實作類似的功能

ts
import hello, { A, B } from "./module";
console.log(hello, A, B);
// transpiles to:
const module_1 = require("./module");
console.log(module_1.default, module_1.A, module_1.B);

到目前為止,CJS 世界中的所有內容都與 ESM 世界中的所有內容一一對應。進一步延伸上述等價性,我們可以看到我們也有

ts
import * as mod from "./module";
console.log(mod.default, mod.A, mod.B);
// transpiles to:
const mod = require("./module");
console.log(mod.default, mod.A, mod.B);

你可能會注意到,在此架構中,沒有辦法撰寫一個 ESM 匯出,其會產生將函式、類別或基本型別指定給 exports 的輸出

ts
// @Filename: exports-function.js
module.exports = function hello() {
console.log("Hello, world!");
};

但現有的 CommonJS 模組經常採用此形式。使用我們的轉譯器處理的 ESM 匯入如何存取此模組?我們剛剛建立了一個命名空間匯入 (import *) 轉譯為一個純粹的 require 呼叫,因此我們可以支援像這樣的輸入

ts
import * as hello from "./exports-function";
hello();
// transpiles to:
const hello = require("./exports-function");
hello();

我們的輸出在執行階段運作,但我們有一個相容性問題:根據 JavaScript 規範,命名空間匯入總是解析為一個 模組命名空間物件,也就是說,一個其成員為模組匯出的物件。在此情況下,require 會傳回函式 hello,但 import * 永遠無法傳回函式。我們假設的對應看起來無效。

值得在此後退一步,釐清目標為何。一旦模組出現在 ES2015 規範中,轉譯器便會支援將 ESM 降階至 CJS,讓使用者在執行環境支援它之前很久就能採用新的語法。甚至有一種感覺,撰寫 ESM 程式碼是「未來化」新專案的好方法。為了實現這個目標,需要有一條無縫的遷移路徑,從執行轉譯器的 CJS 輸出,到在執行環境開發支援後原生執行 ESM 輸入。目標是找到一種將 ESM 降階至 CJS 的方法,讓任何或所有這些轉譯的輸出都能在未來的執行環境中以其真正的 ESM 輸入取代,而不會產生可觀察到的行為變更。

透過遵循規範,轉譯器很容易找到一組轉換,讓其轉譯的 CommonJS 輸出的語意與 ESM 輸入的指定語意相符(箭頭代表匯入)

A flowchart with two similar flows side-by-side. Left: ESM. Right: ESM transpiled to CJS. In the ESM flow: "Importing module" flows to "Imported module" through arrow labeled "specified behavior". In the ESM transpiled to CJS flow: "Importing module" flows to "Imported module" through arrow labeled "designed based on spec".

然而,CommonJS 模組(撰寫為 CommonJS,而非轉譯為 CommonJS 的 ESM)已在 Node.js 生態系中廣泛使用,因此撰寫為 ESM 並轉譯為 CJS 的模組開始「匯入」撰寫為 CommonJS 的模組是不可避免的。不過,這種互通性的行為並未由 ES2015 指定,且尚未在任何實際執行環境中存在。

A flowchart with three areas side-by-side. Left: ESM. Middle: True CJS. Right: ESM transpiled to CJS. Left: ESM "Importing module" flows to ESM "Imported module" through arrow labeled "specified behavior," and to True CJS "Imported module" through dotted arrow labeled "unspecified behavior." Right: ESM transpiled to CJS "Importing module" flows to ESM transpiled to CJS "Imported module" through arrow labeled "designed based on spec," and to True CJS "Imported module" through dotted arrow labeled "❓🤷‍♂️❓"

即使轉譯器作者什麼都不做,行為也會從轉譯程式碼中發出的 require 呼叫與現有 CJS 模組中定義的 exports 之間現有的語意中產生。而且,為了讓使用者在執行環境支援後能無縫從轉譯的 ESM 轉換為真正的 ESM,該行為必須與執行環境選擇實作的行為相符。

猜測 interop 行為執行時期將支援的內容不僅限於 ESM 匯入「真正的 CJS」模組。ESM 是否能夠將 ESM 從 CJS 轉譯而來,與 CJS 區分開來,以及 CJS 是否能夠 require ES 模組,也未明確說明。甚至 ESM 匯入是否會使用與 CJS require 呼叫相同的模組解析演算法,也無法得知。為了讓轉譯器使用者順利轉移到原生 ESM,必須正確預測所有這些變數。

allowSyntheticDefaultImportsesModuleInterop

讓我們回到我們的規範相容性問題,其中 import * 轉譯為 require

ts
// Invalid according to the spec:
import * as hello from "./exports-function";
hello();
// but the transpilation works:
const hello = require("./exports-function");
hello();

當 TypeScript 最初新增撰寫和轉譯 ES 模組的支援時,編譯器透過針對任何命名空間匯入(其 exports 不是類似命名空間的物件)的模組發出錯誤,來解決這個問題

ts
import * as hello from "./exports-function";
// TS2497 ^^^^^^^^^^^^^^^^^^^^
// External module '"./exports-function"' resolves to a non-module entity
// and cannot be imported using this construct.

唯一的解決方法是讓使用者改回使用較舊的 TypeScript 匯入語法,表示 CommonJS require

ts
import hello = require("./exports-function");

強迫使用者改回非 ESM 語法,本質上等於承認「我們不知道或不確定像 "./exports-function" 這樣的 CJS 模組未來是否可以使用 ESM 匯入,但我們知道它不能使用 import *,即使它會在我們使用的轉譯架構中於執行時期運作。」這無法達到允許這個檔案在不變更的情況下轉移到真正的 ESM 的目標,但允許 import * 連結到函式的替代方案也無法達成。當 allowSyntheticDefaultImportsesModuleInterop 停用時,這仍然是 TypeScript 中的行為。

很不幸地,這是一個輕微的過度簡化——TypeScript 沒有完全避免這個錯誤的相容性問題,因為它允許命名空間匯入函式並保留其呼叫簽章,只要函式宣告與命名空間宣告合併——即使命名空間是空的。因此,儘管匯出裸函式的模組被視為「非模組實體」

ts
declare function $(selector: string): any;
export = $; // Cannot `import *` this 👍

一個應該是無意義的變更允許無效的匯入在沒有錯誤的情況下進行類型檢查

ts
declare namespace $ {}
declare function $(selector: string): any;
export = $; // Allowed to `import *` this and call it 😱

同時,其他轉譯器想出了一個解決相同問題的方法。思考過程大致如下

  1. 要匯入匯出函式或基本資料類型的 CJS 模組,我們顯然需要使用預設匯入。命名空間匯入會是非法的,而命名匯入在此處沒有意義。
  2. 最有可能的是,這表示實作 ESM/CJS 相互操作的執行時期會選擇讓 CJS 模組的預設匯入總是直接連結到整個 exports,而不是僅在 exports 是函式或基本資料類型時才這麼做。
  3. 因此,真正 CJS 模組的預設匯入應該就像 require 呼叫一樣運作。但是,我們需要一個方法來區分真正的 CJS 模組和我們轉譯的 CJS 模組,因此我們仍然可以將 export default "hello" 轉譯為 exports.default = "hello",並讓模組的預設匯入連結到 exports.default。基本上,我們自己轉譯的模組之一的預設匯入需要以一種方式運作(模擬 ESM 到 ESM 的匯入),而任何其他現有 CJS 模組的預設匯入需要以另一種方式運作(模擬我們認為 ESM 到 CJS 的匯入將如何運作)。
  4. 當我們將 ES 模組轉譯為 CJS 時,讓我們在輸出中新增一個特殊的額外欄位
    ts
    exports.A = {};
    exports.B = {};
    exports.default = "Hello, world!";
    // Extra special flag!
    exports.__esModule = true;
    當我們轉譯預設匯入時,我們可以檢查該欄位
    ts
    // import hello from "./modue";
    const _mod = require("./module");
    const hello = _mod.__esModule ? _mod.default : _mod;

__esModule 旗標首先出現在 Traceur,然後不久後出現在 Babel、SystemJS 和 Webpack 中。TypeScript 在 1.8 中新增了 allowSyntheticDefaultImports,讓類型檢查器能夠將預設匯入直接連結到 exports,而不是任何缺乏 export default 宣告的模組類型的 exports.default。此旗標並未修改匯入或匯出的發射方式,但它允許預設匯入反映其他轉譯器將如何處理它們。也就是說,它允許使用預設匯入來解析「非模組實體」,其中 import * 是錯誤

ts
// Error:
import * as hello from "./exports-function";
// Old workaround:
import hello = require("./exports-function");
// New way, with `allowSyntheticDefaultImports`:
import hello from "./exports-function";

這通常足以讓 Babel 和 Webpack 使用者撰寫在這些系統中已經運作的程式碼,而不會讓 TypeScript 抱怨,但這只是一個部分的解決方案,留下了一些未解決的問題

  1. Babel 和其他系統會根據在目標模組上是否找到 __esModule 屬性來改變它們的預設匯入行為,但 allowSyntheticDefaultImports 僅在目標模組的類型中找不到預設匯出時啟用後備行為。如果目標模組有 __esModule 旗標但沒有預設匯出,這就會產生不一致。轉譯器和打包器仍會將此類模組的預設匯入連結到其 exports.default,而這將會是 undefined,理想情況下這在 TypeScript 中會是一個錯誤,因為如果無法連結,真正的 ESM 匯入會導致錯誤。但使用 allowSyntheticDefaultImports,TypeScript 會認為此類匯入的預設匯入連結到整個 exports 物件,允許將命名匯出存取為其屬性。
  2. allowSyntheticDefaultImports 沒有改變命名空間匯入的類型,這造成了一個奇怪的不一致,因為這兩個都可以使用,而且會有相同的類型
    ts
    // @Filename: exportEqualsObject.d.ts
    declare const obj: object;
    export = obj;
    // @Filename: main.ts
    import objDefault from "./exportEqualsObject";
    import * as objNamespace from "./exportEqualsObject";
    // This should be true at runtime, but TypeScript gives an error:
    objNamespace.default === objDefault;
    // ^^^^^^^ Property 'default' does not exist on type 'typeof import("./exportEqualsObject")'.
  3. 最重要的是,allowSyntheticDefaultImports 沒有改變 tsc 發射的 JavaScript。因此,只要將程式碼提供給 Babel 或 Webpack 等其他工具,此旗標就能夠啟用更精確的檢查,但對於使用 tsc 發射 --module commonjs 並在 Node.js 中執行的使用者來說,這會造成真正的危險。如果他們遇到 import * 錯誤,啟用 allowSyntheticDefaultImports 看起來似乎可以修復它,但事實上它只會讓編譯時間錯誤靜音,同時發射會在 Node 中崩潰的程式碼。

TypeScript 在 2.7 中引入了 esModuleInterop 旗標,改進了匯入的類型檢查,以解決 TypeScript 分析與現有轉譯器和打包器所使用的互操作行為之間的剩餘不一致,並至關重要的是,採用了與轉譯器在數年前採用的相同 __esModule 條件式 CommonJS 發射。(另一個新的 import * 發射輔助程式確保結果始終為物件,並移除呼叫簽章,完全解決了上述「解析為非模組實體」錯誤並未完全迴避的規範相容性問題。)最後,啟用新旗標後,TypeScript 的類型檢查、TypeScript 的發射,以及轉譯和打包生態系統的其餘部分都同意使用規範合法的 CJS/ESM 互操作方案,而且可能由 Node 採用。

Node.js 中的互操作性

Node.js 在 v12 中出貨了對 ES 模組的支援,未標示旗標。就像打包器和轉譯器在數年前開始做的那樣,Node.js 為 CommonJS 模組提供了其 exports 物件的「合成預設匯出」,允許使用 ESM 的預設匯入存取整個模組內容

ts
// @Filename: export.cjs
module.exports = { hello: "world" };
// @Filename: import.mjs
import greeting from "./export.cjs";
greeting.hello; // "world"

這是無縫遷移的一項優點!不幸的是,相似之處大多到此為止。

未偵測到 __esModule(「雙重預設」問題)

Node.js 無法尊重 __esModule 標記來改變其預設匯入行為。因此,當由另一個轉譯模組「匯入」時,具有「預設匯出」的轉譯模組會以一種方式運作,而當由 Node.js 中的真正 ES 模組匯入時,則會以另一種方式運作

ts
// @Filename: node_modules/dependency/index.js
exports.__esModule = true;
exports.default = function doSomething() { /*...*/ }
// @Filename: transpile-vs-run-directly.{js/mjs}
import doSomething from "dependency";
// Works after transpilation, but not a function in Node.js ESM:
doSomething();
// Doesn't exist after trasnpilation, but works in Node.js ESM:
doSomething.default();

儘管轉譯的預設匯入僅在目標模組缺少 __esModule 旗標時才會產生合成預設匯出,但 Node.js 總是 會合成預設匯出,在轉譯模組上建立「雙重預設」

不可靠的命名匯出

除了讓 CommonJS 模組的 exports 物件可用作預設匯出外,Node.js 嘗試尋找 exports 的屬性以供作為命名匯出。此行為在運作時與套件管理程式和轉譯器相符;然而,Node.js 使用 語法分析在執行任何程式碼之前合成命名匯出,而轉譯模組則在執行階段解析其命名匯出。結果是,在轉譯模組中運作的 CJS 模組匯入可能無法在 Node.js 中運作

ts
// @Filename: named-exports.cjs
exports.hello = "world";
exports["worl" + "d"] = "hello";
// @Filename: transpile-vs-run-directly.{js/mjs}
import { hello, world } from "./named-exports.cjs";
// `hello` works, but `world` is missing in Node.js 💥
import mod from "./named-exports.cjs";
mod.world;
// Accessing properties from the default always works ✅

無法 require 真正的 ES 模組

真正的 CommonJS 模組可以 require 一個 ESM 轉譯成 CJS 的模組,因為它們在執行時期都是 CommonJS。但在 Node.js 中,如果 require 解析成一個 ES 模組,就會當機。這表示已發布的函式庫無法從轉譯的模組轉移到真正的 ESM,而不會中斷它們的 CommonJS(真正的或轉譯的)使用者

ts
// @Filename: node_modules/dependency/index.js
export function doSomething() { /* ... */ }
// @Filename: dependent.js
import { doSomething } from "dependency";
// ✅ Works if dependent and dependency are both transpiled
// ✅ Works if dependent and dependency are both true ESM
// ✅ Works if dependent is true ESM and dependency is transpiled
// 💥 Crashes if dependent is transpiled and dependency is true ESM

不同的模組解析演算法

Node.js 針對解析 ESM 匯入引進了一種新的模組解析演算法,與解析 require 呼叫的長期演算法有顯著的不同。儘管與 CJS 和 ES 模組之間的互通性無直接關聯,但這種差異是從轉譯模組順利移轉至真正的 ESM 可能無法實現的另一個原因

ts
// @Filename: add.js
export function add(a, b) {
return a + b;
}
// @Filename: math.js
export * from "./add";
// ^^^^^^^
// Works when transpiled to CJS,
// but would have to be "./add.js"
// in Node.js ESM.

結論

顯然,從轉譯模組順利移轉至 ESM 是不可能的,至少在 Node.js 中是如此。這讓我們何去何從?

設定正確的 module 編譯器選項至關重要

由於互通性規則因主機而異,因此 TypeScript 無法提供正確的檢查行為,除非它了解每個它看到的文件所代表的模組類型,以及要對它們套用哪一組規則。這就是 module 編譯器選項的目的。(特別是,預計在 Node.js 中執行的程式碼受限於比將由套件管理員處理的程式碼更嚴格的規則。除非將 module 設定為 node16nodenext,否則不會檢查編譯器的輸出是否與 Node.js 相容。)

使用 CommonJS 程式碼的應用程式應始終啟用 esModuleInterop

在 TypeScript 應用程式(相對於其他人可能使用的函式庫)中,如果使用 tsc 發出 JavaScript 檔案,則啟用 esModuleInterop 並沒有重大影響。您撰寫特定類型模組的匯入方式將會改變,但 TypeScript 的檢查和發出是同步的,因此無錯誤的程式碼應可在任一模式中安全執行。在此情況下,停用 esModuleInterop 的缺點是,它允許您撰寫語意明顯違反 ECMASCript 規範的 JavaScript 程式碼,混淆對命名空間匯入的直覺,並讓將來更難遷移到執行 ES 模組。

另一方面,在由第三方轉譯器或套件管理員處理的應用程式中,啟用 esModuleInterop 更加重要。所有主要的套件管理員和轉譯器都使用類似 esModuleInterop 的發出策略,因此 TypeScript 需要調整其檢查以進行比對。(編譯器始終推論 tsc 將發出的 JavaScript 檔案中會發生什麼事,因此即使使用其他工具取代 tsc,影響發出的編譯器選項仍應設定為盡可能與該工具的輸出相符。)

應避免在沒有 esModuleInterop 的情況下使用 allowSyntheticDefaultImports。它會變更編譯器的檢查行為,而不會變更 tsc 發出的程式碼,允許發出潛在不安全的 JavaScript。此外,它所引入的檢查變更是不完整版的 esModuleInterop 所引入的變更。即使 tsc 沒有用於發出,也最好啟用 esModuleInterop,而不是 allowSyntheticDefaultImports

有些人反對在啟用 esModuleInterop 時,將 __importDefault__importStar 輔助函式包含在 tsc 的 JavaScript 輸出中,原因可能是它會稍微增加磁碟上的輸出大小,或因為輔助函式所採用的互通演算法似乎會透過檢查 __esModule 來錯誤呈現 Node.js 的互通行為,導致前面討論的風險。這兩個反對意見都可以解決,至少部分解決,而無需接受在停用 esModuleInterop 時所表現出的有缺陷檢查行為。首先,可以使用 importHelpers 編譯器選項從 tslib 匯入輔助函式,而不是將它們內嵌到每個需要它們的檔案中。為了討論第二個反對意見,讓我們來看最後一個範例

ts
// @Filename: node_modules/transpiled-dependency/index.js
exports.__esModule = true;
exports.default = function doSomething() { /* ... */ };
exports.something = "something";
// @Filename: node_modules/true-cjs-dependency/index.js
module.exports = function doSomethingElse() { /* ... */ };
// @Filename: src/sayHello.ts
export default function sayHello() { /* ... */ }
export const hello = "hello";
// @Filename: src/main.ts
import doSomething from "transpiled-dependency";
import doSomethingElse from "true-cjs-dependency";
import sayHello from "./sayHello.js";

假設我們正在編譯 src 為 CommonJS 以供 Node.js 使用。在沒有 allowSyntheticDefaultImportsesModuleInterop 的情況下,從 "true-cjs-dependency" 匯入 doSomethingElse 會出錯,而其他則不會。若要在不變更任何編譯器選項的情況下修正錯誤,您可以將匯入變更為 import doSomethingElse = require("true-cjs-dependency")。不過,根據模組的類型(未顯示)撰寫方式,您也可能能夠撰寫並呼叫命名空間匯入,這將會違反語言層級規格。使用 esModuleInterop 時,顯示的匯入都不會出錯(且都可以呼叫),但會偵測到無效的命名空間匯入。

如果我們決定將 src 遷移至 Node.js 中的真實 ESM(例如,將 "type": "module" 新增至我們的根目錄 package.json),會產生什麼變化?第一個匯入,來自 "transpiled-dependency"doSomething,將無法再呼叫—它會出現「雙重預設」問題,我們必須呼叫 doSomething.default() 而不是 doSomething()。(TypeScript 會在 --module node16nodenext 下了解並捕捉到這一點。)但值得注意的是,第二個匯入的 doSomethingElse,在編譯為 CommonJS 時需要 esModuleInterop 才能運作,在真實 ESM 中運作良好。

如果這裡有什麼值得抱怨的,那不是 esModuleInterop 對第二個導入所做的。它所做的更改,允許默認導入和防止可呼叫命名空間導入,完全符合 Node.js 的真實 ESM/CJS 互操作策略,並使遷移到真實 ESM 變得更容易。如果存在問題,那就是 esModuleInterop 似乎無法為我們提供一個無縫的遷移路徑,用於第一個導入。但這個問題並非由啟用 esModuleInterop 引起;第一個導入完全不受其影響。不幸的是,這個問題無法在不破壞 main.tssayHello.ts 之間的語義合約的情況下解決,因為 sayHello.ts 的 CommonJS 輸出在結構上看起來與 transpiled-dependency/index.js 完全相同。如果 esModuleInterop 改變了轉譯後導入 doSomething 的工作方式,使其與在 Node.js ESM 中的工作方式相同,它將以相同的方式改變 sayHello 導入的行為,使輸入代碼違反 ESM 語義(從而仍然阻止 src 目錄在不進行更改的情況下遷移到 ESM)。

正如我們所看到的,從轉譯模組到真實 ESM 沒有無縫的遷移路徑。但 esModuleInterop 是朝著正確方向邁出的一步。對於那些仍然希望最小化模組語法轉換和包含導入輔助函數的人來說,啟用 verbatimModuleSyntax 是比停用 esModuleInterop 更好的選擇。verbatimModuleSyntax 強制在發射 CommonJS 的檔案中使用 import mod = require("mod")export = ns 語法,避免我們討論的所有類型的導入歧義,代價是容易遷移到真實 ESM。

函式庫程式碼需要特別考量

函式庫(附帶宣告檔案)應特別注意確保在各種編譯器選項下,所撰寫的型別皆無錯誤。例如,可以撰寫一個介面,以某種方式延伸另一個介面,使其僅在停用 strictNullChecks 時才能順利編譯。如果函式庫要發布此類型別,將會強迫所有使用者也停用 strictNullChecksesModuleInterop 可以讓型別宣告包含類似「會傳染」的預設匯入

ts
// @Filename: /node_modules/dependency/index.d.ts
import express from "express";
declare function doSomething(req: express.Request): any;
export = doSomething;

假設這個預設匯入在啟用 esModuleInterop 時才有效,且當沒有該選項的使用者參照這個檔案時,會造成錯誤。使用者可能還是應該啟用 esModuleInterop,但一般來說,函式庫讓其組態會傳染,被視為不佳的習慣。函式庫發布宣告檔案如下會好得多

ts
import express = require("express");
// ...

類似這樣的範例導致傳統觀念認為函式庫不應啟用 esModuleInterop。這個建議是一個合理的起點,但我們已經看過啟用 esModuleInterop 時,命名空間匯入的型別會變更,可能會引入錯誤的範例。因此,無論函式庫是否使用 esModuleInterop 編譯,都存在撰寫語法的風險,使其選擇會傳染。

想要更進一步確保最大相容性的函式庫作者,最好針對編譯器選項矩陣驗證其宣告檔案。但使用 verbatimModuleSyntax 會強制發出 CommonJS 的檔案使用 CommonJS 風格的匯入和匯出語法,進而完全避開 esModuleInterop 的問題。此外,由於 esModuleInterop 只會影響 CommonJS,隨著時間推移,越來越多函式庫轉移到僅發布 ESM,這個問題的相關性將會下降。

TypeScript 文件是一個開源專案。透過 傳送 Pull Request ❤ 來幫助我們改善這些頁面

此頁面的貢獻者
ABAndrew Branch (7)

最後更新:2024 年 3 月 21 日