加速JavaScript - barrel 文件崩溃


让我们想象一下,你正在处理一个包含许多文件的大型项目。你新增了一个文件,以便处理一个新功能,并在你的代码中从另一个目录导入了一个函数。

1
2
3
4
5
6
7
8
import { foo } from "./some/other-file";

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

在兴奋地完成你的功能后,你运行代码却发现它花费的时间非常长。你编写的代码非常直接,不应该花费那么多时间。因为对此感到担忧,于是你加入了一些测量代码,以查看你的函数执行所花费的时间。

1
2
3
4
5
6
7
8
import { foo } from "./some/other-file";

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

你再次运行代码,令你惊讶的是,你插入的测量代码显示它运行得飞快。你重复测量步骤,但这次将console.time()语句插入项目的主入口文件,然后再次运行代码。但是,无济于事,记录的测量结果只是确认你的代码本身运行得非常快。到底发生了什么?

好吧,系好安全带。这是关于barrel文件对你的代码产生毁灭性影响的故事。

信息收集

到目前为止,我们获得的关键信息是代码的运行时间不是问题所在。你进行了测量,而它只占总时间的一小部分。这意味着我们可以假设所有其他时间都是在运行代码之前或之后浪费的。根据经验,当涉及到工具时,时间通常是在运行项目代码之前花费的。

你想到了一个主意:你记得听说有些 npm 包为了性能原因会预编译代码。也许这在这里有帮助?于是,你决定测试这个理论,并使用 esbuild 将你的代码编译成一个单一文件。你故意禁用任何形式的最小化,因为你希望你的代码尽可能接近原始源代码。

完成后,你运行bundle文件来重复实验,咦,它在眨眼之间就完成了。出于好奇,你测量了运行 esbuild 和运行bundle文件所需的时间,注意到它们两者结合起来仍然比运行原始源代码更快。嗯?到底发生了什么?

然后你恍然大悟:bundle工具的主要作用是平铺化和合并模块图。曾经由数千个文件组成的东西,因为 esbuild 的作用被合并成一个单一的文件。这将是模块图的大小是真正的问题的强烈指示。而barrel文件是其中的主要原因。

barrel文件分析

Barrel 文件是仅导出其他文件而本身不包含代码的文件。在编辑器没有自动导入和其他功能的时代,许多开发人员尝试通过手写最少的导入语句来保持代码的整洁。

1
2
3
4
// Look at all these imports
import { foo } from "../foo";
import { bar } from "../bar";
import { baz } from "../baz";

这导致了一种模式,即每个文件夹都有一个自己的 index.js 文件,它仅仅是从通常是同一目录中的其他文件中重新导出代码。在某种程度上,这样做是为了摊销手动输入的工作,因为一旦有了这样的文件,所有其他代码只需要引用一个导入语句。

1
2
3
4
// feature/index.js
export * from "./foo";
export * from "./bar";
export * from "./baz";

先前显示的导入语句现在可以合并成一行。

1
import { foo, bar, baz } from "../feature";

过一段时间,这种模式会在整个代码库中蔓延开来,你的项目的每个文件夹都有一个index.js文件。挺整洁的,不是吗?嗯,不是的。

并不是想象中的美好

在这样的设置中,一个模块很可能导入另一个barrel文件,该文件引入了一堆其他文件,然后这些文件导入了另一个 barrel 文件,依此类推。最终,你通常会通过一系列导入语句导入项目中的每个文件。而项目越大,加载所有这些模块所需的时间就越长。

问问自己:哪个更快?加载 30,000 个文件还是只加载10个文件?很可能只加载 10 个文件更快。

在 JavaScript 开发者中存在一个常见的误解,即模块只有在需要时才会被加载。这是不正确的,因为这样做会破坏依赖全局变量或模块执行顺序的代码。

1
2
3
4
5
6
7
8
9
// a.js
globalThis.foo = 123;

// b.js
console.log(globalThis.foo); // should log: 123

// index.js
import "./a";
import "./b";

如果引擎不加载第一个./a导入,那么代码会意外地记录 undefined 而不是 123

对性能的影响

考虑到测试运行工具,情况变得更糟。在流行的 Jest 测试运行器中,每个测试文件在其自己的子进程中执行。实际上,这意味着每个单独的测试文件都要从头构建模块图,并且必须支付这个成本。如果在一个项目中构建模块图需要花费 6 秒,而你只有 - 比如说 - 100 个测试文件,那么你总共会浪费10分钟,不断地构建模块。在这段时间内没有运行任何测试或其他代码。这只是引擎需要准备源代码以便后续运行的时间。

另一个 barrel 文件对性能产生严重影响的领域是任何形式的导入循环检测规则。通常,检查器是逐个文件运行的,这意味着必须为每个文件支付构建模块图的成本。这往往导致linting时间失控,突然间在一个较大的项目中linting花费几个小时。

为了获取一些原始数据,我生成了一个包含相互导入的文件的项目,以更好地了解构建模块图的成本。每个文件都是空的,除了导入语句外,不包含任何代码。

正如你所看到的,加载更少的模块是非常值得的。让我们将这些数字应用到一个有100个测试文件的项目中,该项目使用一个为每个测试文件生成一个新子进程的测试运行器。在这里我们慷慨一点,假设我们的测试运行器可以并行运行 4 个测试:

  • 500 个模块:0.15 秒 * 100 / 4 = 3.75 秒的额外开销
  • 1000 个模块:0.31 秒 * 100 / 4 = 7.75 秒的额外开销
  • 10000 个模块:3.12 秒 * 100 / 4 = 1:18 分钟的额外开销
  • 25000 个模块:16.81 秒 * 100 / 4 = 约 7:00 分钟的额外开销
  • 50000 个模块:48.44 秒 * 100 / 4 = 约 20:00 分钟的额外开销

由于这是一个合成的设置,这些是低估的数字。在一个真实的项目中,这些数字可能更糟。在工具性能方面barrel 文件并不理想。

如何优化

在代码中只有少数几个 barrel 文件通常没问题,但当每个文件夹都有一个barrel文件时,情况就会变得棘手。不幸的是,在JavaScript行业中,这并不是罕见的情况。

因此,如果你正在一个大量使用 barrel 文件的项目中工作,有一个免费的优化方法可以让许多任务变得快 60-80%摆脱所有 barrel 文件。