Node.js require(esm) 转正:CommonJS 与 ESM 的同步加载之路 前两天群里有人发了一张 release notes 截图配了一句require(esm) 终于转正了CommonJS 和 ESM 这下算是和解了。当时不少老 Node 开发者都冒出来了因为这句话我们确实等了很久。从 2017 年 Node 8.5 第一次戴着--experimental-modules这顶帽子露脸到 2025 年这个特性被正式标记为 stableCommonJS 和 ESM 之间的那堵墙让太多人吃过苦头老项目想升级新版本依赖结果对方只发 ESMrequire 直接报ERR_REQUIRE_ESM想在 CJS 工具脚本里用一个小 ESM 模块只能写个 async 函数包一层await import()包作者想两边都兼容又要在 package.json 里折腾 dual exports。这篇文章我会把这几年绕不开的背景、require(esm) 的版本落地过程、背后的同步加载原理、实际用法以及我踩过的几个坑完整讲一遍也会尽量照顾到刚接触 Node.js 模块系统的新手。1. 八年拉锯战require 和 import 为什么一直没法直接见面1.1 CommonJS 与 ESM 的天然差异同步/异步这条鸿沟这两个模块系统打架根子在于它们的加载模型完全不同。CommonJS 的设计思路是文件读进来立刻执行返回 module.exports整个过程是同步的。你写const fs require(node:fs)这一行执行完fs 就已经可用了。这是 Node 从诞生那天起就定下的基调对服务端启动、对命令行工具来说同步加载非常自然。ESM 就不一样它是 JavaScript 语言标准里的模块机制。import声明是静态的模块之间会形成一个依赖图引擎必须先把整个图解析完确定所有模块的边界然后才开始执行。而且 ESM 还支持顶层 awaitTop-Level Await下面简称 TLA这直接意味着模块加载这个动作天然可能是异步的。生活里有个类比挺贴切CommonJS 像食堂打饭报个菜名师傅一勺就给你全程同步。ESM 更像点外卖你下单之后商家要备菜、骑手要配送你没法站在柜台前等着菜从窗口直接递出来。一个同步语义的 require想把一个外卖式的模块同步拿到手在 TLA 存在的情况下从机制上就不可能。所以这些年所有互操作问题的本质都绕不开这条同步/异步的鸿沟。1.2 从 --experimental-modules 到 require(esm)一段漫长的解禁史Node.js 对 ESM 的支持起步很早但过程真的是起大早赶晚集。Node 8.5 在 2017 年引入了--experimental-modules但这顶实验帽子一戴就是好几年期间各种模块格式识别、互操作规则都不稳定。Node 12 虽然改进了 ESM 的加载器Node 14 开始可以不带标志使用 ESM但互操作问题依旧CJS 里 require 一个 ESM 文件会直接报错ESM 里想用 CJS 也得专门借助createRequire。这就导致很长一段时间里CJS 项目想用 ESM-only 的库基本只有一条路把读取逻辑改写成异步用await import()去绕。真正让局面松动的是 Node.js 22 时代开始的 require(esm) 工作。刚开始这个能力藏在--experimental-require-module标志后面属于少数人尝鲜的玩法随后在 22.12 里默认打开再到 24 版本正式标记为 stable。整个过程大概用了一年多但对使用者来说这一步跨过去之后模块系统之间的体验才算是真正接上了。1.3 开发者最难受的三个互操作时刻如果只讲历史会有点空我具体说三个让团队里开发最头疼的场景你应该有共鸣。第一个是依赖升级场景。项目跑在 Node 16/18 上某个核心库发了新版本升完之后 require 直接崩跑到 GitHub 一看原来的 CJS 入口没了只剩 ESM。这时候你只能把调用处改成async function init() { const lib await import(new-lib); lib.doSomething(); }一个同步逻辑被硬生生拆成异步整个调用链全部要跟着调整代码里到处飘着await import()。第二个是工具脚本场景。写一个 CJS 的构建脚本、配置文件、CLI 辅助工具偏偏想引用的 helper 是用 ESM 写的。为了这个引用你不得不把整个脚本的启动流程改成 async或者用createRequire做一层别扭的桥接。脚本本身可能就几十行结果一大半代码都在处理模块系统兼容。第三个是包作者场景。为了让自己的包同时被 require 和 import 使用必须在 package.json 的 exports 里写两套条件{ exports: { .: { import: ./index.mjs, require: ./index.cjs } } }这还只是最基础的 dual package再遇上打包器各自的解析规则、不同版本的 Node 行为不一致维护成本就上去了。所以很多人听到 require(esm) 转正会这么激动不是没道理的。2. 从实验标志到转正require(esm) 走过的关键版本2.1 22.12.0默认启用但埋了一个隐藏 Bug2024 年 12 月Node.js 22.12.0 发布这是一个值得记住的版本。它把--experimental-require-module这个标志的默认值翻成了 true同时引入了模块语法检测module syntax detection。除了这些官方文档也明确说明这个特性虽然还是 experimental但不再强制要求加标志。也就是说从你升级到 22.12 开始不需要加任何参数就能 require 一个.mjs文件了。结果大量用户第一时间去试撞上一个隐蔽的 bug。问题出在模块对象的初始化顺序上22.12.0 里通过 require 加载 ESM 时命名空间对象没有完整设置Symbol.toStringTag等内部属性导致一部分依赖类型判断的代码行为异常少数场景还会拿不到预期的默认导出。这个 bug 的触发条件比较微妙普通项目可能感知不明显但只要你的代码里做了类似Object.prototype.toString.call(require(...))的判断或者依赖某些工具库对模块对象的 introspection就很容易踩中。2.2 22.13.0 的集中修复与 23.x 的稳定表现Node.js 官方处理得也算快2025 年 1 月发布的 22.13.0 集中修复了 22.12 里暴露出来的这批问题之后 23.x 也始终保持着默认启用的状态。虽然在官方文档里这个特性依然标着 experimental但实际用下来稳定性已经明显上来了。这里要专门提醒一句如果你的生产环境还停留在 22.11 或更早别急着在项目里套用 require(esm) 的写法要升级就直接到 22.13把 22.12 里那批 bug 全部避开。我也见过有团队在 22.12 上大批量改代码结果被 bug 干扰最后排查了半天还以为是自己的问题很浪费时间。2.3 24.x 正式标记为 Stable版本状态一览真正让这个特性转正的是 Node.js 24。2025 年 4 月 Node.js 24 发布5 月的 24.2.0 release notes 里require(esm) 的状态从 experimental 改成了 stable。24 本身会在 2025 年 10 月进入 LTS对绝大多数生产项目来说这等于宣告CJS 项目里 require 一个 ESM 模块不再是临时方案不再依赖任何实验标志就是一行普通且受支持的 require。我整理了一张版本状态表方便你对照自己的运行环境Node.js 版本require(esm) 状态关键说明v22.0.x实验需标志通过--experimental-require-module开启v22.12.0实验默认启用无需标志但存在模块对象初始化 bugv22.13.0实验默认启用修复 22.12 的 bug建议生产环境至少用此版本v23.x实验默认启用继续稳定推进v24.2.0Stable正式转正后续 LTS 版本均支持3. 它到底怎么做到同步加载 ESM的模块语法检测与 Amaro3.1 模块语法检测你说你是 ESMNode 先检查你的语法require(esm) 能轻松工作的第一块基石是官方文档里反复提到的模块语法检测module syntax detection。Node.js 加载一个文件时以前判断模块类型主要看两样东西扩展名和 package.json 的 type 字段。.mjs一定是 ESM.cjs一定是 CJS.js则看 type 是 module 还是 commonjs默认是 commonjs。问题来了一个.js文件没有 type 字段默认按 CJS 解析但文件里偏偏写了import/export这时候怎么办以前直接报语法错误。现在 Node 会先用一个快速解析器把这个文件扫一遍看它是不是真的包含 ESM 语法有 import/export 语句就当 ESM 加载没有就按 CJS。这就是 module syntax detection 在做的事。这个机制的意义非常大。它让大量正在从 CJS 往 ESM 迁移但还没改 package.json的项目可以平滑过渡你先在代码里写 ESM 语法文件系统里还是.jsNode 照样能识别。不需要一上来就大规模改文件名、改 type 字段迁移成本一下子降下来了。3.2 Amaro 快速解析器为什么要折腾一个 WASM 解析器这个快速解析器就是 Amaro。它是 Node.js 团队专门为这个场景做的代码在官方的 nodejs/amaro 仓库里。很多人第一次看到这个名字会问为什么不直接用现成的解析器babel/parser 不是现成的吗核心原因在于开销。完整的 JavaScript 解析器通常会生成 AST抽象语法树但对判断一个文件里有没有 import 声明这件事来说生成完整 AST 的成本太高了。Amaro 的做法是把 Babel 解析器编译成 WebAssembly只保留语法层面的检测能力不做完整的语法树构建。这样Node 在加载一个存疑的.js文件时可以用一个相对轻量的解析过程完成 ESM 语法识别。当然任何额外的解析都有成本所以 Node 并不会对每个文件都做语法检测。只有在文件类型存在歧义的时候才走这个流程扩展名和 type 字段已经把模块类型定死的情况下不会多此一举。这也是为什么纯.mjs或.cjs文件的加载路径几乎没有额外开销。3.3 require 一个 ESM 时内部到底发生了什么接下来是核心问题require 明明是同步的ESM 的模块图到底怎么同步加载答案的关键就在 TLA。ESM 之所以在传统认知里必须异步主要是因为它支持顶层 await。反过来说如果一个 ESM 模块的整棵依赖图里没有任何 TLA那么当模块图解析完成后它的执行过程其实是完全同步的所有 import 进来的模块都已经就位不需要等待任何异步资源。Node 抓住了这一点加载时先检查这个模块的依赖图里是否存在 TLA没有就把它当作一个普通执行过程同步完成有 TLArequire 无法违背同步语义就直接抛出ERR_REQUIRE_ASYNC_MODULE。这个设计相当精妙。它没有试图让 require 待异步完成而是利用 ESM 在无 TLA 场景下天然可同步执行这个事实把同步 require 和 ESM 模块图在语义上对齐了。这也是为什么require(esm) 支持范围和模块里有没有 TLA这么强相关。后面我讲实操时会专门演示这个限制的绕过方法。4. 上手实操哪些能直接 require哪些必须绕路4.1 最基础的用法几个可以直接跑通的例子先上最简单的例子。假设你有一个 ESM 文件// math.mjs export const add (a, b) a b; export function mul(a, b) { return a * b; } export default function sub(a, b) { return a - b; }然后在 CJS 文件里直接 require// app.cjs const math require(./math.mjs); console.log(math.add(1, 2)); // 3 console.log(math.mul(3, 4)); // 12 console.log(math.default(8, 2)); // 6只要你的 Node.js 在 22.13 或 23/24 版本这段代码不需要任何参数、不需要额外依赖直接node app.cjs就能跑。以前这种写法会触发ERR_REQUIRE_ESM现在这一行require(./math.mjs)和 require 一个普通 CJS 模块几乎没有差别。同样如果你的 node_modules 里某个包把入口文件设成了 ESM比如main: ./index.mjs并且 exports 条件允许 require 命中那么在 CJS 项目里也能直接require(那个包)。这里我先卖个关子exports条件本身有个大坑我放到下一节专门讲因为光这一条就足以让很多人的require(esm) 初体验翻车。4.2 顶层 awaitTLA一票否决绕行方案怎么写最核心的限制就是 TLA。只要目标 ESM 模块或者它依赖的某个模块里有顶层 awaitrequire 就会抛出ERR_REQUIRE_ASYNC_MODULE。举个例子// config.mjs const config await loadRemoteConfig(); export const mode config.mode;// consumer.cjs const { mode } require(./config.mjs); // 直接抛错绕行方案有两个。第一个是把 TLA 移到函数里让模块本身保持同步可加载// config.mjs export function loadConfig() { return loadRemoteConfig(); }// consumer.cjs async function main() { const { loadConfig } require(./config.mjs); const config await loadConfig(); console.log(config.mode); } main();注意这里require(./config.mjs)拿到的是一个普通的导出函数模块本身没有 TLA所以可以同步加载。真正异步的部分被放到了函数调用层。第二个方案更简单既然本来就准备 await那就直接用动态 importasync function main() { const { mode } await import(./config.mjs); console.log(mode); }如果你的 ESM 依赖链里确实存在无法避免的顶层 await我的建议是保留动态 import把 require(esm) 当作无 TLA 模块的优化路径。千万不要为了统一写法而强行绕过 TLA把业务逻辑改成各种奇怪的 boot 顺序收益不抵成本。4.3 返回值的形状你拿到的不是 module.exports是命名空间这是我最想强调的一点也是实操中大家最容易迷糊的地方。require 一个 ESM 模块返回的并不是那个模块的 module.exports而是一个 ESM 命名空间对象。它和 CJS 的 module.exports 语义完全不同。还是用刚才的 math.mjs 为例const mod require(./math.mjs); console.log(mod); // [Module: null prototype] { // add: [Function: add], // mul: [Function: mul], // default: [Function: sub] // }看到没有即使这个模块有一个export default function sub通过 require 拿到之后它依然躺在mod.default里而不是直接替代整个导出值。这一点和很多人的直觉相反在 CJS 里module.exports function之后require 拿到的就是函数本身但在 require(esm) 语义里ESM 的默认导出永远都被当作命名空间对象上的default键。所以对接 ESM 库时如果对方主要靠默认导出你在 CJS 侧必须写.default才能拿到真正的实例。这也是为什么包作者设计 API 时不能只扔一个 default 出去完事——命名导出才是两种模块系统之间最稳的桥梁。例如一个未来只发 ESM 版本的框架如果只提供export default class App那么 CJS 老用户的调用代码就会从new App()变成new (require(framework).default)()体验相当割裂。4.4 性能和 source map实测中需要注意的两个点性能方面Node.js 团队在多个 issue 里都提过require(esm) 目前无法像 CJS 那样充分利用 V8 的代码缓存首次加载 ESM 依赖会比预期慢一些。再加上模块语法检测会对处于模糊地带的.js文件多做一次解析如果你的服务对启动时间特别敏感或者跑在 serverless 冷启动环境下升级之后要记得对比一下启动耗时。我实际测过一个小工具原来用await import()启动耗时大约 180ms改成 require(esm) 后首次调用多了十几毫秒的解析开销整体还在可接受范围但如果是几十上百个 ESM 文件的依赖树这个开销会被放大所以不建议大项目无脑全面替换。source map 的体验也值得留意。如果你加载的是一个由 TypeScript 编译出来的 ESM 文件想拿到准确的行号记得开--enable-source-maps。我本地就遇到过不开启这个开关时错误堆栈指向编译产物行的情况调试起来比 CJS 难受不少。严格来说这不算 require(esm) 的锅ESM 的 source map 支持本来就更依赖显式开关但因为在 CJS 里很少需要关心这一点切换后就格外扎眼。5. 迁移避坑指南我的三个真实翻车现场5.1 只写了 import 条件的 ESM-only 包照样 require 不动第一个坑也是我最有感触的一个。我一度以为 require(esm) 转正之后那些ESM-only包全都好使了结果不是。很多包为了保持 API 清晰package.json 的 exports 里只写了 import 条件像这样{ name: some-esm-only-pkg, exports: { .: { import: ./index.js } } }这种情况下即使你的 Node.js 已经是 24require(some-esm-only-pkg)依然会报ERR_PACKAGE_PATH_NOT_EXPORTED。原因是 require 的解析算法只会在条件集里找 require 能命中的条件没有 require 条件它也不会回退去用 import 条件。Node 支持 require(esm) 和 每个 ESM 包都允许被 require 是两码事。要解决只能等包作者在 exports 里补上 require 条件或者你自己用createRequire做一层桥接。当时排查这个问题花了我不少时间因为报错信息看起来像是路径写错谁会想到是 exports 条件集的锅。5.2 双实例问题同一个 CJS 模块被加载了两份第二个坑是模块重复实例化。ESM 模块图里如果 import 了一个 CJS 模块同时外部 CJS 又对这个 CJS 模块执行 require在混用场景下有可能拿到两份不同的实例。这个问题的根子在模块差异化module differentiation同一个文件在被 ESM 引用和被 require 直接加载时走的是不完全一样的包装路径缓存键不完全一致结果就是两边各存一份。我在一个网关项目里踩到过有一个全局的状态管理器原本整个进程都是 CJS单例行为一直正常。后来我引入了一个 ESM 写的插件插件里 import 了这个状态管理器而主进程的业务代码还在用 require 加载它。结果插件里写入的状态主进程这边完全读不到排查了一下午打印模块地址才发现加载了两份。虽然日常大多数项目不会立刻踩到但只要你的架构里有庞大老 CJS 库被 ESM 依赖树引用同时又有大量 CJS 代码直接 require 它单例和事件订阅这类逻辑就可能出问题。建议在做迁移时先用模块图工具梳理一下依赖关系尽量保证同一个模块在运行时只有一种加载路径。5.3 什么时候建议继续用 await import而不是无脑 require最后聊一下选型。require(esm) 转正确实是大利好但我不建议把所有await import()机械地替换成 require。有三个场景我会继续保留动态 import。一是依赖链里存在 TLA。这个没什么好说的require 语义上不支持硬要绕反而难看。二是代码本来就跑在 async 函数里await import()写起来和上下文一致没必要非改成同步 require改动还可能引入新的错误处理问题。三是启动性能敏感的场景动态 import 走的是正式 ESM 异步加载路径某些情况下比 require(esm) 对缓存更友好冷启动指标更稳。反过来工具脚本、配置文件加载、非 TLA 的 ESM-only 依赖这些场景用 require(esm) 是真香。尤其是配置文件以前做 CLI 工具配置想用 ESM 写得先 async 包装再去 import现在直接const config require(./my.config.mjs);整个逻辑立刻清爽了。现在回头看CommonJS 和 ESM 这场拉锯战终于有了一个比较体面的结局。新项目直接全 ESM 没有悬念老项目也不用焦虑把 Node 升到 22.13能上 24 更好然后先把手边那些非 TLA 的 ESM 依赖从await import()改成普通 require跑顺一批再动下一批。这个特性最舒服的地方在于它不需要你推翻什么只是在原有代码里把绕路去掉。如果你也正准备做这个迁移建议先从一个小工具脚本试起感受一下这个变化再决定要不要全面铺开。