跳到主要内容

require() ESM in Node.js

· 阅读需 28 分钟
Random Image
图片与正文无关
  • 原文链接:https://joyeecheung.github.io/blog/2024/03/18/require-esm-in-node-js
  • 机器翻译: Gemini 2.5 Pro Preview
  • 提示词: 翻译以下文档,并且结合你的前端开发专业知识补充相关的知识点说明,对文章内容进行结构化,以 Markdown 格式返回,文字风格是技术博客。
  • 翻译理由:这个特性是 Node.js 领域的一个长期痛点,虽然很多最佳实践是不建议 require esm 的,但是不建议和不能是两码事,这个进展还是非常值得期待的。顺便说一句,这个优秀的特性的贡献值是中国人。

大家好!最近,我在 Node.js 中实现了一个实验性的新功能:支持通过 require() 加载同步的 ES 模块 (ESM) (PR #51977)。这可以说是 Node.js 生态期待已久的一个特性。在那个 PR 的评论区,我留下了自己对于“为什么这个功能直到 2024 年才出现”的理解 (评论链接)。这篇博客文章将基于那条评论,做更深入的探讨。

请注意: 本文表达的观点仅代表我个人,反映了我作为一个长期旁观者对 Node.js ESM 发展的看法。我不代表任何特定团体或 Node.js 项目本身——Node.js 项目的官方立场需要通过协作者 (collaborators) 达成共识来形成,没有任何个体可以单独代表项目发言。

ERR_REQUIRE_ESM 带来的困扰

如果你是 Node.js 开发者,并且曾经被 ERR_REQUIRE_ESM 这个错误折磨过,那么你一定明白为什么让 require() 支持加载 ESM 是如此重要。但为了那些可能偶然看到这篇文章的非 Node.js 用户,我先简单解释一下 Node.js 中 ESM 的现状以及为什么我认为(以及许多其他人也认为)这个新功能是必要的。

CJS 与 ESM 的鸿沟

Node.js 最初的模块系统是 CommonJS (CJS),使用 require() 导入模块,module.exports 导出模块。后来,为了跟进 ECMAScript 标准,Node.js 引入了 ES Modules (ESM),使用 importexport 关键字。

然而,多年以来,Node.js 的实现存在一个不对称性:

  • 你可以在 ESM 文件中 import 一个 CJS 模块 (import cjs)。
  • 但你不能在 CJS 文件中 require() 一个 ESM 模块 (require(esm))。

尝试后者会直接抛出 ERR_REQUIRE_ESM 错误。

这种限制给开发者带来了巨大的麻烦,可以说是 Node.js 生态系统中浪费开发者时间的“罪魁祸首”之一。

包作者的困境与双模块包 (Dual Packages)

如果一个库的作者希望他们的包既能被 CJS 用户(使用 require)消费,也能被 ESM 用户(使用 import)消费,他们通常面临两种选择:

  1. 继续发布 CJS 格式: 这是最安全的方式,但无法利用 ESM 的一些特性(如静态分析、Tree Shaking 等)。
  2. 发布双模块包 (Dual Package): 同时提供 CJS 和 ESM 两种格式的版本。这需要精心配置 package.json 中的 exports 字段(使用条件导出 Conditional Exports)来告诉 Node.js 和打包工具在不同场景下加载哪个版本。虽然这是一种非常普遍的做法,但它本身也可能引入一些复杂的问题(例如,双重包危害 Dual Package Hazard,可能导致同一个包的不同版本实例被同时加载,引发状态不一致等问题)。

知识点补充 (Conditional Exports):

// package.json
{
"name": "my-dual-package",
"type": "module", // 默认 .js 文件是 ESM
"main": "./dist/index.cjs", // 旧版 Node.js 或未指定类型时的入口 (CJS)
"module": "./dist/index.js", // 打包工具(如 Webpack, Rollup)识别的 ESM 入口
"exports": {
".": {
"import": "./dist/index.js", // 当使用 import 时加载 ESM 版本
"require": "./dist/index.cjs" // 当使用 require 时加载 CJS 版本
}
}
// ...
}

这种配置让同一个包能适应不同的模块系统。

转译器用户的困惑

许多开发者使用 TypeScript、Babel 等转译器编写代码。他们可能在代码中完全使用 ESM 语法 (import/export)。然而,很多时候,这些转译器的默认配置(或者为了最大化兼容性)会将代码最终编译成 CJS 格式。

这意味着,开发者写的虽然是 ESM 风格的代码,但在 Node.js 中实际运行的可能是 CJS 代码。当这段 (伪装成 CJS 的) 代码尝试 require() 一个 真正 的第三方 ESM 包时,ERR_REQUIRE_ESM 错误就会出现。这会让开发者非常困惑,因为他们可能一直以为自己的代码就是以 ESM 方式运行的。

关于 ESM 同步性的迷思

很自然地,人们会问:为什么 require() 就不能直接支持加载 ESM 呢?

在很长一段时间里,Node.js 项目给出的答案(引用我提交 PR 前的官方文档)大致是这样的:

不支持使用 require 加载 ES 模块,因为 ES 模块具有异步执行特性 (asynchronous execution)。

这个说法在各种半官方的沟通中也反复出现,并且总是以一种非常肯定的语气。因此,即使我作为一名长期的 Node.js 贡献者,也一直相信这个说法——毕竟 ESM 和 Node.js 的用户模块加载器并非我所擅长的领域。对于不熟悉的组件,我自然会像其他人一样,相信官方文档的说法。

真相:ESM 只是 有条件地 异步

然而,这正是文档和其他沟通渠道存在误导的地方。或许他们描述的只是 Node.js 当时 的 ESM 实现行为,而非 ESM 标准本身的设计。

去年,当我在修复 Node.js vm API 的内存泄漏问题 (博客文章链接) 时,偶然浏览了 V8 引擎的源码 (链接)。我惊讶地发现,ESM 本身并非被设计成无条件异步的

根据 ECMAScript 规范 (链接),ESM 的执行 (evaluation) 阶段只有在模块依赖图中包含顶层 await (Top-Level await, TLA) 时,才是异步的 (链接)。

知识点补充 (Top-Level await - TLA):

TLA 允许你在模块的顶层作用域直接使用 await 关键字,而无需将其包裹在 async 函数中。

// data-fetcher.js (ESM)
const response = await fetch("/api/data"); // Top-level await
export const data = await response.json();

// main.js (ESM)
import { data } from "./data-fetcher.js"; // 这个 import 会等待 data-fetcher.js 中的 await 完成
console.log(data);

当一个模块依赖图中任何一个模块使用了 TLA,整个图的执行就变成了异步过程。如果没有任何 TLA,整个图的执行可以是同步完成的。

同步 require(esm) 的可能性

既然 ESM 只有在包含 TLA 时才是异步的,那么让 require() 至少支持加载不包含 TLA 的 ESM 模块图,似乎就变得非常自然了。

虽然某些库可能有使用 TLA 的合理理由(例如,在模块加载时进行异步初始化),但这可能并不是非常普遍的情况。事实上,在我后来测试我的 require(esm) PR 时,在 npm 上找了大约 30 个高影响力的纯 ESM 包,没有一个包含 TLA。

因此,仅仅支持在 require() 中加载同步的 ESM 模块,可能就已经足以解决生态系统中许多令人头疼的问题了。

但 ESM 标准这样设计已经很久了,肯定有其他人比我更早意识到这一点吧?是的,当然有。

2019 年的尝试:同步 require(esm) 的提出与搁浅

require() 支持同步 ESM 图的想法并非全新。我后来发现,这个想法在 2019 年的一个 PR (#30891) 中就已经被提出来了,那个 PR 尝试添加对 require() 加载 .mjs 文件(Node.js 中明确表示 ESM 的文件扩展名)的支持。

然而,那个 PR 试图通过在加载器中“空转”事件循环 (spin the event loop) 来处理 TLA,这种方式被认为是不安全的,最终导致 PR 被关闭。虽然当时有人提到了“只支持同步图,不空转事件循环”的想法 (评论链接),但那个 PR 似乎偏离了方向,最终没有朝着这个目标发展。

此后,至少在我能找到的公开 PR 中,再没有出现过实现同步 require(esm) 的尝试了(后来有一些其他的 require(esm) 尝试,但它们仍然基于“ESM 是无条件异步”的假设)。

标准已知,Node.js 未知?

在规范层面,基于语法的 ESM 同步执行理论基础早在 2019 年就已经确立 (TC39 TLA 提案 PR #61)。后来我与(非 Node.js 项目的)从事 ESM 相关工作的人交流时,发现这似乎是他们的共识。

看起来,随着时间的推移,Node.js 内部逐渐形成了一个迷思:“ESM 是异步的,CJS 是同步的,所以 CJS 不能加载 ESM”。与此同时,在标准组织(如 TC39 和 W3C)那边,ES 规范特别注意确保 ESM 只是有条件地异步,W3C 规范甚至利用这一点来确保 Service Workers 只允许同步的模块评估 (W3C ServiceWorker Issue #1407)。

如果规范中基于语法的同步性被更广泛地了解,那么在 2019 年之后很可能会有更多人尝试实现同步 require(esm),官方文档也不会再用那种“无条件异步”的口吻来描述 ESM 了。

那么,为什么这个相对重要的信息在 Node.js 内部(特别是那些不直接参与 ESM 实现的人中)却鲜为人知呢?

ESM 的“信息孤岛” (The ESM Silo)

知识点补充 (信息孤岛 - Information Silo):

指在一个组织内部,不同部门、团队或个体之间缺乏有效的信息沟通和共享,导致信息被限制在局部范围内,无法流通和整合的现象。这会阻碍协作、决策和创新。

在深入思考后,我认为 ESM 的同步性之所以没有更早地在 Node.js 中催生出同步 require(esm)文化原因可能大于技术原因。在 Node.js 内部,负责 ESM 实现、与标准组织沟通的人和不参与这些工作的人之间,似乎存在一个“信息孤岛”问题。

Node.js 的决策流程与 ESM 的特殊性

通常,Node.js 的大部分决策是通过 100 多名协作者 (collaborators) 达成共识来完成的。这些协作者是通过对项目的贡献获得提名并拥有提交权限的人。当协作者之间无法达成共识时,决策会提交给 Node.js 技术指导委员会 (TSC)。TSC 是由更活跃的 Node.js 协作者组成的小组,可以通过简单多数投票来做出决策。

然而,Node.js 中 ESM 的早期开发采用了不同的流程。ESM 的实现和决策被委托给了“模块团队 (modules group)”。这个团队不仅包括 Node.js 协作者,还包括社区成员(如包作者、标准组织参与者和其他利益相关者)。在 ESM 决策方面,Node.js TSC 大多只是对模块团队达成的共识进行例行批准 (rubber stamp)。

沟通壁垒与知识隔阂

由于议题本身的特性,模块团队的讨论往往非常激烈。虽然团队的构成旨在让决策更具包容性,但这种分离的设置和激烈的讨论,使得团队之外的协作者(包括 TSC 成员)很难跟上进度或参与其中。我自己在当时也确实尽量避开 Node.js 的 ESM 相关事务——看起来他们已经有足够多的意见了。

结果就是形成了信息孤岛。如果一场辩论从未升级到模块团队之外,它就可能成为 Node.js 内部研究 ESM 的那一小部分人,甚至只是参与了那场特定辩论的人才了解的“小众知识”。我认为关于同步 require(esm) 的辩论就是这种情况。至少据我记忆,关于同步 require(esm) 的辩论从未被提交给 TSC。参与辩论的人无法达成共识,这个问题就在了解它的人中间逐渐沉寂了,而其他人则开始假设这是不可能实现的。

贡献者的门槛

另一个导致同步 require(esm) 延迟的因素是,对 Node.js ESM 加载器的更改往往比对其他任何系统都更容易引发争论,这可能会让贡献者望而却步。我为 Node.js 贡献了 7 年,但在去年之前很少接触 ESM 加载器——去年我主要是在修复一些没有争议的 bug 或内存泄漏,而不是改变 ESM 在 Node.js 中“应该”如何工作,后者往往充满争议。

这可能也阻止了更多人去研究加载器并更早地实现 require(esm)。至少,当我在去年开始研究 require(esm) 时,我确实不想过早声张,以避免在取得任何技术进展之前就卷入不必要的辩论。

ESM 加载器的技术复杂性

虽然我认为同步 require(esm) 未能早日实现主要是文化原因,但一些较小的技术因素可能也起到了推波助澜的作用。

复杂且“有机生长”的代码库

发展至今,Node.js 的 ESM 加载器本身已经相当庞大和复杂。当我刚开始为 Node.js 做贡献时,我觉得 CJS 加载器就已经很难理解了——那还是在 Node.js 有 ESM 加载器之前。几年后,当我修复由 ESM 集成引起的 vm API 内存泄漏时 (之前的博客文章),我第一次不得不深入研究 Node.js 的 ESM 加载器。我惊讶地发现,ESM 加载器比 CJS 加载器还要复杂得多(代码量几乎是后者的 3 倍)。这可能是加载器多年来“有机生长”的结果,看起来确实需要一些清理工作。

JavaScript 实现核心的挑战

ESM 加载器主要用 JavaScript 实现。虽然我认为 JavaScript 在实现某些 API(如 Streams)时有其优势,但当你尝试用它来实现 JavaScript 运行时自身的核心部分(比如 ESM 加载器)时,这可能会适得其反。

  • 原型链污染防御: Node.js 内部的 JavaScript 代码为了防止原型链污染,使用了 JavaScript 内建对象的一个特殊副本,这大大降低了代码的可读性。
  • 循环依赖: 加载器被拆分到多个文件中,但文件之间的组织方式导致了普遍的循环依赖。
  • 临时死区 (TDZ) 防御: 部分代码需要主动防御 letconst 引入的临时死区 (TDZ),这使得代码更加难以阅读。
  • 不必要的异步: 这个相当复杂的 JavaScript 代码库也给加载过程带来了很多随机且不必要的异步性。
  • 对 V8 能力利用不足: ESM 的相当一部分支持是由 V8 提供的,这些能力通过 C++/JS 绑定暴露给 JavaScript 层。但看起来随着时间的推移,JavaScript 部分开始自我限制于 C++/JS 绑定所提供的功能,而没有充分利用 V8 本身提供的能力。

性能影响:同步反而更快?

这些 ESM 加载器的技术问题也带来了其他后果,例如性能方面。有人可能假设,能够始终异步加载(并且如果模块包含多个 import,还能并行加载)会让加载速度更快。

但当我用 require(esm) 测试那 30 多个纯 ESM npm 包,并与 import esm 进行比较时,前者(使用了专门的同步路径)实际上速度快了约 1.22 倍 (测试结果评论)。这表明当前 ESM 加载器中的(不必要的)异步开销可能拖慢了同步模块的加载速度。

重启同步 require(esm) 之路

去年年底,在我发现 ESM 的执行可以根据语法是同步的,并且只是 Node.js 在加载过程中强加了异步性之后,我和 @GeoffreyBooth 开始讨论重启同步 require(esm) 的工作。即使到今天,Node.js 的 ESM 对我来说仍然是一个令人生畏的话题,但是 ERR_REQUIRE_ESM 带来的痛苦实在太深了,似乎值得冒着卷入那些费力辩论的风险,来重新推动这件事。

话虽如此,如果这是一个非常难以实现的目标 (high-hanging fruit),我也不会去冒险——在我真正研究 ESM 加载器之前,我确实是这么认为的。当时有一个正在进行的努力,目标是“让 ESM 加载器成为 Node.js 中唯一的加载器”。考虑到前面提到的复杂性,估计需要相当长的时间来重构 ESM 加载器以支持这一目标。所以我宁愿把这项重构工作留给那些更熟悉 ESM 加载器的人。

发现更简单的路径

直到 2024 年 2 月底,当我在为 CJS 和 ESM 加载器开发一个类似 ccache 的缓存机制 (Issue #47472) 并再次深入研究它们时,我注意到似乎存在一种更简单的实现方式:

放弃“让 ESM 加载器成为唯一加载器”的想法(我对此本就持怀疑态度),转而为 CJS 加载器实现一些专门的同步例程来支持 require(esm)。它使用的现有 ESM 加载器代码越少,实现起来就越容易。

新的 PR 与更小的范围

这就催生了 PR #51977。这个 PR 与 2019 年的那个主要区别在于,它试图将 require(esm) 的范围保持得很小,并且只支持加载同步的 ESM

事实证明,这个想法在协作者/TSC 中根本没有争议,并且几乎没有遇到阻力就合并了(中间因为关于加载器钩子 (loader hooks) 的讨论稍微偏离了一下,但至少我们达成了一致:钩子支持可以留给后续工作,而 require(esm) 本身应该回到正轨)。

下一步计划是什么?

目前,这个功能仍然是实验性的,需要通过 --experimental-require-module 标志启用。在它正式发布之前,还有一些工作需要完成。

待办事项

  1. 处理边缘情况: 可能还有一些边缘情况需要处理。我在 PR 中尝试测试了很多边缘情况,但模块图的可能性实在太多,无法在一个 PR 中全部覆盖。初始版本已经足以加载我测试过的大多数高影响力的纯 ESM npm 包。我们可以随着用户反馈继续打磨它。

  2. 自定义加载器钩子 (Loader Hooks) 支持: 当前的加载器钩子也是无条件异步的。这通过使用 worker 来让主线程阻塞在加载器上,带来了巨大的开销。虽然这总比完全没有加载器钩子要好,但在性能和能力方面,这远不及 require() 的猴子补丁 (monkey-patching) 所能提供的。

    知识点补充 (Loader Hooks & Monkey Patching):

    • Loader Hooks: Node.js 提供的一种机制,允许开发者介入模块加载过程,例如实现自定义的模块解析逻辑(如路径别名)、转换代码(如实时编译 TypeScript)等。目前的钩子是异步的,运行在单独的 worker 线程中。
    • Monkey Patching require(): 在 CJS 时代,很多库(如 ts-node/register, babel-register, APM 工具等)通过直接修改 Node.js 内建的 require 函数或其内部机制来达到类似目的。这种方式虽然灵活强大,但非常脆弱(依赖 Node.js 内部实现)、可能相互冲突,且被认为是不安全的实践。

    同步加载器钩子的目标是提供一个官方、稳定且高性能的替代方案,取代脆弱的猴子补丁。

    我认为下一步应该是开发一种线程内 (in-thread) 且同步的加载器钩子变体,同时支持 require()import。这可能使得 require() 猴子补丁不再必要,并让更多用户能够从 CJS 迁移到 ESM。

  3. 模块类型检测: 目前 require(esm) 只支持被明确标记为 ESM 的模块——要么通过 .mjs 扩展名,要么在最近的 package.json 中为 .js 文件设置了 "type": "module"。这已经足以支持加载 npm 上的纯 ESM 包。 技术上可以实现当遇到没有 "type": "module" 标记但包含 ESM 语法的 .js 文件时,回退到 ESM 加载。但这通常是用户应该避免的做法——ESM 语法检测会带来开销。一旦你的项目中有足够多的 ESM 模块,你可能不希望 Node.js 浪费时间去猜测你的模块类型,而你本可以通过在 package.json 中添加一个明确的 "type": "module" 字段来节省这些成本。

  4. 入口点的顶层 await 支持: 支持在入口点使用 TLA 仍然是可能的。因为入口点的导出 (exports) 不会被其他模块使用,我们可以在这种情况下简单地回退到异步加载。这已经可以通过 --experimental-detect-module 标志实现,现在更多的是一个实现细节问题,即在 CJS 加载器内部(既然它现在支持加载 ESM 了)实现这种回退。

结语

这项实验性功能标志着 Node.js 在弥合 CJS 和 ESM 鸿沟方面迈出了重要一步。虽然还有后续工作要做,但它有望极大地改善开发者的体验,减少因 ERR_REQUIRE_ESM 而浪费的时间,并可能为更平滑地向 ESM 过渡铺平道路。

最后,感谢 Bloomberg 多年来对我工作的赞助,支持我解决了这个令人头疼的问题。