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() 的同步简单性。

