在现代 JavaScript
开发中,ECMAScript Module
已经逐渐成为了公认的业界标准。自 ESM
被引入 Node.js
以来,它的异步加载特性和模块解析逻辑广受大家好评。
然而,由于历史原因,很多既有代码和第三方库仍依赖于 CommonJS
模块系统,然而因为 ESM
的异步加载的设计,两个模块化方案一直是无法共存的,这也成了很多开发者的一大痛点。
最近, joyeecheung
提交的一个关键的 Pull Request
(https://github.com/nodejs/node/pull/51977) 来解决这个问题。
在开始介绍前,我们先回顾一下 JavaScript
的两大模块化方案:CJS
和 ESM
。
在 JavaScript
的世界里,模块化是构建大型应用程序的基础。模块化可以帮助开发者在不影响全局命名空间的前提下管理代码,便于功能分离、代码复用和依赖管理。在 Node.js
和浏览器环境中,有两种主流的模块系统:CommonJS(CJS)
和 ECMAScript Module(ESM)
。
CommonJS
是 Node.js
原生支持的模块系统,起初为了满足服务端模块化的需求而被引入。CJS
使用 require
函数来加载模块,用 module.exports
或 exports
对象将代码暴露为模块。CommonJS
模块的特点是同步加载,这意味着代码会在模块被加载完成后立即执行:
// math.js
function add(x, y) {
return x + y;
}
module.exports = { add };
// app.js
const math = require('./math.js');
console.log(math.add(0, 17)); // 打印出 17
在服务器环境中,同步加载通常不是问题,因为文件大都在本地。然而,在浏览器环境中,同步加载可能会导致性能问题,因为它会阻塞浏览器的事件循环,直到脚本完全下载和解析。
ESM
是现代 JavaScript
的官方标准模块系统,也被最新版本的浏览器原生支持。与 CommonJS
不同,它们设计成静态的,这意味着你不能在运行时动态地加载或创建模块。ESM
使用 import
和 export
语句进行模块的导入和导出,支持异步加载:
// math.js
export function add(x, y) {
return x + y;
}
// app.js
import { add } from './math.js';
console.log(add(0, 17)); // 打印出17
ESM
的设计允许浏览器优化加载和解析过程,如通过 HTTP/2
进行有效的并行加载,以及进行 tree shaking
以剔除未使用的代码,从而增强性能和效率。但是,在 Node.js
中,ESM
的异步特性与现有的大量 CommonJS
模块存在不兼容问题。
当前在 Node.js
中启用 ESM
的方法要复杂一些,因为代表性的 .js
文件扩展名默认与 CommonJS
模块关联。为了解决此问题,Node.js
允许使用 .mjs
文件扩展名或在 package.json
中明确指定 "type": "module"
属性来表示 ESM
模块。
由于 ESM
是在 Node.js
中提供支持的,所以我们可以 import cjs
,但不可能 require(esm)
。这种 ERR_REQUIRE_ESM
的挫败感困扰着许多人,并且可能是 Node.js
生态系统中浪费时间的主要原因。
如果包作者想要确保 CJS
和 ESM
用户都可以使用他们的包,他们要么必须继续将其模块作为 CJS
发布,要么将 CJS
和 ESM
版本即作为双模块发布(这可能会导致一些问题,但现在这是一种非常常见的做法)。
同时,许多转译器(例如 TypeScript
编译器)仍然配置为生成 CJS
代码作为其最终输出。这些转译器的用户使用 ESM
语法编写代码,但他们不一定知道他们的代码最终由 Node.js
作为 CJS
运行。当他们的代码使用真正的 ESM
第三方模块(无法 require
)时,他们会看到一个 ERR_REQUIRE_ESM
。这可能会非常令人困惑,因为他们可能假设他们的代码是作为真正的 ESM
运行的。
自然地,人们可能会问:为什么 require()
就不能支持加载 ESM
呢?
很长一段时间以来,Node.js
项目的答案总是这样:
使用
require
来加载 ES 模块是不被支持的,因为 ES 模块是异步执行的。
但这是一种文档和其他交流方式有误导作用的情况 - 也许它们只在谈论在 Node.js
的 ESM
中发生的事情,而不是 ESM
本身被设计成什么样的。去年,当 joyeecheung
阅读 V8 代码来修复内存泄漏问题时,偶然发现 ESM
本身并不是真正设计成无条件异步的。而是设计成只在条件下异步 - 只有当代码中包含顶级 await
时才会异步。
那么,对 require()
至少支持不包含顶级 await
的 ESM 当然就没毛病了。虽然一些库可能有合理的理由使用顶级 await
,但这可能并不会那么常见。
的确,当 joyeecheung
后来在 npm
注册表中对 Top
影响力的仅提供 ESM
支持的包进行 require(esm)
测试时,测试的约 30 个包中没有一个包含顶级 await
- 并且在 require()
中支持同步模块可能已经足够解决生态系统中的许多头痛问题。
同步 ESM
的支持其实也经历了长期的讨论、设计和试验。早在 2019
年,Node.js
社区就开始探讨如何支持 ESM
和 CommonJS
之间的互操作性。期间,不少开发人员提交了 Pull Requests
,提出不同的实现方案和改进措施。
在那个时候,一个具有里程碑意义的 PR
讨论集中在如何在 Node.js
中支持 .mjs
后缀的文件,以及如何实现一个双模块系统,可以同时支持 CommonJS
和 ESM
。
https://github.com/nodejs/node/pull/30891
这个 pull request
试图通过在加载器中循环事件来处理顶级 await
,但它的处理方式是不安全的,这就是它被关闭的原因。
在规范方面,基于语法的 ESM
同步评估的理论基础在 2019
年已经确定。随着时间的推移,Node.js
中似乎发展出了一种关于 “ESM
是异步的,CJS
是同步的,所以 CJS
不能加载 ESM
” 的神话,而在标准机构中,ES
规范特别注意保证 ESM
只是有条件的异步,W3C
规范使用它确保 Service Workers
只允许同步模块评估。如果规范中基于语法的同步性得到了更广泛的认知,那么在 2019
年后可能会有更多的尝试,文档也不会像无条件地谈论 ESM
是异步的。
require(esm)
在去年年末,joyeecheung
发现根据语法,ESM
可以是同步的,而且只有 Node.js
把异步性投入到加载过程中后,于是 joyeecheung
和 GeoffreyBooth
开始讨论重新启动同步 require(esm)
。
在 2024 年 2 月底,joyeecheung
在为 CJS
和 ESM
加载器做一个类似 cache
的事情,开始再次深入研究它们时,他注意到似乎有一种更简单的实现方式 - 只需放弃“使 ESM
加载器成为 Node.js
中唯一的加载器” 的想法,并为 CJS
加载器实现一些专用程序以支持同步 require(esm)
。它使用的现有 ESM
加载器代码越少,就越容易。
所以,这就有了这个 PR
。
https://github.com/nodejs/node/pull/51977
它与 2019
年的 PR
的主要区别在于,这试图使 require(esm)
的范围保持小,并且只支持加载同步 ESM
。事实证明,这在技术指导委员会(TSC)中根本不是一个有争议的想法,并且没有遭到多少争议。
目前这个特性仍然在 --experimental-require-module
标志下进行实验,还有一些工作需要在它走出实验阶段之前完成。
目前, require(esm)
仅支持显式标记为 ESM
的 ESM
- 通过 .mjs
扩展名或者对 .js
扩展名的 "type“: "module"
包字段。这已经足够支持在 npm
中加载仅 ESM
包的功能。它可以实现当 .js
文件出现 ESM
语法且其最近的 package.json
中没有 "type": "module"
字段时,回退到 ESM
加载,但这通常是用户应该避免的 - ESM
语法检测会产生开销,一旦你的项目中有足够的 ESM
模块,你可能不希望 Node.js
浪费时间去猜测你的模块类型。尤其是,当你可以只用一个显式的 "type": "module"
字段在你的 package.json
中就可以节省这些开销。
说实话这个问题也困扰我很久了,相比很多 NPM
包开发者也都深受其害,希望这次 joyeecheung
的尝试可以尽早走向生产吧!
参考: