ESM 與 CJS 的 Interop 來世今生

語言: CN / TW / HK
截圖自https://sokra.github.io/interop-test/

今天因為 esbuild 的一個 bug ,需要升級 esbuild 的版本,升級完後驚訝的發現 Babel 居然掛了,我只是升級了個小版本(0.14.1 -> 0.14.5),理應不該出現如此大的變動,後來追蹤了下 esbuild 的 changelog ,發現了 esbuild 在 0.14.4 引入了一個巨大的 breaking change (嚴謹如 esbuild 也沒嚴格遵循語義化版本,可見業務如果強依賴語義化版本是個多不靠譜的事情)。

esbuild 0.14.4 引入的 breaking ,正是 js 社群臭名昭著的一個問題,即 ESM 和 CJS 的 Interop(互操作性)問題,esbuild 的 changelog 寫了相當長的篇幅總結了這個問題( esbuild 的 changelog 是業界良心,總能學到新東西)。下面內容均來自 esbuild changelog 的翻譯。

在開發 ECMAScript 模組匯入/匯出語法時,CommonJS 模組格式(用於 Node.js )已經被廣泛使用。正因為如此,為了解決 ESM 和 CJS 的互動性問題,名為 default 的匯出名稱被賦予了特殊的語法。你可以不寫 import { default as foo } from 'bar' ,而只寫 import foo from 'bar'

