Node.js 對需要 ESM 模組的穩定支持
多年來,Node.js 開發人員面臨著痛苦的分歧:CommonJS (CJS) 和 ES 模組 (ESM) 共存,但互通性有限。 CJS 程式碼可以非同步載入 import() ESM 模組,但 require()(CJS 核心的同步載入機制)根本無法載入 ESM。這迫使程式碼庫遷移成為要么全有要么全無的事情。
Node.js 22 和 23 改變了這種情況,穩定了 --experimental-require-module 標誌。從 Node 23 開始,同步 require() 可以載入 ES 模組,前提是 ESM 圖不使用頂層等待。
問題:CJS 與 ESM 互通性
根本问题是 CommonJS 同步加载模块(通过 require()),而 ES 模块 异步 解析和执行(顶级等待,返回 Promise 的静态导入语句)。 ESM 圖可以包含 CJS 無法處理的循環引用和即時綁定。
在 Node 22 之前,從 CJS 載入 ESM 的唯一方法是:
// CJS file
async function loadESM() {
const esmModule = await import('./esm-module.mjs');
return esmModule.default;
}
這迫使 CJS 程式碼庫要么將所有內容重寫為 ESM,要么維護尷尬的非同步包裝層。
解決方案:Node 22+ 中的 require(esm)
Node 22 引入了 --experimental-require-module 標誌,該標誌使 require() 能夠同步載入 ESM 模組,只要 ES 模組及其整個依賴關係圖沒有頂級等待。
Node 23 將其提升為 --require-module 標誌後面的穩定功能(注意:沒有「實驗性」),並計劃在 Node 24 中預設啟用它。
// CJS file — works in Node 22+ with flag
const lodashEs = require('lodash-es'); // ESM package!
const result = lodashEs.merge({ a: 1 }, { b: 2 });
console.log(result); // { a: 1, b: 2 }
它是如何工作的
當 require() 遇到 ESM 檔案時,會發生下列解析:
- 節點將模組標識為 ESM(透過
package.json"type": "module"或.mjs擴充) - 它檢查 ESM 圖是否包含頂級等待 - 如果是,它會拋出 ERR_REQUIRE_ASYNC_MODULE 錯誤
- 如果不存在頂級等待,它會同步解析並評估 ESM 圖
- 預設導出直接返回;可以透過傳回物件的屬性存取命名導出
// ESM module: esm-lib.mjs
export const greet = (name) => `Hello, ${name}!`;
export default class Logger {
log(msg) { console.log(msg); }
}
// CJS consumer
const lib = require('./esm-lib.mjs');
lib.greet('World'); // "Hello, World!"
new lib.default().log('hi'); // "hi"
命名導出需要 .default 存取權限才能進行預設匯出,但命名導出可直接使用。
支援 ESM 功能
| 特色 | 支援require(esm) | 筆記 |
|---|---|---|
| 命名導出 | 是的 | 直接財產訪問 |
| 預設匯出 | 是的 | 以 .default 傳回或直接用於單一匯出 |
| 即時綁定 | 沒有 | 需要捕獲值,而不是即時綁定 |
| 循環依賴 | 部分 | 適用於簡單的循環,但對於複雜的圖形可能會失敗 |
| 頂級等待 | 沒有 | 拋出 ERR_REQUIRE_ASYNC_MODULE |
| 動態導入() | 是的 | 在透過 require |
限制和錯誤處理
最重要的限制是頂級等待。任何在頂層使用 await 的 ESM 模組都無法透過 require() 載入。
// top-level-await.mjs
const data = await fetch('https://api.example.com/data').then(r => r.json());
export default data;
// CJS consumer
const data = require('./top-level-await.mjs');
// ERR_REQUIRE_ASYNC_MODULE: require() cannot be used on an ES module
// with top-level await
要處理這個問題,可以:
- 重構ESM模組以刪除頂級await
- 使用
await import()而非require() - 包裝在非同步包裝器中
// Workaround for top-level await ESM
async function load() {
return require('./top-level-await.mjs');
}
// Still fails — the issue is in the module itself, not how you call it
// Correct approach:
const data = await import('./top-level-await.mjs');
CJS 程式碼庫的遷移優勢
此功能極大地簡化了從 CJS 到 ESM 的增量遷移:
之前(節點 20):如果沒有 await import() 包裝器,則無法在 CJS 中使用僅 ESM 包,從而無法在 CJS 模組的頂層使用。
// Painful before — async wrapper required
const getExpress = () => import('express');
(Node 23+)之後:您可以直接 require() 大多數 ESM 包,從而實現逐步遷移:
// CJS file, Node 23+
const express = require('express'); // works if express ships ESM
const { nanoid } = require('nanoid'); // ESM-only package!
這意味著 CJS 程式碼庫可以採用僅 ESM 依賴項,而無需重寫其模組系統。 nanoid、chalk (v5+) 和 p-limit 等函式庫僅限 ESM,但現在可以從 CJS 存取。
生態系影響
require(esm) 的穩定性具有顯著的生態系效應:
- 包作者可以放棄雙重 CJS/ESM 構建並僅發布 ESM,因為知道 CJS 消費者仍然可以
require()他們的包 - CJS 程式碼庫 可以逐漸採用 ESM 依賴項,而無需「大爆炸」重寫
- 工具(Jest、ESLint、TypeScript)受益於簡化的模組解析
但是,套件作者應該意識到,匯出中的頂級等待將破壞 require() 相容性。
結論
Node.js 22/23 中 require(esm) 的穩定性彌補了 CommonJS 和 ES 模組之間長期存在的差距。 CJS 程式碼庫現在可以直接使用僅 ESM 的套件,從而消除了先前阻礙增量遷移的非同步包裝器稅。
對於維護大型 CJS 應用程式的團隊來說,這是一個遊戲規則改變者:立即採用現代 ESM 依賴項,逐個模組遷移程式碼庫,並充分利用 ESM 生態系,而不犧牲 require() 的同步簡單性。

