在Node.js中使用`require(esm)`

原文信息: 查看原文查看原文

require(esm) in Node.js

- Joyee Cheung

最近,我在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运行。当他们的代码使用无法require()的真实ESM的第三方模块时,他们将看到一个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注册表中测试我的require(esm)拉取请求对高影响的ESM-only软件包时,我测试的约30个软件包中没有一个包含顶级await - 而支持在require()中加载同步模块可能已经足以解决生态系统中的许多问题。

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

2019年的同步require(esm)

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

在规范方面,关于基于语法的同步评估ESM的理论基础已经在2019年确定。后来我与(非Node.js)从事ESM工作的人交谈时,似乎这是一个常识。随着时间的推移,在Node.js中发展了一个关于“ESM是异步的,CJS是同步的,因此CJS无法加载ESM”的神话,而标准机构则特别注意确保ESM仅在条件下异步,并且W3C规范使用它来确保服务工作者仅允许同步模块评估。如果语法基于同步性在规范中更为广泛地得到认知,那么在2019年之后可能会有更多尝试,并且文档在谈论ESM时不会像它是无条件异步的那样。

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

ESM的孤立现象

经过一番思考,我认为导致Node.js中没有更早实现同步require(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加载器之前。几年后,当我碰巧在修复一些由ESM集成引起的vm API中的内存泄漏时(我在以前的博客文章中谈到了它们),我不得不第一次真正深入研究Node.js中的ESM加载器。我惊讶地发现ESM加载器比CJS加载器更复杂(几乎是CJS加载器代码的3倍)。这可能是由于加载器多年来的有机增长,它似乎确实需要进行一些整理。

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

ESM加载器中的这些技术问题也会带来其他后果 - 例如性能方面。有些人可能认为总是能够异步加载(如果模块包含多个import,则并行加载)会使加载更快。但是当我对大约30个ESM-only的npm软件包进行了require(esm)测试,并与import esm进行比较时,前者(使用专门的同步例程)实际上快了约1.22倍

重新启动同步require(esm)

去年底,当我发现ESM的评估可以根据语法是同步的,而只有Node.js将异步性注入到加载过程中时,@GeoffreyBooth和我开始讨论重新启动同步require(esm)。即使到了今天,ESM在Node.js中仍然是一个非常可怕的主题,但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-only npm包,但我们可以继续根据用户的反馈对其进行优化。

另一个需要解决的问题是支持自定义加载器挂钩。当前的加载器挂钩再次是异步的。这通过使用工作线程使主线程在加载器上阻塞而产生了巨大的开销。虽然这比根本没有加载器挂钩要好,但从性能和功能上来说,这与require()的猴子补丁完全不相上下,require()的猴子补丁已经被如此多的流行软件包使用,以至于我认为我们确实需要提供一个类似的替代方案供用户迁移。在我看来,下一步应该是开发一个线程内和同步的加载器挂钩的变体,支持require()import。这可能会使require()的猴子补丁不再必要,并使更多的用户能够从CJS迁移到ESM。

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

对于入口点中的顶级等待的支持仍然是可能的 - 在这种情况下,我们可以简单地在入口点中使用异步加载,因为入口点的导出不会去任何地方。通过--experimental-detect-module已经可以做到这一点,这更多地是一个实现细节,将回退移动到CJS加载器中,因为它支持加载ESM。

最后的话

感谢Bloomberg多年来一直赞助我的工作,支持我解决这个痛苦。

分享于 2024-03-24

访问量 138

预览图片