前端开发··1 阅读·预计 24 分钟

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']
      }
    }
  }
}

这段配置有三个问题:

  1. lodash 和 dayjs 放一起——它们的引用方完全不同,打包到一起会导致路由 A 只需要 dayjs 却被迫加载整个 vendor-utils
  2. antd 单独分包——antd 内部引用了 @ant-design/iconsrc-* 系列包,这些包可能跟其他依赖重复打包
  3. 完全忽略了 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,让数据告诉你真相。

0 评论

评论区

登录 后参与评论