Vite 构建产物诊断工程化:从 rollup-plugin-visualizer 到体积治理的完整链路
前言
Vite 的零配置体验让开发效率起飞,但当 npm run build 输出一个 2.8MB 的 vendor chunk 时,大多数人的第一反应是去 vite.config.ts 里乱调 manualChunks。这就像头疼吃止痛药——症状消失了,病灶还在。
本文不教你「怎么配置分包策略」,而是建立一套产物诊断的工程化方法:用工具看清依赖拓扑、用数据驱动分包决策、用 CI 门禁锁定体积基线。
一、为什么「直觉分包」是反模式
先看一个典型反例:
// ❌ 直觉分包:凭感觉拆分,缺乏数据支撑
build: {
rollupOptions: {
output: {
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-ui': ['antd'],
'vendor-utils': ['lodash', 'dayjs', 'axios']
}
}
}
}
这段配置有三个问题:
- lodash 和 dayjs 放一起——它们的引用方完全不同,打包到一起会导致路由 A 只需要 dayjs 却被迫加载整个 vendor-utils
- antd 单独分包——antd 内部引用了
@ant-design/icons、rc-*系列包,这些包可能跟其他依赖重复打包 - 完全忽略了 node_modules 内部的依赖图——
react-dom依赖scheduler,如果两个 chunk 都引用同一个包的不同版本,体积翻倍
我们需要的不是「更聪明的分包」,而是先看清再动手的流程。
二、rollup-plugin-visualizer:把产物变成可读的拓扑图
2.1 最小集成
npm i -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
react(),
visualizer({
filename: 'dist/stats.html', // 交互式 HTML 报告
gzipSize: true, // 显示 gzip 后大小
brotliSize: true, // 显示 brotli 后大小
template: 'treemap' // treemap | sunburst | network
})
]
})
构建后打开 dist/stats.html,你会看到一棵矩形树图——每个依赖的体积一目了然。
2.2 多模板对比:不同问题用不同视图
// ✅ 多模板策略:不同诊断阶段用不同视图
visualizer({
filename: 'dist/stats-treemap.html',
template: 'treemap' // 阶段一:识别体积大户
}),
visualizer({
filename: 'dist/stats-network.html',
template: 'network' // 阶段二:分析依赖拓扑,发现重复打包
}),
visualizer({
filename: 'dist/stats-sunburst.html',
template: 'sunburst' // 阶段三:给团队演示,展示层级关系
})
| 视图 | 适合场景 | 关注点 |
|---|---|---|
| treemap | 首次诊断 | 哪个包最大? |
| network | 依赖拓扑分析 | 哪个包被重复引入了? |
| sunburst | 团队演示 | 业务代码 vs 第三方库的比例? |
| raw-data | 自动化分析 | JSON 数据可用于脚本解析 |
2.3 从可视化到可操作数据
光看不行,得能自动化提取:
// ✅ 导出 JSON 原始数据,接入 CI 分析流水线
visualizer({
filename: 'dist/stats.json',
template: 'raw-data',
// 后续可在 CI 中用脚本解析 stats.json,检查体积阈值
})
三、从报告到决策:四步诊断法
拿到 treemap 报告后,按以下顺序排查:
Step 1:识别体积异常点
// ❌ 典型体积异常:引入了整个 moment.js 的语言包
import moment from 'moment'
// 引入体积 ≈ 70KB (gzip),其中 60KB 是你永远不会用的语言数据
// ✅ 方案一:使用 date-fns 的 tree-shakable 导入
import { format, differenceInDays } from 'date-fns'
// 引入体积 ≈ 3KB (gzip)
// ✅ 方案二:如果必须用 moment,配置 IgnorePlugin
// vite.config.ts
import { defineConfig } from 'vite'
export default defineConfig({
plugins: [
{
name: 'strip-moment-locales',
// 利用 rollup 插件能力剔除语言包
}
]
})
Step 2:定位重复打包
切换到 network 视图,看是否有同一个包出现在多个 chunk 中:
// ❌ 典型重复引入场景
// chunk-a.ts
import { debounce } from 'lodash-es'
// chunk-b.ts
import { throttle } from 'lodash-es'
// → lodash-es 被重复打入两个 chunk
// ✅ 方案:manualChunks 按引用频率分组
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/lodash-es')) {
return 'vendor-lodash'
}
if (id.includes('node_modules')) {
// 其他依赖:按包名自动分组
return id.toString().split('node_modules/')[1]
.split('/')[0]
}
}
}
}
}
Step 3:分析 chunk 引用链路
一个 chunk 被多少路由引用,决定了它是否应该独立存在。
// ✅ 利用 Vite 的 code-splitting 元信息
// 如果某个 chunk 仅被一个懒加载路由引用 → 可以保持独立
// 如果某个 chunk 被 3 个以上路由引用 → 应该提升到共享层
import { defineConfig } from 'vite'
export default defineConfig({
build: {
rollupOptions: {
output: {
// 当模块被 ≥3 个 chunk 引用时,提取为共享 chunk
experimentalMinChunkSize: 20_000 // 20KB,过小的共享 chunk 不值得
}
}
}
})
Step 4:验证优化效果
# 构建后对比体积
ls -lh dist/assets/*.js | awk '{print $5, $NF}'
# 或者使用更精确的脚本
npx vite-bundle-visualizer
四、CI 门禁:不让体积回归
一次性优化没有意义。关键是把体积指标固化为 CI 检查项。
4.1 体积基线文件
// .bundle-baseline.json —— 提交到 Git
{
"total": { "raw": 524288, "gzip": 157286 },
"chunks": {
"vendor-react": { "raw": 120000, "gzip": 40000 },
"vendor-lodash": { "raw": 24000, "gzip": 8000 },
"index": { "raw": 85000, "gzip": 28000 }
},
"threshold": {
"totalGzip": { "max": 200000, "maxIncreasePercent": 10 },
"singleChunkGzip": { "max": 80000 }
}
}
4.2 CI 检查脚本
// scripts/check-bundle-size.ts —— 在 CI 中执行
import { readFileSync, existsSync } from 'fs'
import { gzipSync } from 'zlib'
import { globSync } from 'glob'
interface Baseline {
total: { raw: number; gzip: number }
chunks: Record<string, { raw: number; gzip: number }>
threshold: {
totalGzip: { max: number; maxIncreasePercent: number }
singleChunkGzip: { max: number }
}
}
interface BuildStats {
total: { raw: number; gzip: number }
chunks: Record<string, { raw: number; gzip: number }>
}
function getBuildStats(): BuildStats {
const chunks: BuildStats['chunks'] = {}
let totalRaw = 0
let totalGzip = 0
for (const file of globSync('dist/assets/*.js')) {
const raw = readFileSync(file)
const gzip = gzipSync(raw)
const name = file.replace('dist/assets/', '').replace(/\-[a-f0-9]+\.js$/, '.js')
chunks[name] = { raw: raw.length, gzip: gzip.length }
totalRaw += raw.length
totalGzip += gzip.length
}
return { total: { raw: totalRaw, gzip: totalGzip }, chunks }
}
function check(baseline: Baseline, current: BuildStats): boolean {
let pass = true
// 检查总体积
const totalIncrease = ((current.total.gzip - baseline.total.gzip) / baseline.total.gzip) * 100
if (totalIncrease > baseline.threshold.totalGzip.maxIncreasePercent) {
console.error(`❌ 总体积增长 ${totalIncrease.toFixed(1)}%,超过阈值 ${baseline.threshold.totalGzip.maxIncreasePercent}%`)
pass = false
}
if (current.total.gzip > baseline.threshold.totalGzip.max) {
console.error(`❌ 总体积 ${(current.total.gzip / 1024).toFixed(1)}KB 超过上限 ${(baseline.threshold.totalGzip.max / 1024).toFixed(1)}KB`)
pass = false
}
// 检查单个 chunk
for (const [name, stats] of Object.entries(current.chunks)) {
if (stats.gzip > baseline.threshold.singleChunkGzip.max) {
console.error(`❌ Chunk ${name}: ${(stats.gzip / 1024).toFixed(1)}KB 超过单文件上限 ${(baseline.threshold.singleChunkGzip.max / 1024).toFixed(1)}KB`)
pass = false
}
}
// 检查是否有新的巨型依赖
const baselineChunks = new Set(Object.keys(baseline.chunks))
for (const name of Object.keys(current.chunks)) {
if (!baselineChunks.has(name) && current.chunks[name].gzip > 30_000) {
console.warn(`⚠️ 新增 chunk ${name}: ${(current.chunks[name].gzip / 1024).toFixed(1)}KB——请更新基线文件`)
}
}
return pass
}
// Main
const baselinePath = '.bundle-baseline.json'
if (!existsSync(baselinePath)) {
console.log('⚠️ 基线文件不存在,跳过检查。运行 npm run build:baseline 生成。')
process.exit(0)
}
const baseline: Baseline = JSON.parse(readFileSync(baselinePath, 'utf-8'))
const current = getBuildStats()
console.log(`📊 当前构建:总 Gzip ${(current.total.gzip / 1024).toFixed(1)}KB`)
console.log(`📊 基线构建:总 Gzip ${(baseline.total.gzip / 1024).toFixed(1)}KB`)
if (!check(baseline, current)) {
console.error('\n构建体积检查未通过!请查看上方报告并优化。')
process.exit(1)
}
console.log('✅ 构建体积检查通过')
4.3 GitHub Actions 集成
# .github/workflows/bundle-check.yml
name: Bundle Size Check
on: [pull_request]
jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: 'npm'
- run: npm ci
- run: npm run build
- run: npx tsx scripts/check-bundle-size.ts
五、实战案例:一个真实项目的诊断与治理
以一个中型后台管理项目为例,展示完整的诊断→优化→固化流程。
初始状态
构建产物(gzip):
├── index.js 45KB
├── vendor.js 520KB ← 问题所在
└── 懒加载路由 chunk × 8 ~180KB
总计:~745KB
诊断过程
Step 1 — treemap 视图发现:vendor.js 中 antd 占 320KB,moment 占 65KB,lodash 占 40KB。
Step 2 — network 视图发现:两个懒加载路由各自打入了一份 @ant-design/icons(约 45KB/份),因为 antd 的日期组件间接引用了 moment。
Step 3 — 决策:
// 1. 替换 moment → date-fns(节省 62KB)
// 2. antd 按需导入图标(节省 90KB 重复)
// 3. 将 antd 拆分为独立 chunk,避免打入 vendor.js
build: {
rollupOptions: {
output: {
manualChunks(id) {
if (id.includes('node_modules/antd')) return 'antd'
if (id.includes('node_modules/@ant-design')) return 'antd-icons'
if (id.includes('node_modules/react')) return 'react-vendor'
if (id.includes('node_modules')) {
return id.split('node_modules/')[1].split('/')[0]
}
}
}
}
}
优化结果
优化后构建产物(gzip):
├── index.js 38KB
├── react-vendor.js 42KB
├── antd.js 110KB
├── antd-icons.js 12KB
├── date-fns.js 3KB
└── 其他 chunk × 7 ~110KB
总计:~315KB ← 下降 57.7%
六、建立团队的产物治理 SOP
最后,把诊断方法固化到团队流程中:
## 产物治理检查清单
### 每次 PR 之前
- [ ] `npm run build` 无报错
- [ ] 查看 `dist/stats.html`,确认无异常体积占比
- [ ] bundle-check CI 通过
### 每月例行
- [ ] 对比本月与上月基线,标记增长 >5% 的依赖
- [ ] 评估是否有体积更小的替代库(如 lodash → lodash-es, moment → date-fns)
- [ ] 检查是否有可移除的未使用依赖(npx depcheck)
### 引入新依赖时
- [ ] 使用 `bundlephobia.com` 查看体积
- [ ] 更新 `.bundle-baseline.json`(运行 npm run build:baseline)
- [ ] 在 PR 描述中附上 `stats.html` 截图,标注新依赖的体积贡献
总结
构建产物体积优化的本质不是「调配置」,而是一个可观测、可决策、可回溯的工程化闭环:
可视化诊断(visualizer) → 数据驱动决策(treemap + network)
→ 策略实施(manualChunks + 替代方案)
→ CI 门禁(bundle-check) → 基线回溯
下次当你的 vendor.js 超过 500KB 时,不要急着改 manualChunks。先打开 dist/stats-treemap.html,让数据告诉你真相。
评论区
登录 后参与评论