本节目标:建立一套可重复的构建诊断流程。你会知道构建时间花在哪些插件上、如何用可视化工具定位体积大户、怎么把体积预算变成 CI 门禁,以及依赖替换、按需引入、压缩器与
target取舍、长期缓存命名这四条治理手段各自的适用边界。
15.3 构建性能诊断与包体积治理
前两节讲的是「怎么构建」和「怎么切分」。这一节处理工程里最磨人的两类抱怨:CI 里 vite build 越来越慢,以及首屏体积悄悄涨了一倍。
两者往往同源:一个体积巨大的依赖既拖慢构建,也拖慢下载。所以诊断的顺序应当是先测量,再归因,最后才动手——跳过测量直接换工具,通常只是把问题挪了个地方。
15.3.1 先测量:构建耗时拆解
Vite 提供了 --debug 开关,它会把各插件的 transform 与 renderChunk 耗时打出来:
$ vite build --debug 2>&1 | grep -E "transform|renderChunk" | sort -k3 -n | tail -10
vite:react-babel transform 812ms src/components/Editor.tsx
vite:esbuild transform 431ms node_modules/.vite/deps/echarts.js
vite:css transform 188ms src/styles/global.css
vite:terser renderChunk 1204ms dist/assets/charts-Dk92mP.js
这张表已经能回答大部分问题。上面的输出里 vite:terser 占了 1.2 秒——压缩器是主要成本,而不是编译。此时可选的方向是把 minify 从 terser 换回默认的 esbuild,或用 build.minify: false 快速验证瓶颈归属:
import { defineConfig } from 'vite';
export default defineConfig({
build: {
minify: 'esbuild', // 默认值,比 terser 快一个量级
reportCompressedSize: false, // 跳过多余的 gzip 统计,构建更快
chunkSizeWarningLimit: 800, // 单位 kB,只是阈值,不改变产物
},
});
另一个常被忽略的成本来源是依赖预构建。dev 首次启动时 Vite 用 esbuild 把 node_modules 里的依赖打成 ESM 并缓存到 node_modules/.vite。如果每次启动都在重新预构建,通常是依赖列表在抖:
export default defineConfig({
optimizeDeps: {
include: ['echarts/core', 'echarts/charts', 'lodash-es'],
exclude: ['some-linked-workspace-pkg'],
},
});
include 用于那些被动态引用、扫描不到的依赖;exclude 用于本地 workspace 包(它们是源码,本就该走 Vite 的转译管线)。预构建缓存的机制见站内 Vite 依赖预构建机制
与 Vite 构建缓存
。
15.3.2 可视化:包体积分析
测完时间测体积。构建产物的 treemap 分析用 rollup-plugin-visualizer:
import { defineConfig } from 'vite';
import { visualizer } from 'rollup-plugin-visualizer';
export default defineConfig({
plugins: [
visualizer({ filename: 'stats.html', gzipSize: true, brotliSize: true }),
],
});
构建后打开 stats.html,注意两点:按 gzip 排序而不是原始体积,以及区分「谁引入的」。treemap 里最大的一块往往不是你以为的那个包——常见的意外是 moment 的语言包、echarts 的全量引入、或者被两个依赖各自打包了一份的 lodash。
体积膨胀最常见的三种形态:
| 形态 | 症状 | 处置 |
|---|---|---|
| 整包引入 | treemap 里一个包占 30%+ | 改按需引入或换包 |
| 重复打包 | 同一包出现多份 | 统一版本,或用 resolve.dedupe |
| 多语言/多格式 | 一堆 locale/*.js | 只保留需要的语言 |
第二条值得展开。两个依赖各自依赖了不同大版本的同一个包时,npm 会在 node_modules 里放两份,Rollup 也会打包两份:
$ npm ls lodash
├─┬ lib-a@1.2.0
│ └── lodash@4.17.20
└─┬ lib-b@3.0.1
└── lodash@4.17.21 # 重复
export default defineConfig({
resolve: { dedupe: ['lodash', 'react', 'react-dom'] },
});
15.3.3 体积预算与 CI 门禁
体积治理最难的不是优化一次,而是防止回涨。做法是把预算写成断言,让超标的 PR 直接失败:
// scripts/check-size.ts
import { gzipSync } from 'node:zlib';
import { readFileSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
interface Budget { [chunk: string]: number }
const BUDGET_KB: Budget = { 'index': 180, 'vendor': 220, 'charts': 140 };
const ASSETS = 'dist/assets';
function gzipKb(file: string): number {
return gzipSync(readFileSync(file)).byteLength / 1024;
}
let failed = false;
for (const name of readdirSync(ASSETS)) {
if (!name.endsWith('.js')) continue;
const key = name.split('-')[0] ?? name;
const limit = BUDGET_KB[key];
if (limit === undefined) continue;
const actual = gzipKb(join(ASSETS, name));
const status = actual > limit ? 'FAIL' : 'ok';
if (actual > limit) failed = true;
console.log(`${status} ${name} ${actual.toFixed(1)} kB / ${limit} kB`);
}
process.exit(failed ? 1 : 0);
$ node scripts/check-size.ts
ok index-Cq3x8K.js 176.2 kB / 180 kB
FAIL vendor-Dk92mP.js 241.7 kB / 220 kB
注意脚本按 name.split('-')[0] 提取 chunk 名——这依赖下一小节要讲的命名约定。放进 CI 只需要一行:
- run: pnpm build && node scripts/check-size.ts
门禁的意义在于让体积变化在评审里可见。没有它,优化成果会在半年后的一次依赖升级里悄悄还回去。
15.3.4 依赖替换与按需引入
四条治理手段里,替换依赖收益最大、成本也最高。按性价比排序:
| 常见依赖 | 替代方案 | 典型节省 |
|---|---|---|
moment | dayjs / date-fns | 200 KB+ |
lodash | lodash-es 按需 / 原生方法 | 50–70 KB |
axios | 原生 fetch | 15 KB |
uuid | crypto.randomUUID() | 10 KB |
echarts 全量 | echarts/core + 按需注册 | 300 KB+ |
lodash-es 的按需引入要点是不要从包根导入:
import _ from 'lodash-es'; // 整包,摇树受限
import { debounce } from 'lodash-es'; // 可摇,只留 debounce
echarts 的按需引入需要显式注册用到的图表与组件:
import * as echarts from 'echarts/core';
import { LineChart } from 'echarts/charts';
import { GridComponent, TooltipComponent } from 'echarts/components';
import { CanvasRenderer } from 'echarts/renderers';
echarts.use([LineChart, GridComponent, TooltipComponent, CanvasRenderer]);
引入这些包时类型会跟着一起进来,echarts.use 的参数是组件构造器数组,写错名字会报 TS2345。
替换依赖的顺序建议是:先查 treemap 确认它是大户,再评估替换成本,最后一次性替换并跑一遍体积门禁。不要为了省 5 KB 引入一个不熟悉的库。
15.3.5 压缩器与 target 取舍
build.target 决定了语法降级的程度。默认值 'modules' 面向支持原生 ESM 的浏览器,但如果你为了兼容旧环境把它调到 es2015,esbuild 会把 ?.、??、async/await 全部展开成辅助函数——体积和运行速度双输:
export default defineConfig({
build: {
target: 'es2022', // 现代浏览器;旧环境用 plugin-legacy 单独出包
cssTarget: 'chrome110',
minify: 'esbuild',
sourcemap: 'hidden', // 生成但不引用,便于错误上报
},
});
确实要兼容旧浏览器时,用 @vitejs/plugin-legacy 生成一份带 polyfill 的降级包,让现代浏览器走主包:
import legacy from '@vitejs/plugin-legacy';
export default defineConfig({
plugins: [legacy({ targets: ['defaults', 'not IE 11'] })],
});
另一个小收益点是把调试语句在构建期去掉,而不是靠压缩器猜:
export default defineConfig({
esbuild: { drop: process.env.NODE_ENV === 'production' ? ['console', 'debugger'] : [] },
});
drop: ['console'] 会删掉所有 console.* 调用——上线前请确认错误上报不依赖 console.error。
15.3.6 长期缓存与 chunk 命名
体积优化最终要通过缓存变现。Vite 默认给产物加内容哈希,但默认命名不便于按 chunk 做预算,也容易在依赖顺序变化时让 hash 抖动。显式约定命名:
export default defineConfig({
build: {
rollupOptions: {
output: {
entryFileNames: 'assets/[name]-[hash].js',
chunkFileNames: 'assets/[name]-[hash].js',
assetFileNames: 'assets/[name]-[hash][extname]',
manualChunks: {
vendor: ['react', 'react-dom'],
charts: ['echarts/core'],
},
},
},
},
});
三条经验:入口与 chunk 用同一套命名模板,脚本才好统一解析;[name] 放在哈希前,才能用 split('-')[0] 提取 chunk 名;业务代码与第三方依赖分开命名,让依赖升级不影响业务 chunk 的 hash。
配合 Cache-Control: public, max-age=31536000, immutable 的强缓存,用户只需在发版后重新下载真正变化的 chunk。这一步与部署配置衔接,见 《TypeScript编程实战》18.1 Docker 与 CI/CD 流水线
。
15.3.7 dev 阶段与类型检查的增量性能
生产构建慢是一半问题,开发体验慢是另一半。dev 阶段的耗时主要花在首次预构建与首次请求某个页面时的转译上,两者都可以预热:
export default defineConfig({
server: {
warmup: {
clientFiles: ['./src/main.tsx', './src/routes/**/*.tsx'],
},
},
});
warmup 会让 dev server 在启动后立刻转译这些文件,把「第一次点开页面等两秒」变成「启动时多花几百毫秒」。它只影响 dev,不进入产物。
依赖预构建异常时(比如改了 optimizeDeps 却仍是旧产物),用 --force 重建缓存:
$ vite --force # 忽略 node_modules/.vite 缓存,重新预构建依赖
另一半成本在类型检查。tsc -b --noEmit 之所以快,是因为它用了项目引用与增量构建信息:
{
"compilerOptions": {
"composite": true,
"incremental": true,
"tsBuildInfoFile": "./node_modules/.cache/tsconfig.app.tsbuildinfo"
},
"include": ["src"]
}
两个常见错误会直接毁掉增量收益:一是把 include 写成 ["**/*"],把 dist、node_modules 也扫进来;二是清理 CI 缓存时把 .tsbuildinfo 一起删了。前者让每次检查都全量跑,后者让 CI 每次都从零开始。
$ time tsc -b --noEmit
real 0m12.4s # 首次,无缓存
$ time tsc -b --noEmit
real 0m1.1s # 改一个文件后,命中增量信息
CI 里保留 node_modules/.cache 与 node_modules/.vite 两个目录,能让构建与类型检查都吃到缓存。这一步通常挂在流水线的缓存配置里,见 《TypeScript编程实战》18.1 Docker 与 CI/CD 流水线
。
15.3.8 七条检查清单
一、构建脚本里有没有 tsc -b --noEmit。 没有的话,构建快得毫无意义。
二、vite build --debug 的耗时大头是谁。 是压缩器还是某个插件,处置方式完全不同。
三、treemap 里最大的三块是什么。 按 gzip 看,别按原始体积看。
四、有没有重复打包的依赖。 用 npm ls <pkg> 检查,配 resolve.dedupe。
五、target 是不是被无谓调低了。 降级会显著增肥。
六、体积门禁在 CI 里吗。 没有门禁的优化会回涨。
七、chunk 命名是否稳定。 hash 抖动会让缓存全量失效。
15.3.9 与本书其它章节的衔接
本节的分包策略承接 《TypeScript编程实战》15.2 代码分割与 tree-shaking ;构建管线的配置基础见 《TypeScript编程实战》15.1 Vite 与 TS 集成 ;产物大小之外,运行时性能的观测见 《TypeScript编程实战》17.2 指标与告警 ;第三方依赖本身的安全与许可风险见 《TypeScript编程实战》17.3 依赖供应链与应用安全加固 。
站内延伸阅读:Vite 包体积分析与性能 、Vite 构建优化 、TypeScript 构建性能优化 、前端性能调试 、Core Web Vitals 与前端性能 。
小结
构建诊断的顺序是测量、归因、再动手。测量用 vite build --debug 拆出插件耗时,用 rollup-plugin-visualizer 拆出体积构成;归因看 gzip 体积与「谁引入的」,重复打包与整包引入是最常见的两种形态;动手时四条手段按性价比排序——依赖替换收益最大,按需引入成本最低,压缩器与 target 是配置层的快速收益,chunk 命名与长期缓存负责把优化结果固化下来。
最重要的是把预算写进 CI。没有门禁的体积优化,会在下一次依赖升级里被悄悄还回去。
本章到此结束。我们沿着「转译与检查分离 → 分割与摇树 → 诊断与门禁」这条线,把前端交付的最后一公里走完了。下一章换到前后端交界处:当接口契约不再靠文档约定,而是由类型系统端到端保证时,工程上会发生什么变化——这正是 tRPC 与代码生成要解决的问题。
阅读导航:上一节:15.2 代码分割与 tree-shaking · 下一节:16.1 tRPC 端到端类型安全 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。