Featured image of post Node.js 對需要 ESM 模組的穩定支持Featured image of post Node.js 對需要 ESM 模組的穩定支持

Node.js 對需要 ESM 模組的穩定支持

探索節點解析更改,支援透過 require() 載入 ES 模組,簡化程式碼庫遷移。

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 檔案時,會發生下列解析:

  1. 節點將模組標識為 ESM(透過 package.json "type": "module".mjs 擴充)
  2. 它檢查 ESM 圖是否包含頂級等待 - 如果是,它會拋出 ERR_REQUIRE_ASYNC_MODULE 錯誤
  3. 如果不存在頂級等待,它會同步解析並評估 ESM 圖
  4. 預設導出直接返回;可以透過傳回物件的屬性存取命名導出
// 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 依賴項,而無需重寫其模組系統。 nanoidchalk (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() 的同步簡單性。