当谈到 JavaScript 的模块化规范时,我们不可避免地会提到 CommonJS。多年来,CommonJS 在 Node.js 生态系统中扮演着至关重要的角色,它让开发者能够方便地使用模块化的代码组织方式。然而,随着 ES Modules(ESM)的出现和日益普及,人们开始思考一个问题:CommonJS 是否注定会消失?在本文中,我们将探讨 CommonJS 的现状和未来,并评估它与ESM之间的关系。是否真的可以全面过渡到ESM,抛弃CommonJS?让我们一起来看看。
CommonJS 的兴起
JavaScript 出现大约 15 年后,开始从浏览器扩展到服务端。许多更大的项目选择使用 JavaScript 进行构建,而 JavaScript 需要一种更好的方法来处理大量源代码,它需要模块化。
2009 年,Mozilla 的开发者 Kevin Dangoor 发出了号召。在 “服务端 JavaScript 需要什么”一文中,他列举出了服务端 JavaScript 所缺少的许多内容,包括模块系统。
JavaScript 需要一种标准的方式来包含其他模块,并且这些模块应该存在于独立的命名空间中。虽然有一些简单的方法来创建命名空间,但是目前还没有一种标准的编程方式来加载一个模块(一次性)。这一点非常重要,因为服务端应用可能包含大量代码,并且可能会混合和匹配满足这些标准接口的部分。
— 出自Kevin Dangoor的《Server Side JavaScript需要什么》(2009年)
在一周内,包括 npm 的创始人 Issac Schlueter 和 Node.js 的创建者 Ryan Dahl 在内,共有 224 人加入了当时称为 ServerJS 的 Google 群组。该邮件列表将继续规范 CommonJS 的第一个版本,该模块系统成为 Node.js 的一部分。
提议的 CommonJS 语法(require()
、module.exports
等)看起来并不像客户端JavaScript。这是经过设计的。Dangoor意图将CommonJS与浏览器JavaScript区分开来,这一点从他 2009 年在 CommonJS Google 群组上的消息中就可以看出来:
我确实认为服务端代码的需求与客户端代码的需求有很大不同,因此最好从 Python 和 Ruby 中获取,而不是从 Dojo 和 jQuery 中获取。
除了 Node.js 之外,其他几个早期的服务端 JavaScript 运行时也采用了 CommonJS,例如 Flusspferd、GPSEE、Narwhal、Persevere、RingoJS、Sproutcore 和 v8cgi(大多数由核心 CommonJS 团队构建)。
但随着 Node.js 成为真正的服务端 JavaScript 运行时,并以 CommonJS 作为其主要模块系统,更广泛的 CommonJS 标准化工作失去了动力。当只有一个主要运行时时,对标准的需求就会减少:Node.js 实现就成为了标准。
尽管 CommonJS 是默认的模块系统,但它仍然存在一些核心问题:
模块加载是同步的,每个模块都按照需要的顺序一一加载和执行。
难以进行 tree shaking,这可以删除未使用的模块并最小化包大小。
不是浏览器原生的,需要打包工具和转译器来使所有这些代码在客户端工作。使用 CommonJS,您=要么陷入巨大的构建步骤,要么为客户端和服务端编写单独的代码。
到 2013 年,CommonJS 小组开始逐渐解体。但到了那一年,负责监督 JavaScript 核心语言更新的 TC39 委员会已经开始开发 CommonJS 模块的后继者:ECMAScript 模块。
ECMAScript 模块是面向Web的
随着ES6语言规范的发布,TC39委员会最终引入了一个直接嵌入到JavaScript语言中的模块系统。其目标是构建一个适用于Web的单一模块加载器系统,包括异步模块加载、与浏览器兼容、静态分析和 tree shaking。ES模块假设它们将从网络中获取数据,而不是从文件系统中获取,以提供更好的性能和用户体验。
现在,由于模块加载系统已经直接集成到语言中,大家都会使用它,这样就可以将精力集中在更高级别、更重要的问题上了。
Node 决定同时支持 CJS 和 ESM
Borins 是 Node“模块团队”的开发人员之一,负责在 Node.js 中实现 ES 模块。尽管成功地将 ESM 添加到 Node,但团队未能就 ESM 和 CJS 之间的互操作性达成明确的共识。然而 Node 无法摆脱 CJS,因为它的嵌入程度如此之深。这意味着互操作性问题被推给了软件包作者。
以下是支持 ESM 和 CJS 所需的模块 package.json
的片段:
显然,支持 CommonJS 已经成为不容忽视的问题。但是,CommonJS 真的是一无是处嘛?
CommonJS 的优势
CommonJS 启动速度更快
对于较大的应用,ES 模块速度较慢。与 require
不同,要么需要在使用语句时加载整个模块图,要么需要使用表达式等待每个导入。例如,如果想要延迟加载一个包以在函数中使用,代码必须返回一个 Promise
。
async function transpileEsm(code) {
const { transform } = await import("@babel/core");
// ... return 必须是一个 Promise
}
function transpileCjs(code) {
const { transform } = require("@babel/core");
// ... return 是同步的
}
ES模块为了绑定导入和导出,需要进行两次处理。整个模块图会被解析和分析,然后代码会被评估。这些被分为不同的步骤。正是这种方式使得ES模块中的“实时绑定”成为可能。
考虑以下两个简单的文件:
// babel.cjs
require("@babel/core")
// babel.mjs
import "@babel/core";
Babel 是一个由大量文件组成的包,因此比较这两个文件的运行时间是评估与模块解析相关的性能成本的好方法。结果如下:
在 Bun 中(一个新的 JavaScript 运行时),使用 CommonJS 加载 Babel 的速度大约比使用 ES 模块快 2.4 倍,相差了 85ms。在无服务器冷启动的情况下,这个差距是巨大的。在 Node.js 中,差异为 1.8 倍(约 60 毫秒)。
增量加载
CommonJS 允许动态模块加载——可以有条件地 require()
文件,或者 require()
动态构造的路径/说明符,或者在函数体中 require()
。这种灵活性在需要动态加载的场景中非常有利,例如插件系统或基于用户交互的延迟加载特定组件。
ES 模块提供了具有类似属性的动态 import()
函数。从某种意义上说,它的存在证明了 CommonJS 的动态方法具有实用性并受到开发人员的重视。
现状
发布到 npm 的数百万个模块已经使用 CommonJS,其中许多都是:
(a) 不再积极维护。
(b) 对现有项目很重要。
可能永远不会达到所有包都可以使用 ES 模块的地步。不支持CommonJS的运行时或框架将失去巨大的价值。
小结
尽管 ES Modules(ESM)的出现给予了 CommonJS 微弱的竞争压力,但我们可以得出结论:CommonJS 不会轻易消失。
CommonJS 在 Node.js 生态系统中的广泛应用以及大量已发布在 npm 上的模块都使用了 CommonJS 规范,这使得 CommonJS 成为不可忽视的存在。很多模块虽然不再得到积极维护,但对于现有项目仍具有重要性。因此,在实际应用中,对于不支持 CommonJS 的运行时或框架来说,忽视 CommonJS 将意味着错失巨大的价值。
尽管 ES Modules 在语言层面提供了更强大的静态分析和优化能力,并且被认为是 JavaScript 的未来,但 CommonJS 仍然具有优势。它简单易用,适合动态加载和代码重用。因此,在目前的情况下,我们仍然需要将 CommonJS 视为一个重要的模块化规范。
无论是 CommonJS 还是 ES Modules,它们都有各自的特点和适用场景。我们不应该试图完全取代 CommonJS,而是应该在技术发展的过程中平衡实用性和未来趋势。只有这样,才能更好地利用 JavaScript 的模块化能力,为项目和生态系统带来最大的价值。
参考资料:
https://bun.sh/blog/commonjs-is-not-going-away
https://deno.com/blog/commonjs-is-hurting-javascript