发布会前四十分钟,我点了第五次构建。进度条卡在 building dependencies, 终端一动不动。那一刻我意识到:这两年来我一直在猜它为什么慢,从来没量过。
01先别动手,先量
任何优化的第一步都不是改配置,而是拿到一条可复现的基线。我用了一个很土但有效的办法: 在每个阶段打时间戳,跑三次取中位数。
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});
跑三次取中位数。第一次永远包含冷缓存,取第一次会让你优化一个不存在的问题。
02元凶:同一个包被解析了七次
有了数字之后,剩下的是体力活。我给依赖解析器打了个计数器,结果非常难看:lodash-es 被解析了 7 次,@internal/ui 被解析了 4 次。
原因是历史遗留的三种引用写法混在一起:相对路径、包别名、以及 workspace 软链。 打包器把它们当成三个不同的模块,各自走了一遍完整的解析流程。
03改动一:锁定入口(−19s)
在构建配置里把所有内部包强制指向源码入口,只允许一种引用写法存在。
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, 而且浏览器缓存命中率低得可怜。按变更频率拆成三层之后:
1output: {
2 manualChunks: {
3 'vendor-stable': ['react', 'react-dom'], // 几乎不变
4 'vendor-ui': ['@arco-design/web-react'], // 按月变
5 'vendor-utils': ['lodash-es', 'dayjs'], // 偶尔变
6 },
7}
别过度拆包。拆到 12 个 chunk 的时候构建反而变慢了 3 秒——模块调度的开销超过了收益。三层是这次的甜点。
05改动三:预构建清单(−13s)
最后一个大头是那 31 秒里的残留:每次冷启动都要重新扫描一遍依赖树。 把清单写死之后,扫描这一步直接跳过。
1optimizeDeps: {
2 // 显式列出,别让它自己扫
3 include: [
4 'react', 'react-dom', 'react-router-dom',
5 'lodash-es', 'dayjs', 'echarts',
6 ],
7 exclude: ['@internal/ui'], // 内部包走源码,别预构建
8}
06结果对照
| 阶段 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 依赖解析 | 31.4s | 2.1s | −93% |
| 转译 | 9.1s | 4.3s | −53% |
| 打包 | 6.7s | 2.0s | −70% |
| 合计 | 47.2s | 8.4s | −82% |
顺带一个意外收获:热更新从 1.9s 降到 0.3s。这不是优化目标,但拆包之后 每次改动只需要重算一个小 chunk,顺理成章。
07三个反直觉的结论
- 慢的部分往往不是你以为的那部分。我押注的是转译,实际大头在依赖解析。
- 配置文件比换工具划算。这一轮没换打包器,没升级依赖,只改了三个文件。
- 拆包有甜点,超过就亏。三层之后继续拆,构建时间反而回升。
优化的本质是减少不确定性。测量把「我觉得慢」变成「这 31 秒在解析 lodash」, 剩下的就只是体力活了。
08可复用的检查表
- 给构建分阶段打点,跑三次取中位数,写进 PR 描述里。
- 检查同名依赖是否被解析多次(
dedupe基本能解决)。 - 按变更频率拆 vendor,控制在三层以内。
- 把依赖清单写死,跳过冷启动扫描。
- 改完再跑一遍打点,用数字证明自己没白干。
下一篇打算写写「为什么我们的 ESLint 会拖慢 CI」,同样的思路:先量,再改。