Skip to main content

CommonJS 與 ESM 的兼容問題

這是一個老題目了,幾乎所有寫 pacakge 的人都會遇到的問題。只能說 JS 的世界還是在混亂中前進。稍微整理一下確保自己能夠解釋清楚。

Node.js 支援兩種模組系統​

CommonJS​

使用 require() 和 module.exports,很長一段時間 NodeJS 都使用這個方式管理模組。

named exports​

// @filename: util.cjs
module.exports.sum = (x, y) => x + y;

// @filename: main.cjs
const { sum } = require("./util.cjs");
console.log(sum(2, 4));

default export​

// @filename: util.cjs
module.exports = (x, y) => x + y;

// @filename: main.cjs
const whateverWeWant = require("./util.cjs");
console.log(whateverWeWant(2, 4));

ES Modules​

使用 import 和 export,這是 ECMAScript 所訂的新標準。

named exports​

// @filename: util.mjs
export const sum = (x, y) => x + y;

// @filename: main.mjs
import { sum } from "./util.mjs";
console.log(sum(2, 4));

default export​

// @filename: util.mjs
export default (x, y) => x + y;

// @filename: main.mjs
import whateverWeWant from "./util.mjs";
console.log(whateverWeWant(2, 4));

互操作性問題​

這兩種模組系統「理論上」可以交互引用,但實際上當你想要用 CJS 呼叫 ESM,會有很多麻煩,以至於幾乎不可行。

  • CJS
    • 不能使用 import 語法,只能使用 require()
    • require 只能對 CJS module 使用,無法 require ES module
    • 可以使用 await import() 動態載入 ESM 模組,但是會帶來一些麻煩
  • ESM
    • 不再有exports, module.exports 可以使用
    • 可以再次引入 require(),但不會帶來更多好處,使用 require 還會造成Bundler (e.g. Webpack) 沒辦法分析相依性
    • 可以對 ESM/CJS 使用 import 來操作,但是
      • 可以對 CJS 使用 default import (e.g. import _ from lodash)
      • 對 CJS 使用named import 有時成功有時失敗 import {shuffle} from 'lodash' ,取決於靜態分析器能否分析出 CJS package 所寫的 exports.shuffle = xxxx

載入模式預設是 CJS,除非你的副檔名寫成 .mjs,或是 package.json 裡面有 "type": "module",才會使用 ESM。

為什麼有上述的限制?​

在 CommonJS 的世界,require() 是同步的。require 執行的時候,會直接去讀檔、接著直接執行相依的模組,會傳 module.exports 身上所設定的值。

但在 ESM 的世界,模組的載入是非同步的,而且會分成兩個階段。第一個階段是 parsing 階段,會靜態分析整個 script 中的 import 和 export,但並不會真的執行程式。檢查是不是所有的引用都是存在的。依據相依性建構出一個 ES module graph之後,接下來才會真的去非同步的載入相依模組,並執行程式。

ESM 改變太多東西了,在瀏覽器內要使用 ESM,你必須額外使用 <script type="module">。當你把 CJS 換成 ESM 時,你同時也破壞了相後相容性。(Deno 是另一種觀點,預設全部都是ESM)

CJS 無法 require() ES module​

為什麼 CJS 不能 require ES module? 一個直觀的解釋方式是,ES module 允許在模組的最外層使用 await,但 CJS 並不允許。這兩者的差異是其中一個導致你沒辦法「把 ESM 轉成 CJS 模組」的原因。

CJS 可以 import() ESM, 但不是個好主意​

如果你在 CJS 想要引入 ES module,你只能寫成如下的形式。

(async () => {
const { foo } = await import("./foo.mjs");
})();

非同步的行為是會感染的,一旦你引入了一個非同步的模組,如果你要 module.exports,那麼你就只能 export 一個 promise,這會導致所有使用你模組的使用者非常不方便。

ESM import CJS 時,named export 可能會失敗​

會成功的例子

// main.mjs
import { namedExport } from "./lib.cjs"; // (A)
console.log(namedExport); // "yes"
// lib.cjs
exports.namedExport = "yes";

會失敗的例子

// lib.cjs
module.exports = {
namedExport: "yes",
};

如果執行 main.mjs 會出現 SyntaxError: Named export 'namedExport' not found

一個簡單但有點醜陋的解法是分兩行寫:

import lib from "./lib.cjs";
const { namedExport } = lib;

有人提議說,那為什麼 ESM 不乾脆在載入 CJS 時就直接把整個 CJS 跑過一遍就好?這樣不就能夠在 CJS 執行完之後,就知道哪些 named export了嗎?但事情沒那麼簡單,看看下面的範例,假設 liquor 和 beer 都是 CJS,一直都是 liquor 先執行,beer 後執行,結果有一天 liquor 升級成 ESM,結果就變成 beer 先執行,liquor 才會在之後載入,萬一兩者有隱含的相依性,就直接炸裂。

import {liquor} from "liquor";
import {beer} from "beer";

設計同時支援 CJS 和 ESM 的套件的原則​

下面是一些簡單的步驟,讓你可以設計出對使用者較為友善的套件。

  1. 用 CJS 設計 library (這不代表你得用 CJS 的語法寫,你可以 transpile 成 CJS,但你沒辦法使用 top-level await)
  2. 如果你的 library 只提供一個 default export,那就結束了
  3. 如果你的 library 有提供一些額外的 named export,那你會需要準備一個 ESM 的墊片 1. 在專案內開一個 esm 的子目錄,放入wrapper.js,裡面加一個 package.json {"type": "module"} 2. 或是選擇專案內額外開一個 wrapper.mjs
// wrapper.js
import cjsModule from "../index.js";
export const foo = cjsModule.foo;
  1. 如果你的專案是全新的,那就在 package.json 加上 exports,這可以限制 module loader 只能從你列出的位置 import。但是如果你的專案已經 release 了,要留意這是很大的 breaking change。
"exports": {
"require": "./index.js",
"import": "./esm/wrapper.js"
}

Reference:​

  1. https://redfin.engineering/node-modules-at-war-why-commonjs-and-es-modules-cant-get-along-9617135eeca1
  2. https://nodejs.org/api/esm.html
  3. https://2ality.com/2022/10/commonjs-named-exports.html
  4. https://hacks.mozilla.org/2018/03/es-modules-a-cartoon-deep-dive/