這個想法的初衷是,當 ECMAScript 模組(又稱 ES 模組)被引入時,你可以使用新的匯入語法來匯入現有的 CommonJS 模組來實現相容性。由於 CommonJS 模組的匯出是動態的,而 ES 模組的匯出是靜態的,一般來說,在模組例項化的時候不可能確定一個 CommonJS 模組的匯出名稱,因為此時程式碼還沒有被執行。所以 module.exports 的值只能作為預設的匯出(因為無法確定其他 name ,只能約定一個 default 作為整體的匯出 name ),特殊的預設匯入語法讓你很容易訪問 module.exports(即 import foo from 'bar' 等價於 const foo = require('bar')

到這裡一切設計都很合乎情理,似乎這個設計也無懈可擊,然而這裡同時埋下了禍根,即這個互動性問題其實只需要支援個 import foo from 'bar' 這個 syntax sugar (語法糖)即可滿足,然而卻同時錯誤的支援了 export default 'xxx' 這個語法,為後續的互動性問題埋下了禍根。

然而(一切不幸的開始),ES 模組語法需要一段時間才能被 JavaScript 執行系統原生支援,而人們仍然希望在這期間開始使用 ES 模組語法。Babel 通過將 ES 編譯到 CJS 讓你現在就可以使用 ES 模組進行編碼。你可以將每個 ES 模組檔案轉化為一個行為相同的 CommonJS 模組檔案。

然而,這種轉換有一個問題: 如何準確的將import語法降級到 commonjs ,上述設計意味著 export default 0import foo from 'bar' 在轉換為 CommonJS 時行為將不再一致。程式碼 export default 0 變成了 module.exports.default = 0 ,程式碼 import foo from 'bar' 變成了 const foo = require('bar') (這裡是為了對齊上述的互動性行為)。這導致程式碼在降級到 cjs 前和降級到 cjs 後的行為是不一致的了。

降級前:

  • bar.js

export default 0
  • foo.js

import foo from 'bar' // foo結果應該為0
console.log('foo',foo);

降級後:

  • bar.js

module.exports.default = 0
  • foo.js

const foo = require('bar') // foo結果為{default:0}
console.log('foo',foo);

降級前後執行結果不一致,這是非常顯然的bug。

為了解決這個問題,Babel 在將 ES 模組轉換為 CommonJS 模組時,通過將屬性 __esModule 設定為true 標記這個模組是一個編譯後的 ES 模組。然後,在匯入 default 匯出時,它可以知道使用 module.exports.default 的值,而不是 module.exports 的值,以確保 CommonJS 模組的行為與原始 ES 模組的行為正確匹配。這一修正在整個生態系統中被廣泛採用,並進入了其他工具,如 TypeScript ,甚至 esbuild 。babel 修復後的結果如下:

  • bar.js

"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.default = 0;
  • foo.js

"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
var bar_1 = __importDefault(require("bar"));
consol.log('foo', bar_1.default); // 結果為0

// 計算過程如下
* require("bar")=> {default: 0,__esModule: true}
* bar_1 => __importDefault => ({default:0,_esModule}).__esModule ? ({default: __esModule}) : {default: {default:0,__esModule:true}} => {default: 0,__esModule:true}
* bar_1.default => ({default:0,__esModule:true}).default => 0

至此,前端社群的程式碼實際上可以認為跑在了一個虛擬的 Babel |Webpack 的 runtime上,這個 babel runtime 通過將ES編譯為CJS幫我們解決了ESM和CJS的互動性問題了,如果沒有後續Node的背刺,實際上已經是趨於穩定了。

然而(另一個不幸的事情,讓事情雪上加霜),當 Node.js 最終釋出他們的 ES 模組實現時,他們採用了原來的實現,即 default 匯出總是等於 module.exports ,這打破了與現有的 ES 模組生態系統的相容性(即和 Babel runtime 的相容性),這些模組已經被 Babel 交叉編譯成 CommonJS 模組。現在你必須根據你的程式碼是需要在 Node 環境中還是在 Babel 環境中執行,來新增或刪除一個額外的 .default 屬性,這就導致了更嚴重性的互操作性問題。此外,像 esbuild 這樣的 JavaScript 工具現在需要猜測你是想要 Node 風格還是 Babel 風格的預設匯入。工具沒有辦法肯定地知道某個檔案所期望的是哪一種,如果你的工具猜錯了,你的程式碼就會被破壞。

至此我們總結下,目前 ESM 和 CJS 的互動性問題,由三件不幸的事情組成, import xxx from 'bar' 本來應該是個處理互動性的語法糖,但是並沒有和其他的模組匯入 && 匯出進行區分(就不應該支援 export default ), Babel 錯誤的實現了 ESM 到 CJS 的降級方案,雖然後來修復了但是還是造成了一定問題,node 選擇了與 Babel runtime (前端社群)不相容的方案,導致市面上存在兩套 interop 的邏輯,並且彼此不相容,我們可以明顯的感知到node社群和前端社群存在很大的割裂性。

esbuild 的相容性修復

這個版本改變了 esbuild 圍繞預設匯出和 __esModule 標記的啟發式方法,以試圖改善與 Webpack 和 Node 的相容性(大部分的生態都是基於他倆),其行為變化如下:

舊的行為:

  • 如果匯入語句被用來載入一個CommonJS檔案,並且

    • module.exports 中存在 default 屬性,那麼 esbuild 將把預設匯出設定為 module.exports.default(像 Babel)。否則默認出口被設定為 module.exports(像Node)。

    • module.exports 是一個物件,

    • module.exports.__esModule 是 truthy ,並且

  • 如果一個 require 呼叫被用來載入一個 ES 模組檔案,返回的模組名稱空間物件的 __esModule 屬性被設定為 true 。這就像 ES 模組通過 Babel 相容的轉換被轉換為 CommonJS 一樣。

  • 當編寫純 ESM 程式碼時,esModule 標記可能會不一致地出現在模組名稱空間物件上(即 import * as )。具體來說,如果一個模組名稱空間物件被物化(materialized)了,那麼 esModule 標記就會出現,但如果它被優化掉了,那麼 __esModule 標記就會消失。
  • 不允許建立一個名為 esModule 的 ES 模組匯出。這避免了生成的程式碼與上述行為衝突導致程式碼 break ,同時也避免了 esModule 的重複定義問題。

新的行為:

  • 如果匯入語句被用來載入一個CommonJS檔案,並且

    • 檔名不是以 .mjs 或 .mts 結尾,package.json 檔案不包含 "type": "module",那麼 esbuild 將把預設匯出設定為 module.exports.default(像Babel一樣)。否則,默認出口將被設定為module.exports(像 Node )。

    • module.exports 是一個物件

    • module.exports.__esModule是真實的,並且

請注意,這意味著默認出口在以前沒有被定義的情況下現在可能是未定義的。這與 Webpack 的行為相匹配,所以希望它能更加相容。

還要注意,這意味著匯入行為現在取決於檔案的副檔名和 package.json 的內容。這也符合 Webpack 的行為,希望能提高相容性。

  • 如果一個 require 呼叫被用來載入一個 ES 模組檔案,返回的模組名稱空間物件的 __esModule屬性被設定為true。這就像ES模組已經通過Babel相容的轉換被轉換為CommonJS一樣。

  • 如果匯入語句或 import() 表示式被用來載入一個 ES 模組,esModule 標記現在不應該出現在模組名稱空間物件上。這釋放了 esModule 的匯出名稱,使其可以用於 ES 模組。

  • 現在允許在 ES 模組中使用 __esModule 作為一個正常的匯出名。這個屬性可以被其他 ES 模組訪問,但不能被使用 require 載入 ES 模組的程式碼訪問,他們將會始終看到這個屬性被設定為 true。

如果你對編譯這類問題問題感興趣,我們是 Web Infra - Cross Platform 團隊,主要專注在跨平臺(Android / iOS / IOT / Desktop / Webview 等)、端能力優化有關的基礎技術建設上,為了讓位元組跳動的業務開發者通過更低的成本和更快的速度來生產和交付高效能的業務程式,我們建設了 Lynx 高效能跨端框架、基於 Rust / Go 的新一代高效能前端編譯器、高效能 Web 解決方案。

點選閱讀原文了解更多。也可以直接給 [email protected] 傳送簡歷,一定要附上 cp 在郵件中哦。