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