Bun

CommonJS 並未消失


Jarred Sumner · 2023 年 6 月 30 日

我們正在招募 C/C++ 和 Zig 工程師,共同打造 JavaScript 的未來!加入我們的團隊 →

有些人可能會驚訝地看到 Bun 的近期 版本 更新日誌 提及了對 CommonJS 的支援。畢竟,CommonJS 是一個傳統的模組系統,而 JavaScript 的未來是 ES Modules (ESM),對吧?作為一個「具有前瞻性」的「次世代」執行環境,為什麼 Bun 會花費這麼多精力來改進 CommonJS 支援呢?

因為 CommonJS 將會繼續存在,而且這沒關係!我們認為,更好的工具可以解決當今開發者在使用 CommonJS 和 ESM 互操作時遇到的體驗問題。

情況說明

您可能會想到,將您的應用程式拆分成多個檔案通常是理想的做法。當您這樣做時,您需要一種方法來引用其他檔案中的程式碼。

CommonJS 模組格式於 2009 年開發,並由 Node.js 推廣。檔案可以將屬性分配給一個名為 exports 的特殊變數。然後,其他檔案可以通過使用特殊的 require 函數「請求」檔案來引用 exports 物件中的屬性。

a.js
b.js
a.js
const b = require("./b.js");

b.sayHi(); // prints "hi"
b.js
exports.sayHi = () => {
  console.log("hi");
};

為了過於簡化其工作原理:當一個檔案被 require 時,該檔案會被執行,並且 exports 物件的屬性會提供給導入者。CommonJS 專為伺服器端 JavaScript 設計(實際上,它最初被命名為 ServerJS),在伺服器端 JavaScript 中,預期所有檔案都可以在本機檔案系統上使用。這就是 CommonJS 同步 的含義 — 您可以將 require() 概念化為「阻塞」操作,它讀取導入的檔案並執行它,然後將控制權交還給導入者。

ECMAScript 模組於 2015 年作為 ES6 的一部分引入。ES 模組使用 export 關鍵字宣告其導出項。import 關鍵字用於從其他檔案導入。與 exports/require 不同,importexport 語句都只能出現在檔案的頂層

a.js
b.js
a.js
import { sayHi } from "./b.js"

sayHi(); // prints "hi"
b.js
export const sayHi = () => {
  console.log("hi");
};

由於 ES 模組旨在在瀏覽器中工作,因此預期檔案是通過網路載入的。這就是 ES 模組 非同步 的含義。給定一個 ES 模組,瀏覽器可以在不執行檔案的情況下看到它導入和導出的內容。通常,整個模組圖將在執行任何程式碼之前解析完成(這可能涉及多個往返網路請求)。

支持 CommonJS 的理由

CommonJS 啟動更快

對於較大的應用程式,ES 模組速度較慢。與 require 不同,您要麼需要在使用語句時載入整個模組圖,要麼使用表達式等待每個導入。例如,如果您想延遲載入一個套件以在函數中使用,您的程式碼必須返回一個 Promise(這可能會引入額外的 microtick 和開銷)。

async function transpileEsm(code) {
  const { transform } = await import("@babel/core");
  // ... return must be a Promise
}

function transpileCjs(code) {
  const { transform } = require("@babel/core");
  // ... return is sync
}

ES Modules 的設計使其速度較慢。它們需要兩個步驟才能將導入綁定到導出。整個模組圖會被解析和分析,然後程式碼才會被評估。這被分成不同的步驟。這使得 ES Modules 中的「即時綁定」成為可能。

考慮這兩個簡單的檔案。

babel.cjs
babel.mjs
babel.cjs
require("@babel/core")
babel.mjs
import "@babel/core";

Babel 是一個由大量檔案組成的套件,因此比較這兩個檔案的執行時間是評估與模組解析相關的效能成本的合理方法。結果如下

使用 Bun,以 CommonJS 載入 Babel 比以 ES 模組載入快約 2.4 倍。

相差 85 毫秒。在無伺服器冷啟動的背景下,這是巨大的差異。使用 Node.js,差異為 1.8 倍(約 60 毫秒)。

增量載入

CommonJS 允許動態模組載入 — 您可以有條件地 require() 一個檔案,或者 require() 一個動態建構的路徑/規範,或者在函數體中 require()。在需要動態載入的情況下,例如外掛系統或根據使用者互動延遲載入特定組件,這種靈活性可能是有利的。

ES 模組提供了一個具有類似屬性的動態 import() 函數。在某種意義上,它的存在證明了 CommonJS 的動態方法具有實用性,並且受到開發人員的重視。

它已經存在

發佈到 npm 的數百萬個模組已經使用 CommonJS。其中許多模組同時滿足以下兩個條件:(a) 不再積極維護,以及 (b) 對現有專案至關重要。我們永遠不會達到所有套件都可以預期使用 ES 模組的程度。一個不支援 CommonJS 的執行環境或框架正在丟失大量的價值。

Bun 中的 CommonJS

從 Bun v0.6.5 開始,Bun 執行環境原生實作了 CommonJS。先前,Bun 將 CommonJS 檔案轉譯為特殊的「同步 ESM」格式。

從 ESM 導入 CommonJS

您可以從 ESM 模組 importrequire CommonJS 模組。

import { stuff } from "./my-commonjs.cjs";
import Stuff from "./my-commonjs.cjs";
const myStuff = require("./my-commonjs.cjs");

最近,Bun 也增加了對 __esModule 註解 的支援。

module.js
exports.__esModule = true;
exports.default = 5;
exports.foo = "foo";

這是一種事實上的機制,用於 CommonJS 模組指示(與 package.json 中的 "type": "module" 結合使用)exports.default 應被解釋為預設導出。當在 CommonJS 模組中設定 __esModule 時,預設 import (import a from "./a.js") 將導入 exports.default 屬性。如果沒有註解,預設導入將導入整個 exports 物件。

使用註解

// with __esModule: true
import mod, { foo } from "./module.js";
mod; // 5
foo; // "foo"

不使用註解

// without __esModule
import mod, { foo } from "./module.js";
mod; // { default: 5 }
mod.default; // 5
foo; // "foo"

這是 CommonJS 模組指示 exports.default 應被解釋為預設導出 的事實標準方法。

總結

CommonJS 已經存在並且將會繼續存在。不僅如此,它還有真實的理由存在。我們在 Bun 這裡熱愛 ES 模組,但務實主義也很重要。CommonJS 不是過去時代的遺物,Bun 今天將其視為一等公民。