译:require(esm) in Node.js

原文:https://joyeecheung.github.io/blog/2024/03/18/require-esm-in-node-js/
作者:Joyee Cheung
译者:ChatGPT 4 Turbo

最近,我在 Node.js 中为同步 ES 模块的 require() 引入了实验性支持,这是一个早就应该有的功能。在该拉取请求中,我评论了我对为什么这个功能在 2024 年之前没有实现的理解。这篇文章就是对那个评论的进一步拓展。

这篇文章中的观点只代表我自己,反映了我作为一个长期旁观者对 Node.js 中 ESM 发展的看法。我在这里不代表任何团体或项目 – Node.js 项目的代表只来自于合作者之间的共识。没有特定的个人可以单独代表整个项目发言。

ERR_REQUIRE_ESM 的烦恼

对那些一直在处理 ERR_REQUIRE_ESM 的人来说,支持在 require() 中加载 ESM 可能显而易见,但如果有非 Node.js 用户偶然看到这篇文章,我在这里解释一下 Node.js 中的 ESM 状况,以及为什么我(和其他许多人)认为那个拉取请求是必须的。

自从 ESM 在 Node.js 中发布以来,多年来,它可以 import cjs,但不能 require(esm)ERR_REQUIRE_ESM 的挫败感困扰了许多人,可能已经成为 Node.js 生态系统中浪费时间的主要来源。如果包作者想要确保 CJS 和 ESM 的用户都能使用他们的包,他们不得不继续以 CJS 形式发布模块,或者同时发布 CJS 和 ESM 版本,即作为双重模块(尽管这可能导致一些问题,但这已经是一种非常常见的做法),并在他们的 package.json 中提供条件导出信息。

与此同时,许多转译器(例如 TypeScript 编译器)仍然配置为生成 CJS 代码作为它们的最终输出(可能也是为了最大限度地促进使用)。这些转译器的用户用 ESM 语法编写代码,但他们可能不知道他们的代码最终是由 Node.js 作为 CJS 运行的。而当他们的代码使用了一个真正的 ESM 第三方模块,这个模块不能被 require() 调用时,他们会看到一个 ERR_REQUIRE_ESM 错误。这可能非常令人困惑,因为他们可能认为他们的代码是作为真正的 ESM 运行的。

ESM 的同步性

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

很长一段时间以来,Node.js 项目的回答是这样的(引用我提交拉取请求之前的文档):

使用 require 来加载 ES 模块是不支持的,因为 ES 模块具有异步执行性。

这也在几次半官方交流中提到过。并且总是以如此肯定的口吻谈论,所以那也是我相信的 – 尽管我是一个长期的 Node.js 贡献者,ESM 或 Node.js 中的用户模块加载器从来不是我的擅长领域。对于我自己不是很熟悉的组件,我会像其他人一样,只相信文档所说的。

但这是其中一种情况,文档和其他交流方式是误导性的 – 也许他们只是在谈论 Node.js 中的 ESM 发生了什么,而不是 ESM 本身被设计成什么样的。去年,当我在浏览 V8 代码修复内存泄露时,我偶然发现 ESM 本身实际上并不是被设计为无条件的异步。相反,它被设计为只有在图中包含顶级 await 时才是有条件的异步。

那么,对于 require() 来说,至少支持不包含顶级 await 的 ESM 图似乎是很自然的事情。虽然一些库可能有合理的理由使用顶级 await,但这可能不是那么常见的事情 – 实际上,当我稍后在 npm 注册表中对仅 ESM 的高影响包进行我的 require(esm) PR 测试时,我测试的大约 30 个包中没有一个包含顶级 await – 而在 require() 中支持同步模块可能已经足够让生态系统中许多头疼的问题消失。

但 ESM 已经被设计成这样很长时间了。肯定有其他人在我之前意识到这一点,对吧?嗯是的,当然有。

2019 年同步的 require(esm)

require() 中支持同步 ESM 图的想法绝不是新鲜事。我后来发现,这个想法已经在 2019 年通过一个尝试为 require() 添加对 .mjs 文件支持的 PR提出。这个 PR 本身试图通过在加载器中旋转事件循环来处理顶级 await(它这样做的方式是不安全的,这就是为什么它被关闭的原因)。虽然只支持同步图而不旋转循环的想法被提到了,但 PR 似乎已经偏离了轨道,最终没有朝这个方向发展。在此之后,至少我没有找到以 PR 的形式共享的同步 require(esm) 的尝试(有一些后来的 require(esm) 尝试,但他们仍然假设 ESM 是无条件异步的)。

