前言
探讨了 ECMAScript 模块(ESM)的现状和趋势,鼓励开发者和项目迁移至 ESM-only,并分析了工具链的准备情况、双格式(CJS/ESM)的问题,以及何时适合迁移至 ESM-only。今日前端早读课文章由 @Anthony Fu 分享,公号:@前端圈授权。
译文从这开始~~
三年前,我写过一篇关于 “在单个包中同时发布 ESM 和 CJS” 的文章,提倡使用 CJS/ESM 双格式以方便用户迁移,并试图充分利用两者的优势。那时,我并不完全赞同激进地只发布 ESM,因为我认为生态系统尚未准备好,尤其考虑到这种推动主要来自底层库。随着时间的推移,工具和生态系统不断发展,我的观点逐渐转变为越来越倾向于采用纯 ESM。
在单个包中同时发布 ESM 和 CJS:https://antfu.me/posts/publish-esm-and-cjs
到 2025 年,ESM 自 2015 年首次推出以来已经过去了十年。现代工具和库越来越多地采用 ESM 作为主要的模块格式。根据 WOOORM 的脚本 (script),2021 年 npm 上发布 ESM 的包占比为 7.8%,而到 2024 年底,这一比例已达到 25.8%。尽管很大一部分包仍在使用 CJS,但趋势明显表明向 ESM 的良好转变。
ESM 采用率随时间变化,由 npm-esm-vs-cjs 生成。最后更新于 2024-11-27
在这篇文章中,我想分享我对当前生态系统状态的看法,以及为什么我认为是时候转向纯 ESM 了。
工具已就绪
现代工具
随着 Vite 作为流行的现代前端构建工具的兴起,许多元框架如 Nuxt、SvelteKit、Astro、SolidStart、Remix、Storybook、Redwood 等等,现在都构建在 Vite 之上,将 ESM 视为一等公民。
作为补充,我们还有测试库 Vitest,它从一开始就为 ESM 设计,具有强大的模块模拟能力和高效的细粒度缓存支持。
像 tsx 和 jiti 这样的 CLI 工具为运行 TypeScript 和 ESM 代码提供了无缝体验,无需额外配置。这简化了开发过程,并减少了设置项目以使用 ESM 相关的开销。
其他工具,例如 ESLint,在最近的 v9.0 中引入了一个新的扁平配置系统,即使在 CJS 项目中,也能通过 eslint.config.mjs 实现原生 ESM 支持。
自上而下与自下而上
早在 2021 年,当 @SINDRESORHUS 首次开始将他的所有包迁移到纯 ESM 时,例如 find-up 和 execa,这是一个大胆的举动。我认为这种做法是自下而上的,因为这些包相对底层,许多它们的依赖项尚未准备好支持 ESM。我曾担心这会迫使这些依赖项停留在旧版本的包上,这可能导致生态系统碎片化。(截至今天,我实际上很欣赏这一举动为我们带来了许多高质量的 ESM 包,尽管过程并不十分顺利)。
ESM 或双格式包依赖 CJS 包要容易得多,反之则不然。就平滑采用而言,我认为自上而下的方法在推动生态系统向前发展方面更有效。有了来自上层框架和工具的支持,使用纯 ESM 包不再是一个重大障碍。ESM 采用方面剩下的挑战主要在于包作者需要迁移并以 ESM 格式发布他们的代码。
Requiring ESM in Node.js
由 @JOYEECHEUNG 发起的在 Node.js 中 require () ESM 模块的能力,标志着一个不可思议的里程碑。此功能允许包以纯 ESM 形式发布,同时仍可被 CJS 代码库以最小的修改使用。它有助于避免由动态 import () ESM 引入的异步感染(也称为 Red Functions),这在某些情况下可能很难(如果不是不可能)迁移和适应。
此功能最近已取消标记并向后移植到 Node.js v22(以及很快移植到 v20),这意味着它应该已经可供许多开发人员使用。考虑到自上而下或自下而上的比喻,此功能实际上使得也可以从中间开始 ESM 迁移,因为它允许像 ESM → CJS → ESM → CJS 这样的导入链无缝工作。
有关此功能进展和讨论的更多详细信息,请跟踪此 issue:https://github.com/nodejs/node/issues/52697。
双格式的麻烦
虽然双 CJS/ESM 包一直是一个非常有用的过渡机制,但它们也带来了一系列挑战。维护两种单独的格式可能很麻烦且容易出错,尤其是在处理复杂的代码库时。以下是维护双格式时出现的一些问题:
互操作问题
从根本上说,CJS 和 ESM 是不同的模块系统,具有不同的设计理念。尽管 Node.js 已经可以在 ESM 中导入 CJS 模块,在 CJS 中动态导入 ESM,甚至 require () ESM 模块,但仍然有许多棘手的情况可能导致互操作问题。
一个关键区别是 CJS 通常使用单个 module.exports 对象,而 ESM 支持默认导出和命名导出。当使用 ESM 编写代码并转译为 CJS 时,处理导出可能特别具有挑战性,尤其是当导出值是非对象时,例如函数或类。此外,为了使类型正确,我们还需要引入 .d.mts 和 .d.cts 声明文件的进一步复杂性。等等...
当我试图更深入地解释这个问题时,我发现我实际上希望你甚至根本不需要为这个问题烦恼。坦率地说,这太复杂和令人沮丧了。如果你只是包的用户,更不用说让包作者去担心这个问题了。这是我提倡整个生态系统过渡到 ESM 的原因之一,以抛开这些问题,让每个人都免受这种不必要的麻烦。
依赖解析
当一个包同时具有 CJS 和 ESM 格式时,依赖项的解析可能会变得复杂。例如,如果一个包依赖于另一个仅发布 ESM 的包,则使用者必须确保使用 ESM 版本。这可能导致版本冲突和依赖解析问题,尤其是在处理传递依赖时。
此外,对于设计为使用单例模式的包,这可能会引入同一包的多个副本并导致意外行为。
包大小
发布双格式实际上会使包大小加倍,因为需要包含 CJS 和 ESM 捆绑包。虽然对于单个包来说,几 KB 的额外开销可能看起来并不重要,但在具有数百个依赖项的项目中,开销会迅速增加,导致臭名昭著的 node_modules 膨胀。因此,包作者应该密切关注他们的包大小。迁移到纯 ESM 是一种优化它的方法,特别是如果该包对 CJS 没有强烈要求。
我们何时应该迁移到纯 ESM?
这篇文章无意贬低双格式发布的价值。相反,我想鼓励评估生态系统的当前状态以及过渡到纯 ESM 的潜在好处。
在决定是否迁移到纯 ESM 时,需要考虑几个因素:
New Packages
我强烈建议所有新包都以纯 ESM 形式发布,因为没有遗留依赖项需要考虑。新采用者可能已经在使用现代的、支持 ESM 的技术栈,因此采用纯 ESM 不会影响使用。此外,维护单个模块系统可以简化开发,减少维护开销,并确保您的包受益于未来的生态系统进步。
面向浏览器的包
如果一个包主要面向浏览器,那么发布纯 ESM 是完全合理的。在大多数情况下,浏览器包会经过打包器,其中 ESM 在静态分析和 tree-shaking 方面提供了显著优势。这会产生更小、更优化的捆绑包,这也会提高加载性能并减少最终用户的带宽消耗。
独立 CLI
对于独立的 CLI 工具,对于最终用户来说,它是 ESM 还是 CJS 并没有区别。但是,使用 ESM 将使您的依赖项也可以是 ESM,从而促进生态系统从自上而下的方法过渡到 ESM。
Node.js 支持
如果一个包的目标是常青的 Node.js 版本,那么现在是考虑纯 ESM 的好时机,尤其是最近对 require (ESM) 的支持。
了解你的消费者
如果一个包已经有特定的用户,那么了解依赖项的状态和要求至关重要。例如,对于需要 ESLint v9 的 ESLint 插件 / 工具,虽然 ESLint v9 的新配置系统即使在 CJS 项目中也原生支持 ESM,但它成为纯 ESM 并没有障碍。
当然,对于不同的项目,有不同的因素需要考虑。但总的来说,我认为生态系统已经准备好让更多的包迁移到纯 ESM,现在是评估过渡的好处和潜在挑战的好时机。
我们还有多远?
向 ESM 的过渡是一个渐进的过程,需要整个生态系统的协作和努力。我相信我们正朝着正确的方向前进。
为了提高 ESM 采用的透明度和可见性,我最近构建了一个名为 Node Modules Inspector 的可视化工具,用于分析您的包的依赖项。它提供了有关您的依赖项的 ESM 采用状态的见解,并有助于识别迁移到 ESM 时的潜在问题。
以下是该工具的一些屏幕截图,让您有一个快速的印象: