LOG // 047 · Build · Performance

我把项目构建从 47 秒压到 8 秒,只改了三个文件

结论先行构建慢很少是打包器的锅。这次 47 秒里有 31 秒花在同一个依赖被反复解析七次上。 锁定入口、拆掉巨型 vendor 包、补一份预构建清单,三个文件解决,不需要换工具。

发布会前四十分钟,我点了第五次构建。进度条卡在 building dependencies, 终端一动不动。那一刻我意识到:这两年来我一直在猜它为什么慢,从来没量过。

01先别动手,先量

任何优化的第一步都不是改配置,而是拿到一条可复现的基线。我用了一个很土但有效的办法: 在每个阶段打时间戳,跑三次取中位数。

scripts/build-timing.mjsnode
1import { performance } from 'node:perf_hooks';
2
3const t0 = performance.now();          // 进程启动
4await resolveDeps();               // 依赖解析
5const t1 = performance.now();
6await transform();                  // 转译
7const t2 = performance.now();
8await bundle();                     // 打包
9const t3 = performance.now();
10
11console.table({
12  '依赖解析': ms(t1 - t0),   // → 31.4s  ← 就是它
13  '转译':     ms(t2 - t1),   // →  9.1s
14  '打包':     ms(t3 - t2),   // →  6.7s
15});
Tip

跑三次取中位数。第一次永远包含冷缓存,取第一次会让你优化一个不存在的问题。

02元凶:同一个包被解析了七次

有了数字之后,剩下的是体力活。我给依赖解析器打了个计数器,结果非常难看:lodash-es 被解析了 7 次,@internal/ui 被解析了 4 次。

原因是历史遗留的三种引用写法混在一起:相对路径、包别名、以及 workspace 软链。 打包器把它们当成三个不同的模块,各自走了一遍完整的解析流程。

03改动一:锁定入口(−19s)

在构建配置里把所有内部包强制指向源码入口,只允许一种引用写法存在。

vite.config.tsts
1export default defineConfig({
2  resolve: {
3    alias: {
4      // 之前:'@ui' 指向 dist,相对路径指向 src,两套并存
5      '@ui': path.resolve(__dirname, 'packages/ui/src'),
6      '@core': path.resolve(__dirname, 'packages/core/src'),
7    },
8    dedupe: ['lodash-es', 'vue', 'react'],
9  },
10});

就这 6 行,构建从 47.2s 掉到 28.6s。dedupe 是关键——它告诉打包器 「这几个包全项目只有一份」,别再为每个子目录各解析一次。

04改动二:拆包策略(−7s)

之前所有第三方依赖被打进一个 18.4MB 的 vendor.js。它每次构建都要整体重算 hash, 而且浏览器缓存命中率低得可怜。按变更频率拆成三层之后:

vite.config.ts · rollupOptionsts
1output: {
2  manualChunks: {
3    'vendor-stable': ['react', 'react-dom'],        // 几乎不变
4    'vendor-ui':     ['@arco-design/web-react'],   // 按月变
5    'vendor-utils':  ['lodash-es', 'dayjs'],       // 偶尔变
6  },
7}
Warn

别过度拆包。拆到 12 个 chunk 的时候构建反而变慢了 3 秒——模块调度的开销超过了收益。三层是这次的甜点。

05改动三:预构建清单(−13s)

最后一个大头是那 31 秒里的残留:每次冷启动都要重新扫描一遍依赖树。 把清单写死之后,扫描这一步直接跳过。

vite.config.ts · optimizeDepsts
1optimizeDeps: {
2  // 显式列出,别让它自己扫
3  include: [
4    'react', 'react-dom', 'react-router-dom',
5    'lodash-es', 'dayjs', 'echarts',
6  ],
7  exclude: ['@internal/ui'],  // 内部包走源码,别预构建
8}

06结果对照

三次冷构建中位数 · MacBook Pro M2 / 16G
阶段优化前优化后变化
依赖解析31.4s2.1s−93%
转译9.1s4.3s−53%
打包6.7s2.0s−70%
合计47.2s8.4s−82%

顺带一个意外收获:热更新从 1.9s 降到 0.3s。这不是优化目标,但拆包之后 每次改动只需要重算一个小 chunk,顺理成章。

07三个反直觉的结论

  • 慢的部分往往不是你以为的那部分。我押注的是转译,实际大头在依赖解析。
  • 配置文件比换工具划算。这一轮没换打包器,没升级依赖,只改了三个文件。
  • 拆包有甜点,超过就亏。三层之后继续拆,构建时间反而回升。
优化的本质是减少不确定性。测量把「我觉得慢」变成「这 31 秒在解析 lodash」, 剩下的就只是体力活了。

08可复用的检查表

  1. 给构建分阶段打点,跑三次取中位数,写进 PR 描述里。
  2. 检查同名依赖是否被解析多次(dedupe 基本能解决)。
  3. 按变更频率拆 vendor,控制在三层以内。
  4. 把依赖清单写死,跳过冷启动扫描。
  5. 改完再跑一遍打点,用数字证明自己没白干。

下一篇打算写写「为什么我们的 ESLint 会拖慢 CI」,同样的思路:先量,再改。