JS生态系统加速桶装文件使用探索
作者:大家的林语冰 人猫神话
JS 桶装文件
长话短说:一大坨项目都塞满了只是重新 export
其他文件的文件。这就是所谓的“桶装文件”(barrel files),也是 JS 工具在大型项目中慢如龟速的关键原因之一。
本期《前端翻译计划》共享的是“加速 JS 生态系统系列博客”,包括但不限于:
- PostCSS,SVGO 等等
- 模块解析
- 使用 eslint
- npm 脚本
- draft-js emoji 插件
- polyfill 暴走
- 桶装文件暴走
- Tailwind CSS
本期共享的是第 7 篇博客 —— 桶装文件暴走。
新文件 import
假设我们正在开发一个包含一大坨文件的大型项目。我们添加一个新文件来处理新功能,并将另一个目录中的函数 import
到代码中。
import { foo } from './some/other-file' export function myCoolCode() { // 假装这是超智能代码 :) const result = foo() return result }
我们十分鸡冻地实现了功能,我们运行代码,但是发现这需要很久才能搞定。我们编写的代码一目了然,理论上应该不会太费时。为此,我们添加了某些测量代码,查看函数完成其任务所需的时间。
import { foo } from './some/other-file' export function myCoolCode() { console.time() const result = foo() console.timeEnd() return result }
我们重跑代码,大吃一斤的是,我们插入的测量结果表明,我们的函数速度惊人。我们重复测量步骤,但这次在项目的主入口文件中插入 console.time()
语句,然后再重跑代码。梅开二度,记录的测量结果只是反复证明我们的代码本身速度惊人。那为什么运行代码却很费时呢?
没时间解释了快上车。这就是桶装文件对代码造成的破坏性影响。
收集更多信息
目前为止,我们获得的关键信息是,代码的运行时不是 bug 所在。我们测量了函数,它只是运行总时间的九牛一毛。这意味着,我们可以假设,所有剩余时间耽误在运行代码前后。根据工具的经验,时间通常花在项目代码运行前。
我有一个大胆的想法:我们听过某些 npm 软件包出于性能考虑,会预打包其代码。也许这是一种新思路?我们决定测试该理论,并诉诸 esbuild 将代码打包到一个简单文件中。我们故意禁用任何形式的压缩,因为我们希望代码尽可能还原原始源码。
完事后,我们可以运行打包后的文件来对照实验,我和我的小伙伴都惊呆了,这次运行比猫猫还快。出于好奇,我们测量了运行 esbuild 和一起运行打包文件的时间,并注意到,它们累加起来仍然比运行原始源码更快。啊?到底是怎么回事?我们明明多了打包步骤,但运行结果却比未打包的源码更快?
然后我悟了:打包器的超能力是,拍平和合并模块图。得益于 esbuild,曾经由数千个文件组成的内容,被合并为有且仅有一个简单文件。这是一个有力的证明,模块图的大小是源码运行缓慢的 bug 所在。桶装文件乃速度低下的“万恶之源”。
剖析桶装文件
所谓“桶装文件”,指的是仅仅用于 export
其他文件,且本身不包含代码的文件。在编辑器支持自动 import
之前,一大坨开发者试图将它们必须手动编写的 import
语句的数量保持在最低限度。
// 瞄一眼所有这些 import import { foo } from '../foo' import { bar } from '../bar' import { baz } from '../baz'
这就产生了一种模式,其中每个文件夹都有自己的 index.js
文件,该文件通常只是从位于同一目录中的其他文件重新 export
代码。某种程度上,这分摊了手动输入工作,因为一旦这样的文件就位,所有其他代码只需要引用一个 import
语句。
// feature/index.js export * from './foo' export * from './bar' export * from './baz'
之前显示的 import
语句现在可以折叠成一行。
import { foo, bar, baz } from '../feature'
一段时间后,这种模式在整个代码库中蔓延,项目中的每个文件夹都有一个 index.js
文件。十分整洁,对不?对对对,才怪咧。
众生皆有病
在类似的设置中,模块大概率会 import
另一个桶装文件,该文件又双叒叕会拉入一大坨其他文件,然后 import
另一个桶装文件,子子孙孙无穷尽也。最终,我们通常会通过 import
语句的“蜘蛛网” import
项目中的每个文件。项目越大,加载所有这些模块就越久。
扪心自问:哪里快了?必须加载 30k 个文件更快,还是只加载 10 个更快?
JS 爱好者的“思想钢印”在于,模块只会按需加载。这大错特错,因为这样做会破坏依赖全局变量或模块执行顺序的代码。
// a.js globalThis.foo = 123 // b.js console.log(globalThis.foo) // 应该打印: 123 // index.js import './a' import './b'
如果引擎无法加载首个 ./a
导入,那么代码会意外打印 undefined
而不是 123
。
桶装文件的性能瓶颈
当我们考虑使用测试运行程序等工具时,情况会雪上加霜。在人气爆棚的 Jest 测试运行器中,每个测试文件都在其子进程中执行。实际上,这意味着,每个测试文件都从零开始构建模块图,并且必须支付该成本。如果在项目中构建模块图需要 6 秒,并且您只有 100 个测试文件,那么您总共会浪费 10 分钟,重复构建模块图。在此期间不会运行任何测试或其他代码。这正是引擎需要准备源码以便运行的时候。
桶装文件作为“性能杀手”的另一个法外之地是,任何类型的 import
周期 linting 规则。通常,linter 按逐个文件运行,这意味着,需要为每个文件支付构建模块图的成本。仅此一点就会导致 linting 时间爆表,并且在较大的项目中可能需要几小时,这种情况司空见惯。
为了获得某些原始数据,我生成了一个项目,其中的文件相互 import
,以便更好地了解构建模块图的成本。每个文件都空空如也,除了 import
语句外,没有包含任何代码。计时是在我的“量子计算机”上测量的。
如你所见,加载更少的模块十分值得。让我们将这些数字应用到一个包含 100 个测试文件的项目,其中使用测试运行程序为每个测试文件生成一个新的子进程。我们的测试运行程序可以并行运行 4 个测试:
500
个模块:0.15s * 100 / 4 = 3.75s
开销
1_000
个模块:0.31s * 100 / 4 = 7.75s
开销
25_000
个模块:16.81s * 100 / 4 = ~7:00m
开销
50_000
个模块:48.44s * 100 / 4 = ~20:00m
开销
由于这是一个综合设置,因此这些数字都是低估的。在实际项目中,这些数字可能更糟糕。就工具性能而言,桶装文件并不友好。
我们该咋办?
代码中只有少量桶装文件通常问题不大,但当每个文件夹都有就会出现问题。不幸的是,这种灾难在 JS 行业屡见不鲜。
因此,如果大家从事的项目广泛使用桶装文件,那么可以免费优化:清空所有桶装文件,使一大坨任务速度加快 60-80%。
免责声明
本文属于是语冰的直男翻译了属于是,略有删改,仅供粉丝参考,英文原味版请传送 Speeding up the JavaScript ecosystem - The barrel file debacle[1]。
以上就是JS 生态系统加速桶装文件使用探索的详细内容,更多关于JS 桶装文件的资料请关注脚本之家其它相关文章!