• CommonJS 和 ES Module 终于要互相兼容了?
  • 发布于 2个月前
  • 208 热度
    0 评论
在现代 JavaScript 开发中,ECMAScript Module 已经逐渐成为了公认的业界标准。自 ESM 被引入 Node.js 以来,它的异步加载特性和模块解析逻辑广受大家好评。然而,由于历史原因,很多既有代码和第三方库仍依赖于 CommonJS 模块系统,然而因为 ESM 的异步加载的设计,两个模块化方案一直是无法共存的,这也成了很多开发者的一大痛点。

最近, joyeecheung 提交的一个关键的 Pull Request(https://github.com/nodejs/node/pull/51977) 来解决这个问题。

在开始介绍前,我们先回顾一下 JavaScript 的两大模块化方案:CJS 和 ESM。

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 };
// 堆代码 duidaima.com
// 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 。

这个 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 的尝试可以尽早走向生产吧!
用户评论