Skip to content

Speeding up the JavaScript ecosystem - The barrel file debacle

A Note to Our Readers

While maintaining fidelity to the source material, this translation incorporates explanatory content and localized expressions, informed by the translator's domain expertise. These thoughtful additions aim to enhance readers' comprehension of the original text's core messages. For any inquiries about the content, we welcome you to engage in the discussion section or consult the source text for reference.

📖 要点总结: 许多项目中充斥着仅仅重导出其他文件的文件。这些所谓的 "桶文件"javascript 工具在大型项目中运行缓慢的关键原因之一。

想象你正在开发一个包含多个模块的大型项目。你添加了一个新模块来开发新的功能,并从另一个模块中导入一个函数:

js
import { foo } from './some/other-file';

export function myCoolCode() {
  // Pretend that this is super smart code :)
  const result = foo();
  return result;
}

你很高兴完成这个新的功能,但当你运行代码时,你异常发现需要等待很长时间。你检查了自己编写的代码,发现没有包含复杂的逻辑和大量的 IO 开销,不应该等待这么长时间。

出于担心,你开始调试代码,通过注入部分与性能相关的运行时代码来来测试执行函数的运行时间:

js
import { foo } from './some/other-file';

export function myCoolCode() {
  console.time();
  const result = foo();
  console.timeEnd();
  return result;
}

再次运行代码,测量性能的代码提示执行函数运行时间很快。此时你陷入了沉思,你重复测量步骤,发现一切都符合预期。然后你在项目的更目录中注入测量性能的运行时代码并再次运行代码,目的是想检测下是否是其他模块影响了运行时间。但很遗憾,结果依旧符合预期。

那这是怎么一回事呢?

好了,系好安全带。这是一个关于 桶文件 对代码的毁灭性影响的故事。

Gathering More Information

到目前为止,我们获得的关键信息是代码的运行时间是符合预期的,但它仅占总运行时长的一小部分,那么我们可以假设剩余的一大部分所消费的时间都浪费在运行测试代码 之前之后

根据工具方面的经验,这些时间通常花在运行项目代码之前。你突然想到:你记得听说一些 npm 包出于 性能兼容性 的原因会通过 bundlers 来预先打包代码。也许这可以帮助解决问题?

你决定测试这个理论,并使用 esbuild 将代码打包到一个文件中,同时特意禁用了任何形式的代码压缩特性,使尽可能与源代码相接近。打包完成后,你重新依赖打包后的产物以重复实验,哇,它瞬间运行完成。

出于好奇,你测量了通过 esbuild 打包的时间和运行打包文件的时间,注意到 两者加起来总时间 仍然比 运行原始源代码 更快。

嗯?发生了什么?然后你明白了:

打包工具会 展平合并 模块依赖图,这是它的核心工作之一。曾经由数千个模块组成的项目,现在通过 esbuild 合并为单一模块。

这将是模块图大小是真正问题的强有力指标,而 桶文件 是主要原因。

Anatomy of a barrel file

桶文件是只导出其他模块且自身并不包含运行时代码的文件。对于非母语人士来说,这个术语很令人困惑,但我们就这么用吧。在编辑器还没有自动导入等功能之前,许多开发者试图将手动编写的导入语句数量降到最少。

js
// Look at all these imports
import { foo } from '../foo';
import { bar } from '../bar';
import { baz } from '../baz';

这催生了一种新的模式:每个文件夹都包含了自己的 index.js 文件,这个 index.js 文件会从同一目录中的其他文件中重新导出所需的引用。从某种程度上说,这分摊了手动编写导入的工作,因为一旦这样的文件就位,消费模块仅需引用一个导入语句即可。

js
// feature/index.js
export * from './foo';
export * from './bar';
export * from './baz';

之前显示的导入语句现在可以折叠为单行:

js
import { bar, baz, foo } from '../feature';

渐渐地,这种模式蔓延到整个代码库,项目中的每个文件夹都有一个 index.js 文件。

看起来挺不错,不是吗?实际上,并不是。

Everything is not fine

在上述模式下,一个模块很可能导入另一个 桶文件,另一个 桶文件 又导入一堆其他模块或 桶文件,如此循环。

最终,你通常会通过一个蜘蛛网般的导入语句导入项目中的每个单独文件。项目越大,加载所有这些模块就需要越长时间。问问自己:加载 3 万个文件更快,还是加载 10 个文件更快?显然只加载 10 个文件会更快。javascript 开发者普遍有一个误解,认为模块只会在需要时加载。这是不正确的,因为这样做会破坏 副作用,例如模块运行时会依赖 全局变量模块执行顺序

举一个例子:

js
import './a';
import './b';
js
globalThis.foo = 123;
js
// 123
console.log(globalThis.foo);

如果 javscript engine 不加载第一个 ./a 导入,那么代码的执行结果则为 undefined 而不是 123

Effects of barrel files on performance

当考虑使用一些依赖运行时的工具(例如: test runner),那么情况变得更糟。

在流行的 jest test runner 中,每个测试文件都在自己的子进程中执行。由于在 node.js 中,不同的子进程之间默认是相互隔离的,每个子进程都有独立的 v8 实例,因此默认情况下是无法直接共享已解析的模块,需要考虑更上游的工具链来实现。

那也就意味着默认情况下每个测试文件都需要从入口模块开始解析,直到依赖图中所有的模块解析完成,并为此付出代价。如果在一个项目中构建模块图需要 6s,那么也就是说若有 100 个测试文件,那么总共浪费 10 分钟来重复解析和构建模块图。在此期间没有运行任何测试代码,而这只是引擎解析源代码以便随后运行所需的时间。

桶文件严重影响性能的另一个领域是任何形式的导入循环 linting 规则。通常,linter 是逐个文件地运行,这意味着构建模块图的成本需要为每个单独的文件付出。那么可能存在在大型项目中 linting 耗时失控的问题,仅仅 linting 就需要开销几个小时。

为了获得一些原始数据,我生成了一个文件相互导入的项目,以更好地了解构建模块图的成本。每个文件都是空的,除了导入语句外没有其他逻辑,测量时间在我的 MacBook M1 Air(2020) 上进行。

图中可以看到,模块数量加载时间 呈非线性增长,模块数量越多,加载所需时间越长,尤其在大量模块的情况下,加载成本 急剧上升,因此控制模块的数量大小是值得的。

我们将以上述测试的场景作为基础应用到每个测试文件中,生成新的子进程的 test runner 的项目中。我们慷慨地假设 test runner 可以并行运行 4 个测试,那么简单人工算一下开销:

模块数量人工计算开销
5000.15s * 100 / 43.75s
10000.31s * 100 / 47.75s
100003.12s * 100 / 41:18m
2500016.81s * 100 / 47:00m
5000048.44s * 100 / 420:00m

计算是保守估计,在实际项目中,开销可能会更糟。针对工具性能角度来说,桶文件这一特性对其并不友好。

What to do

在代码中只有少数几个桶文件通常是可以的,但当每个文件夹都有一个时就会出现问题。这在 javascript 行业中并不罕见。

如果你的项目中广泛使用桶文件,有一个简单的优化可以让运行性能快 60%~80%:摆脱使用所有的桶文件。

Contributors

Changelog

Discuss

Released under the CC BY-SA 4.0 License. (2619af4)