時間回到 2015 年,你正在編寫一個 ESM 轉 CJS 的轉譯器。當時還沒有關於如何實現這一點的規範;你所擁有的僅僅是 ES 模組之間如何互動的規範、關於 CommonJS 模組之間如何互動的知識,以及一種摸索出解決方案的直覺。考慮一個匯出的 ES 模組:
tsexport const A = {};export const B = {};export default "Hello, world!";
你會如何將其轉變為一個 CommonJS 模組?回想一下,預設匯出(default exports)只是具有特殊語法的命名匯出,似乎只有一種選擇:
tsexports.A = {};exports.B = {};exports.default = "Hello, world!";
這是一種很好的類比,它讓你可以在匯入端實現類似的邏輯:
tsimport 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 世界中的一切一一對應。將上述等價性進一步擴充套件,我們可以看到我們也擁有:
tsimport * 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.jsmodule.exports = function hello() {console.log("Hello, world!");};
但現有的 CommonJS 模組經常採取這種形式。我們的轉譯器處理後的 ESM 匯入如何訪問此模組?我們剛剛確定了名稱空間匯入 (import *) 會轉譯為普通的 require 呼叫,因此我們可以支援如下輸入:
tsimport * as hello from "./exports-function";hello();// transpiles to:const hello = require("./exports-function");hello();
我們的輸出在執行時可以工作,但我們遇到了合規性問題:根據 JavaScript 規範,名稱空間匯入始終解析為模組名稱空間物件,即一個其成員為模組匯出的物件。在這種情況下,require 將返回函式 hello,但 import * 永遠無法返回函式。我們假設的對應關係似乎無效。
這裡有必要退後一步,澄清一下目標是什麼。ES2015 規範中剛引入模組時,支援將 ESM 向下轉譯(downleveling)為 CJS 的轉譯器就出現了,這讓使用者可以在執行時實現對新語法的支援之前,就能採用這種新語法。甚至有一種感覺,即編寫 ESM 程式碼是“面向未來”的新專案的良好方式。要使這一點成為現實,就需要從執行轉譯器的 CJS 輸出到一旦執行時開發出支援後原生執行 ESM 輸入之間,存在一條無縫的遷移路徑。目標是找到一種將 ESM 向下轉譯為 CJS 的方法,該方法允許在未來的執行時中,用其真實的 ESM 輸入替換所有這些轉譯後的輸出,且行為不會發生可觀察的變化。
透過遵循規範,轉譯器很容易找到一系列轉換,使它們轉譯後的 CommonJS 輸出的語義與它們所對應的 ESM 輸入的指定語義相匹配(箭頭表示匯入):
然而,CommonJS 模組(作為 CommonJS 編寫,而不是作為轉譯為 CommonJS 的 ESM 編寫)在 Node.js 生態系統中已經非常成熟,因此,編寫為 ESM 並轉譯為 CJS 的模組必然會開始“匯入”編寫為 CommonJS 的模組。然而,這種互操作性的行為並未由 ES2015 指定,也尚未在任何真正的執行時中存在。
即使轉譯器作者什麼都不做,行為也會從他們轉譯程式碼中發出的 require 呼叫與現有 CJS 模組中定義的 exports 之間的現有語義中產生。為了讓使用者在執行時支援後能夠從轉譯後的 ESM 無縫過渡到真正的 ESM,該行為必須與執行時選擇實現的行為相匹配。
猜測執行時會支援什麼樣的互操作行為,並不侷限於 ESM 匯入“真實的 CJS”模組。ESM 是否能夠識別從 CJS 轉譯而來的 ESM(並將其與 CJS 區分開),以及 CJS 是否能夠 require ES 模組,這些也都是未指定的。甚至 ESM 匯入是否會使用與 CJS require 呼叫相同的模組解析演算法也是不可知的。必須正確預測所有這些變數,才能為轉譯器使用者提供一條通往原生 ESM 的無縫遷移路徑。
allowSyntheticDefaultImports 和 esModuleInterop
讓我們回到規範合規性問題,即 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 不是名稱空間類物件的模組的名稱空間匯入發出錯誤來解決此問題:
tsimport * as hello from "./exports-function";// TS2497 ^^^^^^^^^^^^^^^^^^^^// External module '"./exports-function"' resolves to a non-module entity// and cannot be imported using this construct.
唯一的變通方法是讓使用者重新使用代表 CommonJS require 的舊版 TypeScript 匯入語法:
tsimport hello = require("./exports-function");
強制使用者恢復使用非 ESM 語法,本質上承認了“我們不知道像 "./exports-function" 這樣的 CJS 模組在未來是否能透過 ESM 匯入訪問,或者如何訪問,但我們知道它不能透過 import * 訪問,儘管它在我們正在使用的轉譯方案中在執行時可以工作。”這並沒有達到允許該檔案無需更改即可遷移到真正 ESM 的目標,但允許 import * 連結到一個函式也同樣不行。這就是 TypeScript 今天在停用 allowSyntheticDefaultImports 和 esModuleInterop 時的行為。
不幸的是,這稍微有點簡化了——TypeScript 並沒有完全透過這個錯誤規避合規性問題,因為它允許函式名稱空間匯入工作,並保留其呼叫簽名,只要函式宣告與名稱空間宣告合併即可——即使名稱空間是空的。所以雖然一個匯出裸函式的模組被識別為“非模組實體”:
tsdeclare function $(selector: string): any;export = $; // Cannot `import *` this 👍一個本來毫無意義的更改允許無效的匯入在沒有錯誤的情況下透過型別檢查。
tsdeclare namespace $ {}declare function $(selector: string): any;export = $; // Allowed to `import *` this and call it 😱
與此同時,其他轉譯器正在尋找解決同一問題的方法。思維過程大致如下:
- 要匯入匯出函式或原始值的 CJS 模組,我們顯然需要使用預設匯入。名稱空間匯入在這裡是非法的,而命名匯入在這裡沒有意義。
- 很可能,這意味著實現 ESM/CJS 互操作的執行時將選擇使 CJS 模組的預設匯入始終直接連結到整個
exports,而不是僅在exports是函式或原始值時才這樣做。 - 因此,真實 CJS 模組的預設匯入應該像
require呼叫一樣工作。但我們需要一種方法來區分真實 CJS 模組和我們轉譯後的 CJS 模組,這樣我們仍然可以將export default "hello"轉譯為exports.default = "hello",並使該模組的預設匯入連結到exports.default。基本上,我們自己轉譯的模組的預設匯入需要以一種方式工作(模擬 ESM 到 ESM 的匯入),而任何其他現有 CJS 模組的預設匯入需要以另一種方式工作(模擬我們認為 ESM 到 CJS 的匯入將會如何工作)。 - 當我們把 ES 模組轉譯為 CJS 時,讓我們在輸出中新增一個特殊的額外欄位:
當我們轉譯預設匯入時,我們可以檢查它:tsexports.A = {};exports.B = {};exports.default = "Hello, world!";// Extra special flag!exports.__esModule = true;ts// import hello from "./module";const _mod = require("./module");const hello = _mod.__esModule ? _mod.default : _mod;
__esModule 標誌首先出現在 Traceur 中,然後不久出現在 Babel、SystemJS 和 Webpack 中。TypeScript 在 1.8 版本中添加了 allowSyntheticDefaultImports,以允許型別檢查器將預設匯入直接連結到任何缺少 export default 宣告的模組型別的 exports(而不是 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 的抱怨,但這只是一個部分解決方案,留下了一些未解決的問題:
- Babel 等工具根據目標模組上是否找到
__esModule屬性來改變其預設匯入行為,但allowSyntheticDefaultImports僅在目標模組的型別中未找到預設匯出時才啟用回退行為。如果目標模組有__esModule標誌但沒有預設匯出,這會造成不一致。轉譯器和打包器仍然會將此類模組的預設匯入連結到其exports.default(這將是undefined),在 TypeScript 中這理想情況下應該是一個錯誤,因為如果真正的 ESM 匯入無法連結,它們會導致錯誤。但有了allowSyntheticDefaultImports,TypeScript 會認為此類匯入的預設匯入連結到整個exports物件,從而允許將命名匯出作為其屬性進行訪問。 allowSyntheticDefaultImports沒有改變名稱空間匯入的型別檢查方式,導致了一種奇怪的不一致:兩者都可以使用,並且具有相同的型別。ts// @Filename: exportEqualsObject.d.tsdeclare const obj: object;export = obj;// @Filename: main.tsimport 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")'.- 最重要的是,
allowSyntheticDefaultImports沒有改變tsc發出的 JavaScript。因此,雖然該標誌在程式碼被輸入到 Babel 或 Webpack 等其他工具時可以實現更準確的檢查,但對於那些使用tsc發出--module commonjs並在 Node.js 中執行的使用者來說,它帶來了真正的危險。如果他們在使用import *時遇到錯誤,看起來開啟allowSyntheticDefaultImports似乎可以解決它,但實際上它只是靜默了構建時的錯誤,同時發出了在 Node 中會崩潰的程式碼。
TypeScript 在 2.7 版本中引入了 esModuleInterop 標誌,它改進了匯入的型別檢查,以解決 TypeScript 的分析與現有轉譯器和打包器使用的互操作行為之間的剩餘不一致,最重要的是,採用了轉譯器幾年前採用的相同的 __esModule 條件 CJS 發出。(另一個用於 import * 的新發出輔助工具確保結果始終是一個物件,去除了呼叫簽名,完全解決了上述“解析為非模組實體”錯誤未能完全規避的規範合規性問題。)最終,隨著新標誌的啟用,TypeScript 的型別檢查、TypeScript 的發出以及其餘的轉譯和打包生態系統對於一種符合規範的、並且也許是 Node 可能採用的 CJS/ESM 互操作方案達成了一致。
Node.js 中的互操作
Node.js 在 v12 版本中釋出了對 ES 模組的非標記支援。就像打包器和轉譯器幾年前開始做的那樣,Node.js 為 CommonJS 模組提供了其 exports 物件的“合成預設匯出”,允許透過 ESM 的預設匯入訪問整個模組內容:
ts// @Filename: export.cjsmodule.exports = { hello: "world" };// @Filename: import.mjsimport greeting from "./export.cjs";greeting.hello; // "world"
這是無縫遷移的一大勝利!不幸的是,相似之處基本到此為止。
無 __esModule 檢測(“雙重預設”問題)
Node.js 無法透過 __esModule 標記來改變其預設匯入行為。因此,帶有“預設匯出”的轉譯模組在被另一個轉譯模組“匯入”時表現為一種方式,而在 Node.js 中被真正的 ES 模組匯入時表現為另一種方式。
ts// @Filename: node_modules/dependency/index.jsexports.__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 transpilation, 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.cjsexports.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 ✅
Node.js v22 之前無法 require 真正的 ES 模組
真正的 CommonJS 模組可以 require 一個轉譯為 CommonJS 的 ESM 模組,因為它們在執行時都是 CommonJS。但在早於 v22.12.0 的 Node.js 版本中,如果 require 解析為 ES 模組,它會崩潰。這意味著已釋出的庫無法從轉譯後的模組遷移到真正的 ESM,而不會破壞其 CommonJS(真實的或轉譯的)消費者。
ts// @Filename: node_modules/dependency/index.jsexport function doSomething() { /* ... */ }// @Filename: dependent.jsimport { 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.jsexport function add(a, b) {return a + b;}// @Filename: math.jsexport * 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 設定為 node16、node18 或 nodenext,否則不會檢查編譯器的輸出以確保 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 時 tsc 的 JavaScript 輸出中包含 __importDefault 和 __importStar 輔助函式,要麼是因為它在磁碟上略微增加了輸出大小,要麼是因為輔助函式採用的互操作演算法似乎透過檢查 __esModule 錯誤地代表了 Node.js 的互操作行為,從而導致了前面討論的危險。這兩個反對意見至少可以部分解決,而無需接受在停用 esModuleInterop 時表現出的有缺陷的檢查行為。首先,可以使用 importHelpers 編譯器選項從 tslib 匯入輔助函式,而不是將它們內聯到每個需要它們的檔案中。為了討論第二個反對意見,讓我們看一個最終示例:
ts// @Filename: node_modules/transpiled-dependency/index.jsexports.__esModule = true;exports.default = function doSomething() { /* ... */ };exports.something = "something";// @Filename: node_modules/true-cjs-dependency/index.jsmodule.exports = function doSomethingElse() { /* ... */ };// @Filename: src/sayHello.tsexport default function sayHello() { /* ... */ }export const hello = "hello";// @Filename: src/main.tsimport doSomething from "transpiled-dependency";import doSomethingElse from "true-cjs-dependency";import sayHello from "./sayHello.js";
假設我們正在將 src 編譯為 CommonJS 以在 Node.js 中使用。如果沒有 allowSyntheticDefaultImports 或 esModuleInterop,從 "true-cjs-dependency" 匯入 doSomethingElse 是一個錯誤,而其他匯入則不是。要在不更改任何編譯器選項的情況下修復該錯誤,你可以將匯入更改為 import doSomethingElse = require("true-cjs-dependency")。然而,根據模組型別(未顯示)的編寫方式,你可能還可以編寫和呼叫名稱空間匯入,這將違反語言級別的規範。有了 esModuleInterop,顯示的匯入都不會報錯(並且都是可呼叫的),但無效的名稱空間匯入會被捕獲。
如果我們決定將 src 遷移到 Node.js 中的真正 ESM(例如,在根目錄的 package.json 中新增 "type": "module"),會發生什麼變化?第一個匯入,來自 "transpiled-dependency" 的 doSomething,將不再可呼叫——它表現出“雙重預設”問題,我們需要呼叫 doSomething.default() 而不是 doSomething()。(TypeScript 在 --module node16 — nodenext 下理解並捕獲這一點。)但值得注意的是,第二個 doSomethingElse 的匯入,在編譯為 CommonJS 時需要 esModuleInterop 才能工作,在真正的 ESM 中工作得很好。
如果這裡有什麼可以抱怨的,那並不是 esModuleInterop 對第二個匯入所做的事情。它所做的更改,既允許預設匯入又防止可呼叫的名稱空間匯入,完全符合 Node.js 真正的 ESM/CJS 互操作策略,並使向真正 ESM 的遷移變得更容易。如果有問題的話,問題在於 esModuleInterop 似乎未能為第一個匯入提供無縫的遷移路徑。但這個問題並不是透過啟用 esModuleInterop 引入的;第一個匯入完全不受其影響。不幸的是,如果不破壞 main.ts 和 sayHello.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 的便利性降低。
庫程式碼需要特別注意
作為 CommonJS 分發的庫應該避免使用預設匯出,因為這些轉譯後的匯出在不同工具和執行時之間的訪問方式各不相同,並且其中一些方式對使用者來說看起來很混亂。由 tsc 轉譯為 CommonJS 的預設匯出在 Node.js 中可以作為預設匯入的 default 屬性訪問:
jsimport pkg from "pkg";pkg.default();
在大多數打包器或轉譯後的 ESM 中可以作為預設匯入本身訪問:
jsimport pkg from "pkg";pkg();
而在原生 CommonJS 中則可以作為 require 呼叫的 default 屬性訪問:
jsconst pkg = require("pkg");pkg.default();
如果使用者必須訪問預設匯入的 .default 屬性,他們會檢測到模組配置錯誤的“味道”,如果他們試圖編寫既能在 Node.js 中執行又能在打包器中執行的程式碼,他們可能會陷入困境。一些第三方 TypeScript 轉譯器暴露了改變預設匯出發出方式的選項以減輕這種差異,但它們不產生自己的宣告 (.d.ts) 檔案,因此這在執行時行為和型別檢查之間產生了不匹配,進一步使使用者困惑和沮喪。與其使用預設匯出,作為 CommonJS 分發的庫應該為具有單個主要匯出的模組使用 export =,或者為具有多個匯出的模組使用命名匯出。
diff- export default function doSomething() { /* ... */ }+ export = function doSomething() { /* ... */ }
庫(釋出宣告檔案的)還應格外小心,確保它們編寫的型別在廣泛的編譯器選項下是無錯誤的。例如,可以編寫一個擴充套件另一個介面的介面,使其僅在 strictNullChecks 停用時才能成功編譯。如果一個庫釋出了這樣的型別,它將強迫其所有使用者也停用 strictNullChecks。esModuleInterop 可以允許型別宣告包含類似的“傳染性”預設匯入。
ts// @Filename: /node_modules/dependency/index.d.tsimport express from "express";declare function doSomething(req: express.Request): any;export = doSomething;
假設這個預設匯入僅在啟用 esModuleInterop 時有效,並且在沒有該選項的使用者引用此檔案時導致錯誤。使用者可能應該無論如何都要啟用 esModuleInterop,但通常認為庫使其配置具有傳染性是不好的做法。庫釋出如下這樣的宣告檔案要好得多:
tsimport express = require("express");// ...
這樣的例子導致了一種傳統的觀點,即庫不應該啟用 esModuleInterop。這個建議是一個合理的起點,但我們已經看過示例,在啟用 esModuleInterop 時,名稱空間匯入的型別會發生變化,從而可能引入錯誤。因此,無論庫是在啟用還是停用 esModuleInterop 的情況下編譯,它們都冒著編寫會導致其選擇具有傳染性的語法的風險。
想要超越自我以確保最大相容性的庫作者,最好根據編譯器選項矩陣驗證其宣告檔案。但使用 verbatimModuleSyntax 可以透過強制發出 CommonJS 的檔案使用 CommonJS 樣式的匯入和匯出語法,從而完全避開 esModuleInterop 的問題。此外,由於 esModuleInterop 僅影響 CommonJS,隨著越來越多的庫隨著時間的推移轉向僅釋出 ESM,此問題的相關性將會下降。