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/