ECMAScript Module(ESM)逐渐成为现代 JavaScript 开发中的公认行业标准。自从 ESM 被引入到 Node.js 以来,其异步加载特性和模块解析逻辑受到了广泛欢迎。
然而,由于历史原因,许多现有代码库和第三方库仍然依赖于 CommonJS(CJS)模块系统。由于 ESM 的设计是异步加载的,这两种模块化方案一直无法共存,这成为了许多开发者的一个主要痛点。
最近,开发者 joyeecheung
提交了一个重要的 Pull Request 来解决这个问题。
CJS 和 ESM 的过去与现在
在 JavaScript 的世界里,模块化是构建大型应用程序的基础。模块化帮助开发者管理代码而不影响全局命名空间,使得分离功能、重用代码和管理依赖变得更加容易。在 Node.js 和浏览器环境中,有两种主流的模块系统:CommonJS(CJS)
和 ECMAScript Module(ESM)
。
CommonJS
是 Node.js 原生支持的模块系统,最初是为了满足服务器端的模块化需求。CJS
使用 require
函数来加载模块,并使用 module.exports
或 exports
对象来暴露代码。CJS
模块的特点是同步加载,这意味着模块加载完成后,代码会立即执行:
// 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
被设计为静态的,这意味着不能在运行时动态加载或创建模块。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 高效并行加载和通过树摇优化去除未使用的代码,从而提升性能和效率。然而,在 Node.js
中,ESM
的异步特性与大量现有的 CommonJS
模块之间存在不兼容的问题。
在 Node.js 中启用 ESM
目前需要更复杂的方法,因为默认情况下,.js
文件扩展名与 CommonJS
模块关联。为了解决这个问题,Node.js 允许使用 .mjs
文件扩展名或在 package.json
中显式指定 "type": "module"
属性来指示 ESM
模块。
虽然 Node.js 支持导入 CommonJS
模块,但却无法通过 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
注册表上的大约 30 个仅通过 ESM
提供支持的包时,没有一个包含顶级 await
——支持同步模块的 require()
可能已经解决了生态系统中的许多头痛问题。
早期探索和尝试
对 ESM 的支持经历了长时间的讨论、设计和实验。早在 2019 年,Node.js 社区就开始探索如何支持 ESM 和 CommonJS 之间的互操作性。在此期间,许多开发者提交了 Pull Requests
,提出了不同的实现方案和改进措施。
当时,一个里程碑式的 PR 讨论集中在如何在 Node.js 中支持 .mjs
扩展名的文件以及如何实现同时支持 CommonJS 和 ESM 的双模块系统。
该请求尝试通过在加载器中循环事件来处理顶级 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 加载器做类似缓存的工作并再次深入研究它们时,他注意到似乎有一个更简单的实现方法——只需放弃“使 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-only 包。当一个 .js 文件包含 ESM 语法并且其最近的 package.json
中没有 “type”: “module”
字段时,它可以“回退”到 ESM 加载,但这是用户应该避免的事情——ESM 语法检测会带来开销,一旦你的项目中有足够的 ESM 模块,你可能不希望 Node.js 浪费时间猜测你的模块类型。特别是,只需在 package.json 中使用显式的 “type”: “module”
字段,就可以节省这些开销。
最后
老实说,这个问题困扰了我很长时间。许多 NPM 包开发者也深受其害。我希望 joyeecheung 的这次尝试能够尽快投入生产!
首发于公众号 大迁世界,欢迎关注。📝 每周一篇实用的前端文章 🛠️ 分享值得关注的开发工具 ❓ 有疑问?我来回答
本文 GitHub https://github.com/qq449245884/xiaozhi 已收录,有一线大厂面试完整考点、资料以及我的系列文章。
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。