跳到主内容

从 3.2MB 到 800KB:前端包体积治理实战 ​

问题背景:文档站首屏被 3.2MB 产物拖垮 ​

团队基于 Vue3 + VitePress 维护组件文档站,某次迭代后 Lighthouse 性能评分从 92 掉到 61,LCP 超过 3.5s。排查网络面板发现首屏加载了一个 1.8MB 的 JS chunk 和若干未使用的图标字体。问题不在业务代码复杂度,而在依赖被「整包引入」后无人治理:UI 库全量注册、lodash 整包导入、图标库按包名引入、历史组件删了但 re-export 仍在。

包体积治理不能靠直觉,必须遵循「测量 → 定位 → 治理 → 回归」的闭环。本文记录把构建产物从 3.2MB 压到约 800KB(gzip 前)的完整过程,核心手段是 bundle 分析、Tree Shaking 清理与按需引入。

方案对比:四种体积治理路径怎么选 ​

方案收益改造成本适用场景
bundle 可视化分析定位问题,不直接减体积低所有项目第一步
Tree Shaking 清理中高,删除未引用导出低-中ESM 项目、库存在死代码
按需引入高,避免整包进入产物中UI 库、图标库、工具库
代码分割与预加载改善首屏,总量未必降中路由多、首屏关键路径长

实际项目中三者是叠加关系:先用分析工具找到「大块头」,再判断它属于死代码(Tree Shaking)还是错误引入方式(按需引入),最后用分包避免单 chunk 过大。

实操一:用 visualizer 做 bundle 分析 ​

在 Vite 或 VitePress 中接入 rollup-plugin-visualizer,构建后生成可交互的 treemap,精确看到每个模块占用。

javascript
// vite.config.js
import { defineConfig } from 'vite';
import vue from '@vitejs/plugin-vue';
import { visualizer } from 'rollup-plugin-visualizer';

export default defineConfig({
  plugins: [
    vue(),
    visualizer({
      filename: 'stats.html',     // 分析报告输出位置
      open: false,                // CI 中不要自动打开
      gzipSize: true,             // 同时显示 gzip 后体积
      brotliSize: true,
      template: 'treemap',
    }),
  ],
  build: {
    sourcemap: false, // 生产环境关闭,避免 sourcemap 被计入发布物
  },
});

执行 npm run build 后打开 stats.html,重点看三类信息:

  1. node_modules 占比:若超过 70%,优先治理第三方依赖;
  2. 重复依赖:同一包出现多个版本(如 lodash 与 lodash-es 共存);
  3. 异常大模块:单个组件或工具文件超过 100KB 就要点进去看引用链。

本次分析直接定位到三个元凶:element-plus 全量样式约 620KB、lodash 整包 244KB、@iconify/json 全量图标数据近 900KB。

构建产物3.2MBvisualizer定位大模块Tree Shaking按需引入治理后约 800KB测量 → 定位 → 分类治理 → 体积回归校验

实操二:让 Tree Shaking 真正生效 ​

Tree Shaking 依赖 ESM 静态结构,且需要正确声明副作用。很多项目「以为摇掉了」,实际产物里死代码还在,常见原因是 sideEffects 配置缺失或 CommonJS 导出。

使用命名导入替换整包导入 ​

javascript
// ❌ 错误:整包进入 chunk,未使用方法也可能保留
import _ from 'lodash';
console.log(_.debounce(fn, 300));

// ✅ 正确:ESM 命名导入,打包器可静态分析
import { debounce } from 'lodash-es';
console.log(debounce(fn, 300));

Vite 生产构建默认使用 esbuild 进行语法转换、Rollup 做 Tree Shaking。需确保 vite.config.js 中没有把 CommonJS 选项误开,且依赖优先选择 ESM 版本(lodash-es 而非 lodash)。

正确声明 package.json 的 sideEffects ​

如果是维护组件库,必须告诉打包器哪些文件有副作用,否则 CSS 可能被误删:

json
{
  "name": "@scope/ui-kit",
  "module": "dist/index.mjs",
  "sideEffects": ["**/*.css", "**/*.vue"]
}

