我以前更容易把 ESM 和 CommonJS 看成语法差异:importrequire,静态分析对运行时加载。读完 move-on-to-esm-only 后,我更愿意把它看成一个生态迁移问题:模块格式不是孤立的代码风格,而是运行时、工具链、包管理器、类型系统和消费者升级路径之间的协议。

ESM-only 变得合理,不是因为 CJS 突然错了

CommonJS 在 Node.js 生态里非常成功。它简单、同步、符合早期服务端 JavaScript 的直觉,也支撑了 npm 很长时间的爆发。但成功的系统都会留下惯性:当浏览器、bundler、TypeScript、Node.js 和现代框架都逐渐围绕 ESM 做静态分析、tree-shaking 与条件导出时,CJS 就从“默认模块系统”变成了“历史兼容层”。

所以我不会把 ESM-only 理解成对 CJS 的否定。更准确的说法是:当生态的默认执行路径已经转向 ESM,继续为每个包维护 dual CJS / ESM,就开始从迁移桥梁变成维护负债。

这类负债的麻烦在于它不总是显性失败。项目可以继续发包、继续安装、继续跑测试,但包作者会在导出语义、类型声明、构建配置、依赖解析和文档解释上不断支付额外成本。最后用户也会被迫理解本来不该由他们理解的互操作细节。

工具链成熟后,迁移方向会反过来

早期 ESM-only 的推动常常来自底层库作者。这个路径很痛,因为低层包一切换,所有依赖它的上层 CJS 项目都会被迫面对不兼容。生态会出现碎片化:有人锁旧版本,有人写动态 import() 包装,有人推迟升级。

更顺的路径是 top-down。Vite、Nuxt、SvelteKit、Astro、Vitest、tsx、jiti、ESLint flat config 这些高层工具已经把 ESM 当成自然输入后,应用和库的迁移心理会变得完全不同。开发者不是被一个底层依赖突然打断,而是在新项目、新框架和新配置里默认进入 ESM 世界。

这里的直觉可以迁移到很多生态变化上:真正稳定的迁移通常不是靠某一层单独用力,而是要让上层体验和下层能力同时接住中间层。

require(ESM) 的意义在于给迁移留下中间态

Node.js 对 require() ESM 模块的支持,让这件事出现了一个新的中间地带。过去 CJS 调 ESM 通常需要动态 import(),而动态导入是异步的,会把原本同步的加载路径染成异步。这对配置文件、插件加载、CLI 启动路径和许多库初始化逻辑都很别扭。

require(ESM) 不能消除所有边界,但它降低了最痛的一类迁移成本:CJS 消费者不一定要立刻完成全链路改造,ESM-only 包也不一定意味着旧项目完全不可用。再加上 export { Foo as 'module.exports' } 这种 CJS 兼容导出,包作者可以在保持 ESM 发布物的同时,给 CJS 调用方留一条更窄但可走的路。

我理解这类设计的价值,不在于“混用永远没问题”,而在于它让生态从非黑即白变成可渐进演化。

dual format 什么时候还值得

dual format 仍然有存在价值,尤其是当包的真实用户还大量停留在旧 Node.js、旧构建链、旧测试工具或同步 CJS 插件生态里时。兼容不是原罪,过早切换也确实会让用户锁死在旧版本。

但 dual format 不应该是默认惯性。每次继续维护双格式,都应该问几个问题:

  • 新项目是否还真的需要 CJS 入口;
  • 主要消费者是否已经在现代 Node.js 和 bundler 上;
  • 类型声明、条件导出和转译配置是否开始消耗明显维护精力;
  • ESM-only 是否会显著降低包体积和互操作解释成本;
  • 最低 Node.js 版本要求是否可以被消费者接受。

如果这些问题的答案都偏向 ESM,那么继续 dual format 可能只是在把生态迁移成本往后推。

对我的提醒

模块系统这件事最容易被讨论成“某种格式更先进”。但我更关心的是迁移纪律:什么时候保留兼容层,什么时候删除兼容层,什么时候把复杂度交给工具,什么时候必须由维护者明确承担。

ESM-only 的核心判断不是“我想用新语法”,而是“生态是否已经准备好让单一模块系统成为更低成本的默认值”。当工具链、运行时和消费者路径都已经接近这个状态时,继续追求全格式兼容反而会让简单系统变复杂。


来源:move-on-to-esm-only

相关页面:nodejs · anthony-fu · move-on-to-esm-only