理论基础对于基于语法的 ESM 的同步评估在规范方面已于 2019 年确立。当我后来与那些从事 ESM 相关工作的(非 Node.js)人员交谈时,这似乎是一个共识。时间推移,Node.js 中发展了一个关于“ESM 是异步的,CJS 是同步的,所以 CJS 无法加载 ESM”的神话,而与此同时,在标准机构中,ES 规范特别注意确保 ESM 只是有条件异步,且 W3C 规范使用它以确保服务工作线程只允许同步模块评估如果规格中基于语法的同步性被更广泛地知道,可能在 2019 年之后会有更多尝试,而且文档就不会像 ESM 是无条件异步一样谈论 ESM 了。

但为什么这对于那些没有在 Node.js 中工作于 ESM 的人来说相对不为人知呢?

ESM 隔离问题

在深入思考这个问题后,我认为 ESM 的同步性没有在 Node.js 中更早地导致一个同步的 require(esm) 的原因更多是文化上的,而非技术上的。似乎存在一个信息隔离问题,在那些从事 Node.js 中 ESM 实现/与标准机构沟通的人与那些不从事这些工作的人之间。

通常在 Node.js 中,大多数决策是基于 100 多个合作者(即通过基于对项目贡献的提名过程而获得地位的提交者)之间的共识做出的。当合作者之间无法达成共识时,决策转交给 Node.js TSC。Node.js TSC 是更活跃的 Node.js 合作者中的一个子集,它可以通过简单多数投票来做出决策,并采取获胜选项。

Node.js 中 ESM 的早期开发,然而,采用了不同的流程。Node.js 中 ESM 的实现和决策过程被委托给了模块小组,该小组不仅包括 Node.js 的协作者,还包括社区成员(例如,包作者、标准机构参与者和其他各种利益相关者)。对于 ESM 的决策,Node.js TSC 大多数时候充当了他们共识的橡皮图章。

由于话题的性质,模块小组中的讨论往往非常激烈。虽然该小组的组成意图是使决策过程更加包容,但分离的设置和所有激烈的讨论使得小组外的协作者(和 TSC 成员)更难跟上那里发生的事情或参与进来。我自己在那段时间绝对尝试避开 Node.js 中的 ESM——看起来他们那里已经有了足够多的意见。

结果,形成了孤岛。如果一个辩论从未从小组内升级出来,它可能就变成了在 Node.js 中从事 ESM 工作的那些人之间,或者甚至只是在那场具体辩论中的那些人之间的小众知识。我认为这就是对同步 require(esm) 辩论发生的事。至少据我记忆,关于同步 require(esm) 的辩论从未被提升到 TSC。参与辩论的人无法达成共识,它有点像在那些意识到它的人中逐渐消失,而其他人开始假设那是不可能的。

另一个导致同步 require(esm) 延迟的因素是,对 Node.js 中的 ESM 加载器所做的更改往往比任何其他系统更容易引发辩论,这可能会让贡献者退缩。我为 Node.js 贡献了 7 年,但我直到去年之前很少接触 ESM 加载器 – 而去年我只是在修复一些无争议的错误/内存泄漏,而不是更改 ESM 在 Node.js 中的工作方式,而这后者往往会引起争议。这可能也阻止了更多人参与加载器的开发,使得 require(esm) 的实现更早发生。至少当我去年开始研究 require(esm) 时,我绝对不想过于大声宣传,以避免在任何技术进展之前引起不必要的辩论。

ESM 加载器

虽然我说在 Node.js 中实现同步的 require(esm) 没有更早发生主要是出于文化原因,但一些较小的技术因素可能也导致了延迟。

ESM 加载器本身此时已经相当复杂。当我开始为 Node.js 贡献代码时,我发现 CJS 加载器非常难以理解 – 那还是在 Node.js 拥有 ESM 加载器之前的事情。几年后,当我恰巧在修复 vm API 中的一些内存泄漏时,这些内存泄漏是由 ESM 集成引起的(我在之前的博客文章中讨论过),我首次真正深入研究了 Node.js 中的 ESM 加载器。我惊讶于 ESM 加载器的复杂程度甚至超过了 CJS 加载器(几乎多 3 倍代码)。这可能来自于加载器多年的有机增长,似乎确实需要一些清理。

ESM 加载器主要用 JavaScript 实现 – 虽然我确实认为在实现某些 API(如流)时 JavaScript 具有其优势,但如果你试图用它来实现 JavaScript 本身的核心部分(如 ESM 加载器)在运行时中,这可能会对你不利。为了防止原型污染,Node.js 在其 JavaScript 代码中内部使用了一份特殊的 JavaScript 内置副本,这大大降低了可读性。加载器被分割成多个文件,导致到处都是循环依赖,而一些代码需要主动防御暂时性死区(TDZs),使得代码更难以阅读。相对复杂的 JavaScript 代码库还使得 ESM 加载器在加载过程中加入了很多随机、不必要的异步性。ESM 支持的相当一部分是由 V8 提供的,它只通过 C++/JS 绑定暴露给 JavaScript 层。随着时间的推移,JavaScript 部分似乎开始限制自己使用 C++/JS 绑定所提供的内容,而不是充分利用 V8 提供的功能。

