Bun

CommonJS 不会消失


Jarred Sumner · 2023 年 6 月 30 日

我们正在招聘 C/C++ 和 Zig 工程师,共同构建 JavaScript 的未来!加入我们 →

看到 Bun 最近的发布说明中提到 CommonJS 支持,可能有些人会感到惊讶。毕竟,CommonJS 是一个遗留的模块系统,而 JavaScript 的未来是 ES Modules (ESM),对吧?作为一个“有前瞻性”的“下一代”运行时,为什么 Bun 会花费如此大的力气来改进 CommonJS 支持?

因为 CommonJS 将继续存在,而且这样也好!我们认为更好的工具可以解决目前 CommonJS 和 ESM 互操作性方面存在的开发体验问题。

情况说明

正如您可能想象的那样,通常希望将应用程序拆分成多个文件。当您这样做时,您需要一种方法来引用其他文件中的代码。

CommonJS 模块格式开发于 2009 年,并由 Node.js 推广。文件可以将属性分配给一个名为 exports 的特殊变量。然后,其他文件可以通过使用特殊的 require 函数“要求”该文件来引用 exports 对象中的属性。

a.js
b.js
a.js
const b = require("./b.js");

b.sayHi(); // prints "hi"
b.js
exports.sayHi = () => {
  console.log("hi");
};

为极其简化地说明其工作原理:当一个文件被 require 时,该文件会被执行exports 对象中的属性将提供给导入者。CommonJS 是为服务器端 JavaScript 设计的(事实上,它最初名为ServerJS),在这种情况下,所有文件都应该可以在本地文件系统上找到。这就是 CommonJS同步的含义——您可以将 require() 概念化为一个“阻塞”操作,它读取导入的文件并运行它,然后将控制权交还给导入者。

ECMAScript 模块于 2015 年作为 ES6 的一部分引入。ES 模块使用 export 关键字声明其导出。import 关键字用于从其他文件中导入。与 exports/require 不同,importexport 语句只能出现在文件的顶层

a.js
b.js
a.js
import { sayHi } from "./b.js"

sayHi(); // prints "hi"
b.js
export const sayHi = () => {
  console.log("hi");
};

由于 ES 模块旨在浏览器中使用,因此预计文件将通过网络加载。这就是 ES 模块异步的含义。给定一个 ES 模块,浏览器可以在不运行文件的情况下看到它导入和导出了什么。通常,在执行任何代码之前,整个模块图都会被解析(这可能涉及多次往返网络请求)。

CommonJS 的优势

CommonJS 启动更快

对于大型应用程序而言,ES 模块速度较慢。与 require 不同,在使用语句时,您需要加载整个模块图,或者使用表达式 await 每个导入。例如,如果您想为一个函数中使用的包进行惰性加载,您的代码必须返回一个 promise(这可能会引入额外的微任务和开销)。

async function transpileEsm(code) {
  const { transform } = await import("@babel/core");
  // ... return must be a Promise
}

function transpileCjs(code) {
  const { transform } = require("@babel/core");
  // ... return is sync
}

ES 模块的设计就是为了速度较慢。它们需要两个阶段才能将导入绑定到导出。整个模块图被解析和分析,然后代码被评估。这被分成了不同的步骤。这使得 ES 模块中的“实时绑定”成为可能。

考虑这两个简单的文件。

babel.cjs
babel.mjs
babel.cjs
require("@babel/core")
babel.mjs
import "@babel/core";

Babel 是一个包含大量文件的包,因此比较这两个文件的运行时是评估模块解析相关性能成本的不错方法。结果

使用 Bun,加载 CommonJS 格式的 Babel 比使用 ES 模块快约 2.4 倍。

两者相差 85 毫秒。在无服务器冷启动的上下文中,这是一个巨大的差异。在 Node.js 上,差异是 1.8 倍(约 60 毫秒)。

增量加载

CommonJS 允许动态模块加载——您可以有条件地 require() 一个文件,或者 require() 一个动态构造的路径/说明符,或者在函数体内部 require()。当需要动态加载时,例如插件系统或根据用户交互惰性加载特定组件,这种灵活性会很有优势。

ES 模块提供了一个具有类似功能的动态 import() 函数。在某种意义上,它的存在证明了 CommonJS 的动态方法具有实用性并受到开发者的重视。

它已经在这里

数百万已发布到 npm 的模块已经在使用 CommonJS。其中许多模块是:(a)不再积极维护,并且(b)对现有项目至关重要。我们永远不会达到所有包都能被期望使用 ES 模块的地步。不支持 CommonJS 的运行时或框架将错失大量价值。

Bun 中的 CommonJS

从 Bun v0.6.5 开始,Bun 运行时原生实现了 CommonJS。以前,Bun 会将 CommonJS 文件转译为特殊的“同步 ESM”格式。

从 ESM 导入 CommonJS

您可以从 ESM 模块中 importrequire CommonJS 模块。

import { stuff } from "./my-commonjs.cjs";
import Stuff from "./my-commonjs.cjs";
const myStuff = require("./my-commonjs.cjs");

最近,Bun 还增加了对__esModule 注解的支持。

module.js
exports.__esModule = true;
exports.default = 5;
exports.foo = "foo";

这是一种事实上的机制,用于让 CommonJS 模块指示(与 package.json 中的 "type": "module" 一起)exports.default 应被解释为默认导出。当 CommonJS 模块中设置了 __esModule 时,默认 importimport a from "./a.js")将导入 exports.default 属性。如果没有此注解,默认导入将导入整个 exports 对象。

有注解时

// with __esModule: true
import mod, { foo } from "./module.js";
mod; // 5
foo; // "foo"

无注解时

// without __esModule
import mod, { foo } from "./module.js";
mod; // { default: 5 }
mod.default; // 5
foo; // "foo"

这是 CommonJS 模块指示 exports.default 应被解释为默认导出的实际标准方法。

总结

CommonJS 已经并将继续存在。不仅如此,它还有实际存在的理由。我们在 Bun 非常喜欢 ES 模块,但务实也很重要。CommonJS 不是过时时代的遗物,Bun 今天将其视为一等公民。