业务项目一般不需要改根 package.json,但要避免在入口文件写顶层副作用代码(如未使用的 polyfill、立即执行的埋点),这类代码即使导出没人用也会被保留。

清理 barrel 文件的隐性依赖 ​

export * from './components' 这类桶文件在 VitePress 中很常见。Rollup 多数情况下能摇掉未用导出,但当中间文件含副作用或使用 export default 聚合对象时,整组组件会被保留。建议:

typescript
// ❌ 聚合对象会破坏静态分析
export default { Button, Input, Table, Form, Dialog };

// ✅ 保持命名导出,让未用组件可被摇掉
export { default as UiButton } from './button.vue';
export { default as UiInput } from './input.vue';

实操三:UI 库、图标与工具库按需引入 ​

Element Plus 按需引入 ​

不要 app.use(ElementPlus) 全量注册,使用 unplugin-vue-components 自动按需:

javascript
// vite.config.js
import Components from 'unplugin-vue-components/vite';
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers';

export default {
  plugins: [
    Components({
      resolvers: [
        ElementPlusResolver({ importStyle: 'css' }), // 自动引入组件对应样式
      ],
      dts: 'types/components.d.ts',
    }),
  ],
};

模板里直接写 <el-button> 即可,插件会转换为对应组件与样式的精准导入,未使用的 60+ 组件不再进入产物。

图标按需加载,禁止全量 JSON ​

@iconify/json 包含所有图标集,整包引入是体积灾难。文档站推荐运行时按需 API 或构建期按需插件:

javascript
// vite.config.js
import Icons from 'unplugin-icons/vite';
import IconsResolver from 'unplugin-icons/resolver';

export default {
  plugins: [
    Icons({ compiler: 'vue3', autoInstall: true }),
    Components({
      resolvers: [IconsResolver({ prefix: 'i' })],
    }),
  ],
};
vue
<template>
  <!-- 仅这一个图标的 SVG 数据会被打包 -->
  <i-mdi-account-circle class="icon" />
</template>

<style scoped>
.icon { width: 20px; height: 20px; color: #6366f1; }
</style>

手动分包控制首屏关键路径 ​

分析后若发现 vendor chunk 仍偏大,可结合路由懒加载与 manualChunks 拆分:

javascript
// vite.config.js
export default {
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vendor-vue': ['vue', 'vue-router'],
          'vendor-ui': ['element-plus'],
          'vendor-utils': ['lodash-es', 'dayjs'],
        },
      },
    },
  },
};

注意 manualChunks 只改变 chunk 分布,不会减少总体积;它的价值是利用缓存与延后加载,不要把它当成瘦身手段。

治理效果与回归校验 ​

完成上述改造后重新构建:主 chunk 从 1.8MB 降到 320KB,总体积 3.2MB → 约 800KB,降幅约 75%;gzip 后首屏 JS 约 210KB,LCP 回到 1.4s,Lighthouse 性能分恢复到 96。

为防止反弹,在 CI 中加入体积阈值校验:

javascript
// scripts/bundle-limit.js
// 读取构建产物,超过阈值则失败,配合 GitHub Actions 执行
import { readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';

const LIMIT_KB = 900; // gzip 前总预算
const distDir = 'dist/assets';

const total = readdirSync(distDir)
  .filter((f) => /\.(js|css)$/.test(f))
  .reduce((sum, f) => sum + statSync(join(distDir, f)).size, 0);

const kb = total / 1024;
console.log(`产物体积:${kb.toFixed(1)}KB / 预算 ${LIMIT_KB}KB`);
if (kb > LIMIT_KB) {
  console.error('❌ 包体积超预算,请检查是否有整包引入');
  process.exit(1);
}
json
{

<!-- 内容间隙推广位:正文之后内联渲染,不遮挡阅读;变现开关关闭时零渲染零占位 -->
<MonetizeSlot position="inline" domain="frontend" />

<!-- 免责声明:文字与 data/site.config.yaml 的 disclaimer 保持完全一致,不得删改 -->
::: warning 免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。
:::