Node.js 双格式 CJS/ESM 构建的终结
Node.js 现已支持 require(esm),让 ESM-only 成为许多库的默认选择。了解何时放弃 CJS、避免 top-level await 并安全迁移。
自 2026 年 6 月起,所有受支持的 Node.js 版本均可通过 require() 加载 ES 模块。这一变化消除了大多数库发布双格式 CommonJS/ESM 构建的唯一理由——对于越来越多的软件包而言,仅发布 ESM 格式现已成为默认的正确选择。困扰开发者长达十年的非对称性问题——CommonJS 既无法 import 也无法 require ESM 世界中的任何内容——在所有仍应作为目标的运行时上已不复存在。双格式 exports 映射、tsup/unbuild 的并行输出、.d.cts/.d.ts 声明文件的繁琐管理:这些机制的存在都是为了解决一个 Node.js 核心层面已经解决的问题。
本文提出了那些老旧双构建指南无法给出的 2026 年论据:非对称性消亡的确切版本时间线、require(esm) 的实际工作机制及其唯一的硬性限制,以及判断是否仍需要 CommonJS 构建的决策框架。约束并未消失——它只是转移了。新的兼容性契约不再是”发布两种格式”,而是”保持同步加载路径中不含顶层 await”。
核心要点
- 自 Node.js 25.4.0(2026 年 1 月 19 日发布)起,
require(esm)被标记为稳定版,同一变更已回移至各活跃 LTS 版本线——这意味着所有当前受支持的 Node.js 版本均具备require()ES 模块的能力。 require(esm)最初在 Node 22 中以--experimental-require-module标志引入,在 Node 23 中移除了实验性标志,随后于 v22.12.0(2024 年 12 月 3 日)和 v20.19.0 回移至 LTS 版本,并于 2025 年底被声明为稳定版。require(esm)有且仅有一个硬性限制:它无法加载模块图中包含顶层 await 的 ES 模块,此时会抛出ERR_REQUIRE_ASYNC_MODULE,并提示使用import()代替。- 对于仅发布 ESM 格式的作者而言,在可被
require()访问的模块图中任意位置添加第一个顶层await,对所有 CommonJS 消费者来说都是一个破坏性变更——应将其视为 semver 主版本号变更。 - 如果你的软件包目标为 Node 22.12+ 且在 CJS 用户会
require()的代码中避免使用顶层 await,那么仅发布 ESM 格式现已成为默认的正确选择;仅在需要支持 pre-20.19 运行时或含有顶层 await 的模块时,才保留 CJS 构建。
双格式 CJS/ESM 构建存在的原因
双格式构建的存在,根本原因在于 CommonJS 无法 require() ES 模块。两种模块系统的加载方式截然不同:require() 是同步的,调用完成时立即返回 module.exports;而 ESM 则被视为无条件异步的。同步调用方无法等待异步加载完成,因此 require('some-esm-package') 会抛出 ERR_REQUIRE_ESM。反向方向始终可行——ESM 可以 import CommonJS——这造成了库作者长期面临的不对称局面:为现代消费者发布 ESM,为仍在使用 require() 的用户发布 CommonJS,并通过条件式 exports 将两者串联起来。
这意味着真实的工具链开销。tsup 和 unbuild 等打包工具需要同时输出两种格式;package.json 中的 exports 映射将 import 路由到 .mjs 入口,将 require 路由到 .cjs 入口;TypeScript 需要同时提供 .d.ts 和 .d.cts 声明文件,以确保两种解析模式均能通过类型检查。Anthony Fu 的 2021 年双构建指南和 Mayank 的 2023 年详解对这套机制有深入记录——两篇文章在”如何操作”层面至今仍然准确,只是它们回答的问题,对于当前运行时而言已无需再问。
双格式构建还带来了一个结构性风险:双包危险(dual-package hazard)。当依赖图在一处通过 import 加载你的包,在另一处通过 require 加载时,Node.js 可能将 ESM 构建和 CJS 构建作为两个独立的模块实例分别加载。任何单例、缓存、注册表或 instanceof 检查都会面对两个状态分叉的实例。这种为解决互操作问题而生的双格式构建,悄然制造了一个状态重复问题。
require(esm):非对称性消亡的确切版本
Discover how at OpenReplay.com.
这一修复源于对一个长期错误假设的纠正。正如 Node 核心贡献者 Joyee Cheung 所记录的,ESM 本身并非被设计为无条件异步——而是被设计为仅在模块图包含顶层 await 时才是条件性异步的,因此 require() 至少支持不含顶层 await 的 ESM 模块图,这在逻辑上是自然的。这一洞见使得同步 require() 加载(大多数)ES 模块成为可能,require(esm) 正是基于此构建的。
该功能在各版本线上分阶段推出。以下是截至 2026 年 6 月的时间线:
| Node.js 版本线 | require(esm) 状态 | 支持阶段(2026 年 6 月) |
|---|---|---|
| 18.x | 从未获得回移 | EOL——必须迁移至 20+ |
| 20.x | 在 v20.19.0 移除实验性标志 | EOL(2026 年 4 月 30 日) |
| 22.x | 在 v22.12.0(2024 年 12 月 3 日)默认启用 | 维护 LTS |
| 23.x | 移除实验性标志(非 LTS) | EOL |
| 24.x | 稳定版标记在 v24.15.0(2026 年 4 月 15 日)回移 | 活跃 LTS |
| 25.x | 在 v25.4.0(2026 年 1 月 19 日)标记为稳定版 | EOL(2026 年 6 月 1 日) |
| 26.x | 稳定版 | Current |
重点摘要:在 v25.4.0 发布说明中,“module: mark require(esm) as stable”(PR #60959)这一变更移除了实验性标记,同一提交已回移至 LTS 版本线的 v24.15.0。该功能在被标记为稳定版之前早已默认启用:Node 22.12.0 是首个默认开启该功能的 LTS 版本,并在 v20.19.0 回移至 Node 20。Node 18 从未获得此回移。
根据 Node.js 发布计划,2026 年 6 月受支持的版本线为 22(维护 LTS)、24(活跃 LTS,主动支持至 2026 年 10 月 20 日,安全维护至 2028 年 4 月 30 日)和 26(Current)。三者均高于移除实验性标志的版本阈值。由于 Node 18 从未获得回移,Node 20 已于 2026 年 4 月 30 日到达生命周期终点,任何受支持项目应当目标的最低版本已包含 require(esm)。
require(esm) 对库作者意味着什么
在当前 Node.js 版本上,CommonJS 消费者现在可以直接 require() 一个仅发布 ESM 格式的包。发布 CJS 构建的最初理由——require() 调用方否则将被拒之门外——在所有受支持的运行时上已不再成立。正如 Node.js 文档所描述的,如果被加载的 ES 模块满足相关要求,require() 可以加载它并返回模块命名空间对象;在这种情况下,其行为类似于动态 import(),但以同步方式运行并直接返回命名空间对象。
这也使双包危险成为历史。由于 CommonJS 调用方现在加载的是真正的 ES 模块,而非并行的 CJS 副本,因此只存在一个模块实例、一个单例、一个缓存——当只有一种构建时,那个曾经为精心设计双格式构建提供理由的状态分叉问题根本不会出现。
当你移除 CJS 包装层时,有一个互操作细节值得注意。require(esm) 返回的是命名空间对象,而非裸值,因此默认导出会落在 .default 属性上,而不是直接作为返回值,这与 import() 的返回结果类似。如果你希望获得 CommonJS 风格的单一返回值,ES 模块可以使用字符串名称 "module.exports" 导出所需值,以自定义 require(esm) 的直接返回内容。
当你需要降级路径时,可以通过检查 process.features.require_module 是否为 true 来在运行时检测支持情况。
// 运行时特性检测——在 Node 20.19+、22.12+ 以及所有 24/26 版本上返回 true。
if (process.features.require_module) {
const lib = require("some-esm-only-package");
// 默认导出位于 .default 上
const fn = lib.default ?? lib;
}
唯一的限制:顶层 await 是新的兼容性契约
require(esm) 有且仅有一个硬性限制:它无法加载模块图中包含顶层 await 的 ES 模块。由于 require() 必须保持同步,一个在顶层 await 处暂停自身执行的 ESM 文件无法通过这种方式加载。如果被 require() 的模块包含顶层 await,或其导入的模块图中包含顶层 await,则会抛出 ERR_REQUIRE_ASYNC_MODULE,并提示用户改用 import() 加载异步模块。抛出的错误信息十分明确:“require() cannot be used on an ESM graph with top-level await. Use import() instead.”
关键词是模块图。这个限制不仅针对你直接 require() 的文件——而是针对该文件传递性导入的所有内容。
一个真实的、有据可查的事件展示了其影响范围。2026 年 4 月,lru-cache@11.3.0 在其 ESM 构建中引入了顶层 await,导致任何传递性加载 lru-cache ESM 构建的 CJS 模块发生故障,最典型的是 jsdom 通过 @asamuzakjp/css-color(纯 ESM,无 CJS 入口点)的依赖链。调用链为:jsdom(CJS)→ 纯 ESM 颜色包 → lru-cache 的(现已异步的)ESM 入口。exports 映射正确地将 require 路由到 CJS,将 import 路由到 ESM;但当 CJS 包 require 一个纯 ESM 包时,Node.js 解析了 ESM 模块图,而图中 lru-cache 的 ESM 入口点——现已包含 TLA——使整个模块图无法被同步 require()。维护者在后续补丁中回滚了顶层 await,故障已解决——但这证明了该失败模式确实会在生产环境中出现。同样的 ERR_REQUIRE_ASYNC_MODULE 级联错误在 Node 22.12.0 开启该功能时也波及了 Prettier 和 firebase-tools。
require(esm) 重构了整个问题:它消除了双格式构建的互操作理由,但使 TLA 无关性成为一项契约。对于仅发布 ESM 格式的作者而言,在可被 require() 访问的模块图中任意位置添加第一个顶层 await,对所有 CommonJS 消费者来说都是一个破坏性变更。正如 Evert Pot 所指出的,如果这是第一个 await,你可能会无意中破坏那些使用 require() 引入你的模块的 Node.js 用户——这意味着如果你遵循 semver,项目中或任何依赖中的第一个顶层 await 现在可能构成一个新的主版本号变更。请将其视为 semver 主版本号变更。
顶层 await 在库代码中确实罕见。当 Cheung 首次测试该实现时,测试的约 30 个高影响力纯 ESM 包中没有一个包含顶层 await——这正是同步 require(esm) 能够覆盖绝大多数真实包的原因。
2026 年还需要 CJS 构建吗?
对于大多数新包而言,答案是否定的。默认选择仅发布 ESM 格式,只有在特定约束迫使时才考虑双格式构建。围绕三个问题进行判断:
- 你的最低 Node.js 目标版本是什么? 如果是 Node 22.12+(随着 Node 20 已到达 EOL,理应如此),每个消费者都可以
require()你的 ESM 包。发布仅 ESM 格式即可。如果你确实必须支持仍在使用中的 pre-20.19 运行时,则仍需为其提供 CJS 构建。 - 你的可
require()访问模块图中是否使用了顶层 await? 如果是——无论是你自己的代码还是同步加载的依赖——CJS 消费者将遭遇ERR_REQUIRE_ASYNC_MODULE。要么移除 TLA(通常可用懒加载的import()替代顶层导入),要么保留 CJS 入口并在文档中说明不支持require()用户。 - 你是否掌控你的消费者? 使用固定当前 Node.js 版本的应用开发者可以自由选择仅 ESM 格式。拥有未知下游消费者的库作者仍应发布清晰的
exports映射,并将 TLA 视为版本事件处理。
如果上述因素均不迫使你采用第二种格式,双格式构建就是纯粹的负担:额外的工具链、更慢的 CI、更大的发布产物,以及毫无收益地重新引入双包危险。
迁移至仅 ESM 格式:检查清单
迁移至仅 ESM 格式,主要是对 package.json 的简化,加上规范的模块语法。步骤如下:
- 设置
"type": "module",使.js文件被解析为 ESM。 - 将
exports映射精简为单一 ESM 入口。双格式映射变为一行:
{
"type": "module",
"exports": "./dist/index.js",
"engines": { "node": ">=22.12.0" }
}
回顾性建议的 engines 值为 "^20.19.0 || >=22.12.0";由于 Node 20 已到达 EOL,单独使用 >=22.12.0 是合理的。
- 在相对导入中使用显式
.js扩展名——ESM 要求如此:使用import { x } from "./util.js",而非"./util"。 - 在
tsconfig.json中设置"moduleResolution": "NodeNext",使 TypeScript 正确输出和解析 ESM,包括强制要求的扩展名。 - 替换 CommonJS 全局变量。 ESM 中没有
__dirname、__filename或require。从import.meta重建它们:
import { fileURLToPath } from "node:url";
import { dirname } from "node:path";
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
- 在发布前审查顶层 await,涵盖你自己的代码和依赖。如果你计划日后使用 TLA,现在就规划好主版本号变更,而不是将其作为补丁版本发布。
对库作者的影响
曾经为双格式 CJS/ESM 构建提供理由的互操作壁垒,在所有值得支持的 Node.js 版本上已不复存在:require(esm) 自 v25.4.0 起被标记为稳定版,并在 22、24 和 26 版本线上均可使用。剩余的约束是具体且可命名的——保持顶层 await 不出现在 require() 调用方将遍历的路径中,并将第一个顶层 await 视为破坏性变更。对于面向当前 Node.js 版本的新包,发布仅 ESM 格式、精简 exports 映射,并在发布前审查模块图中的 TLA。
常见问题
在 Node.js 22 上可以 require 一个仅 ESM 格式的包吗?
可以。Node 22 从 v22.12.0(2024 年 12 月 3 日发布)起默认启用了 require(esm),因此运行在任何 22.12 或更高版本上的 CommonJS 文件可以直接 require() 一个仅 ESM 格式的包,前提是该包的模块图中不含顶层 await。该功能后来在 Node 25.4.0 中被标记为稳定版,并在 v24.15.0 回移至 24.x LTS 版本线,但自 v22.12.0 发布以来,它在 Node 22 上一直可以正常使用。
ERR_REQUIRE_ESM 和 ERR_REQUIRE_ASYNC_MODULE 有什么区别?
ERR_REQUIRE_ESM 是旧版错误,在 CommonJS 尝试 require() 任何 ES 模块时抛出,在受支持的 Node.js 版本上已不再出现,因为 require(esm) 已处理同步 ESM 加载。ERR_REQUIRE_ASYNC_MODULE 是更为精确的现代错误,仅在被 require 的 ESM 模块图包含顶层 await 时抛出,因为 require() 无法等待异步求值完成。其错误信息会指示你改用 import()。前者意味着 ESM 不受支持;后者意味着某个特定的 ESM 特性不被支持。
require(esm) 会直接返回默认导出吗?
不会。require(esm) 返回完整的模块命名空间对象,而非裸值,因此默认导出会落在 .default 属性上,而不是直接作为返回值,这与动态 import() 的行为一致。这与传统 CommonJS 模块不同,后者的 require() 直接返回 module.exports。如果你需要单一返回值,ES 模块可以使用字符串名称 'module.exports' 导出,以自定义 require(esm) 的返回内容。在将消费者从 CJS 包装层迁移时,始终检查 .default 属性。
如何在运行时检测 require(esm) 是否可用?
检查 process.features.require_module 是否为 true。这个布尔值由 Node.js 运行时设置,在所有支持 require ES 模块的版本上返回 true,包括 Node 20.19 及更高版本、22.12 及更高版本,以及所有 24 和 26 版本线。当你必须在同一代码库中同时支持新旧运行时时,可以用它来在同步 require() 和异步 import() 降级路径之间进行分支。
如果我的依赖使用了顶层 await,仅发布 ESM 格式是否安全?
对于 CommonJS 消费者而言,不安全。require(esm) 的限制适用于整个可 require() 访问的模块图,而不仅仅是你自己的文件,因此同步加载的依赖中任意位置的顶层 await 都会对 require() 的调用方抛出 ERR_REQUIRE_ASYNC_MODULE。2026 年有据可查的事件显示,lru-cache 在其 ESM 构建中添加了顶层 await,在维护者回滚之前,jsdom 因传递性依赖而受到波及。在转向仅 ESM 格式之前,请审查完整的依赖图,或者保留 CJS 入口并标注不支持 require() 用户。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k