Bun

CommonJS 不会消失


Jarred Sumner · 2023 年 6 月 30 日

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

有些人可能会惊讶地看到 Bun 的近期 发布 说明 提到了 CommonJS 支持。毕竟,CommonJS 是一个遗留的模块系统,而 JavaScript 的未来是 ES 模块 (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),在服务器端 JavaScript 中,所有文件都应在本地文件系统上可用。这就是 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 不同,您需要加载整个模块图(当使用语句时)或等待每个导入(当使用表达式时)。例如,如果您想延迟加载一个包以在函数中使用,您的代码必须返回一个 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 模块的设计使其速度较慢。它们需要两个Pass才能将导入绑定到导出。整个模块图被解析和分析,然后代码被评估。这分为不同的步骤。这就是 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 时,默认 import (import 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 今天将其视为一等公民。