在现代
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 };
// 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)
。