ESM 加载器的这些技术问题也有其他后果 – 例如,在性能上。一些人可能会假设能够始终异步加载(如果模块包含多个 import,则并行加载)会使加载更快。但当我测试 require(esm) 与大约 30 个 ESM-only npm 包进行比较并与 import esm 相比时,前者(使用专门的同步程序)实际上大约快 1.22 倍

重启同步 require(esm)

大约在去年年底,当我发现 ESM 的评估可以基于语法同步进行,而只有 Node.js 将异步引入加载过程后,@GeoffreyBooth 和我开始讨论重启同步 require(esm) 的可能性。Node.js 中的 ESM 对我来说仍然是一个非常可怕的主题,即便到了今天也是如此,但是 ERR_REQUIRE_ESM 的痛苦太过剧烈,看起来冒着进入这些繁重辩论的风险重新推进这一议题似乎是值得的。

话虽如此,如果这是一个难以企及的目标,我也不会冒这个险 – 这就是我在真正深入了解 ESM 加载器之前的想法。那时,有一个正在进行的努力,目的是“让 ESM 加载器成为 Node.js 中唯一的加载器”,由于之前提到的复杂性,估计这可能需要相当长的时间来重构 ESM 加载器,使之能够支持这一点。所以我宁愿把它留给那些更熟悉 ESM 加载器的人来进行重构。

2024 年 2 月下旬,当我在为 CJS 和 ESM 加载器工作一个像 ccache 那样的东西并再次深入研究它们时,我注意到似乎有一个更简单的实现方式 – 只需放弃“让 ESM 加载器成为 Node.js 中唯一加载器”的想法(我已经对此表示怀疑),并为 CJS 加载器实现一些专用例程以支持同步 require(esm)。它使用的现有 ESM 加载器代码越少,实现起来就会越容易。

所以,这就导致了这个 PR。与 2019 年的 PR 最大的不同是,这次尝试将 require(esm) 的范围保持得较小,并且仅支持加载同步的 ESM。结果表明,这在协作者 / TSC 中并不是一个有争议的想法,而且没有遇到太多的阻力就落地了(由于关于加载器钩子的讨论稍微偏离了轨道,但至少我们同意可以留待后续处理钩子支持,而 require(esm) 本身应该回到正轨)。

下一步是什么?

该功能仍在 --experimental-require-module 标志下处于实验状态,还有一些工作需要完成,才能从实验中走出。

可能仍然有一些边缘情况需要处理。我尝试在 PR 中测试了许多边缘情况,但显然,模块图的可能性太多了,在一个 PR 中无法全部测试。最初的迭代已经足以加载大多数我测试过的仅限 ESM 的高影响 npm 包。我们可以继续完善这一点,因为我们得到了用户对它的反馈。

另一个需要解决的问题是对自定义加载器钩子的支持。当前的加载器钩子再次是无条件异步的。这通过使用工作线程使主线程阻塞在加载器上,带来了巨大的开销。虽然比根本没有加载器钩子要好,但无论是性能上还是能力上,这都远远不如 require() 的猴子补丁,并且 require() 的猴子补丁已被许多流行的包所使用,我认为我们真的需要为用户提供一个可比较的替代方案,以便他们迁移。在我看来,下一步应该是开发一个线程内的、同步的加载器钩子变体,它支持 require()import。这可以使 require() 的猴子补丁不再必要,并使更多用户能从 CJS 迁移到 ESM。

目前 require(esm) 仅支持显式标记为 ESM 的 ESM – 通过 .mjs 扩展名,或者在 package.json 中的 "type": "module" 字段为 .js 扩展。这已经足以支持在 npm 中加载仅限 ESM 的包。当 .js 文件中出现 ESM 语法而其最近的 package.json 中没有 "type": "module" 字段时,可以实现回退到 ESM 加载,但一般来说用户应该避免这种情况 – ESM 语法检测会带来开销,一旦你的项目中有足够多的 ESM 模块,你可能不希望 Node.js 浪费时间去猜测你的模块类型,当你可以通过在 package.json 中仅添加一个明确的 "type": "module" 字段来节省这个成本。

在入口点中支持顶层等待仍然是可能的 – 在这种情况下,我们可以简单地回退到入口点的异步加载,因为入口点的导出无论如何都无处可去。通过 --experimental-detect-module 已经可以做到这一点,将回退移动到现在支持加载 ESM 的 CJS 加载器内部更多是一个实现细节。

最后的话

感谢 Bloomberg 这些年来赞助我的工作,支持我解决这个苦恼。