Vite 构建优化:代码分割与打包分析

1. 为什么前端包体积重要

Java 后端一个 jar 50MB 无所谓,内网速度足够。前端每 KB 都决定用户体验:

目标参考

in4vue 有 Element Plus + ECharts + Vue + 大堆第三方库,不优化首屏能到 1MB+。优化后能压到 150KB 以内。

Java 对照:类似你用 Spring Boot 的 -Dspring.profiles.include=minimal 出瘦身 jar,或者手写 <exclusions> 排包。前端工具链自动化程度高很多。


2. Vite 构建产物结构

pnpm build 后:

dist/
├── index.html
├── assets/
│   ├── index-a1b2c3d4.js           ← 主入口(包含 main.ts 的代码)
│   ├── index-a1b2c3d4.css
│   ├── vendor-x1y2z3w4.js          ← 第三方库(如果你配了)
│   ├── HomePage-f5g6h7i8.js        ← 懒加载的页面 chunk
│   ├── NoteDetail-j9k0l1m2.js
│   └── logo-n3o4p5q6.png

每个文件名都带 hash:内容变了 hash 才变,利于长期缓存。生产 Nginx 的 Cache-Control: max-age=31536000 就安全。


3. 路由懒加载:第一刀砍掉最多

默认 import 会把所有页面打进主 bundle,首屏就要下所有。改成 () => import(...)

// src/router/index.ts

// ❌ 所有页面一次全加载
import Home from '@/pages/Home.vue'
import NoteList from '@/pages/NoteList.vue'
import NoteDetail from '@/pages/NoteDetail.vue'

const routes = [
  { path: '/', component: Home },
  { path: '/notes', component: NoteList },
  { path: '/notes/:id', component: NoteDetail },
]

// ✅ 首屏只加载当前页面
const routes = [
  { path: '/', component: () => import('@/pages/Home.vue') },
  { path: '/notes', component: () => import('@/pages/NoteList.vue') },
  { path: '/notes/:id', component: () => import('@/pages/NoteDetail.vue') },
]

这一步通常能把首屏包砍掉 30-60%。Vite 自动把每个 import() 拆成独立 chunk,访问对应路由时才下载。

3.1 预加载:提前下一步可能要的

用户进首页后大概率会点"笔记列表"——在首页空闲时提前下载它:

// 用 Vue Router 的 beforeEach 里预加载相关路由
router.beforeEach(async (to) => {
  if (to.path === '/') {
    // 预加载笔记列表,不 await
    import('@/pages/NoteList.vue')
  }
})

或用 Vite 的 /* webpackPrefetch: true */ 注释(Vite 里用 webpackPrefetch 只是 hint,浏览器行为相似)。

Java 对照:类似 Spring Boot 的 lazy initialization + 手动触发 —— 按需加载 + 预测性预热。


4. 第三方库拆分:manualChunks

路由懒加载后,还会看到主 bundle 里塞着 Vue、Vue Router、Pinia、Element Plus 这些"几乎不变"的库。分离它们:

// vite.config.ts
export default defineConfig({
  build: {
    rollupOptions: {
      output: {
        manualChunks: {
          'vue-core': ['vue', 'vue-router', 'pinia'],
          'element-plus': ['element-plus', '@element-plus/icons-vue'],
          'echarts': ['echarts', 'vue-echarts'],
          'utils': ['axios', '@vueuse/core', 'lodash-es'],
        },
      },
    },
  },
})

效果:第三方库拆成独立 chunk,浏览器长期缓存。改业务代码时只有业务 chunk 的 hash 变,用户不用重新下 Element Plus。

4.1 更智能:函数式 manualChunks

手动列清单太麻烦,用函数自动分:

manualChunks(id) {
  if (id.includes('node_modules')) {
    // 大库单独一个
    if (id.includes('echarts')) return 'echarts'
    if (id.includes('element-plus')) return 'element-plus'
    // 其他 node_modules 统一 vendor
    return 'vendor'
  }
}

坑点manualChunks 太激进会增加 HTTP 请求数(HTTP/2 下影响小,但还是别拆太散)。大库单独分 + 其他 vendor 合并通常最优。


5. 打包分析:看包里到底装了什么

猜是没用的,装 rollup-plugin-visualizer 看真实情况:

pnpm add -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    vue(),
    tailwindcss(),
    visualizer({
      open: true,       // 构建完自动打开
      filename: 'dist/stats.html',
      gzipSize: true,
      brotliSize: true,
    }),
  ],
})

pnpm build 后浏览器弹出 stats.html树状图直观展示每个模块多大。

常见发现

Java 对照:类似 mvn dependency:tree + dependency:analyze —— 看实际占用,清理没用的。


6. Tree-shaking:看起来对,实际不一定生效

tree-shaking 的原理:导入了但没用的代码,构建时自动删掉。前提是:

// ✅ ESM 命名导入,能 tree-shake
import { debounce } from 'lodash-es'

// ❌ 默认导入整个模块,shake 不掉
import _ from 'lodash'
_.debounce(...)

// ❌ 动态 key 访问,shake 不掉
const fn = utils['someFn']

规则

6.1 lodash 的坑

// ❌ 全量引入 lodash,整个包进来
import _ from 'lodash'

// 🟡 按字段引入,稍好但还是引整个 lodash
import { debounce } from 'lodash'

// ✅ lodash-es 才是 tree-shakable 版
import { debounce } from 'lodash-es'

推荐:前端项目统一用 lodash-es

6.2 Element Plus 的副作用

Element Plus 的 CSS 是有副作用的(样式注入全局)。如果配了 sideEffects: false,CSS 可能被误删。

unplugin-vue-components + ElementPlusResolver(in4vue 已配)自动处理这个问题——按需引入组件时自动 import 对应样式。


7. 压缩:gzip / brotli

Vite 构建默认输出未压缩的 JS。服务器传输时压缩是关键:

7.1 运行时压缩(最常见)

Nginx 配:

gzip on;
gzip_types text/css application/javascript application/json;
gzip_min_length 1024;
gzip_comp_level 6;

Cloudflare / Vercel 默认都开 brotli(比 gzip 再压 20%),零配置

7.2 构建时预压缩(极致场景)

大流量项目构建时就生成 .gz / .br,Nginx 直接返回不实时压缩:

pnpm add -D vite-plugin-compression
import compression from 'vite-plugin-compression'

plugins: [
  compression({ algorithm: 'gzip', ext: '.gz' }),
  compression({ algorithm: 'brotliCompress', ext: '.br' }),
]

看压缩效果

Java 对照:类似后端的 spring-boot-starter-web 自动开启 gzip——前端也是"开关一下"的事,但收益巨大。


8. 图片优化

8.1 格式选对

格式 体积 兼容性 用在
PNG 透明图、线条、精确像素
JPEG 照片
WebP 小(-25%) 现代浏览器 大部分场景首选
AVIF 最小 较新浏览器 极致优化

实用策略<picture> 按兼容性回退(见响应式笔记第 8 节)。

8.2 构建时压缩

pnpm add -D vite-plugin-imagemin
import viteImagemin from 'vite-plugin-imagemin'

plugins: [
  viteImagemin({
    gifsicle: { optimizationLevel: 7 },
    optipng: { optimizationLevel: 7 },
    mozjpeg: { quality: 80 },
    webp: { quality: 80 },
  }),
]

效果:JPG 可压 60%,PNG 可压 30%。

8.3 assetsInlineLimit

build: {
  assetsInlineLimit: 4096, // 小于 4KB 的资源 base64 内联
}

小图标内联省一次 HTTP 请求;大图单独文件方便缓存。


9. 构建参数:打开看看

vite.config.tsbuild 常用选项:

build: {
  // 输出目录
  outDir: 'dist',

  // 资源子目录
  assetsDir: 'assets',

  // 静态资源内联阈值(字节)
  assetsInlineLimit: 4096,

  // 压缩
  minify: 'esbuild', // 'esbuild' 快 / 'terser' 小(差距不大)

  // 构建后的源码映射
  sourcemap: false, // 生产不要,减少包大小;出 bug 时临时开 'hidden'

  // chunk 大小警告阈值(默认 500KB)
  chunkSizeWarningLimit: 1000, // 改到 1MB 避免误报

  // Rollup 选项
  rollupOptions: {
    output: {
      manualChunks: { ... },
    },
  },

  // target: 目标浏览器
  target: 'es2020', // 默认 'modules'(现代浏览器); 'es2015' 兼容老浏览器但包变大
}

// 去除 console / debugger
esbuild: {
  drop: ['console', 'debugger'],
}

9.1 sourcemap 的取舍


10. 虚拟列表 / 图片懒加载(运行时优化)

构建产物小只是第一步,运行时也要优化。简要提两个(后面单独一篇性能笔记讲透):

10.1 长列表用虚拟列表

10000 条数据渲染 10000 个 DOM 节点会卡。虚拟列表只渲染视口内

pnpm add @tanstack/vue-virtual
<script setup lang="ts">
import { useVirtualizer } from '@tanstack/vue-virtual'
import { ref } from 'vue'

const parentRef = ref<HTMLElement>()
const rowVirtualizer = useVirtualizer({
  count: 10000,
  getScrollElement: () => parentRef.value,
  estimateSize: () => 50,
})
</script>

<template>
  <div ref="parentRef" style="height: 600px; overflow: auto;">
    <div :style="{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }">
      <div
        v-for="row in rowVirtualizer.getVirtualItems()"
        :key="row.key"
        :style="{ position: 'absolute', top: 0, transform: `translateY(${row.start}px)` }"
      >
        {{ items[row.index].title }}
      </div>
    </div>
  </div>
</template>

10.2 图片懒加载

<img src="photo.jpg" loading="lazy" alt="..." />

浏览器原生支持,不用库。


11. 测量:Lighthouse 跑一下

Chrome DevTools → Lighthouse 标签 → Analyze page load。

核心指标

Google 用这些当 SEO 排名因子之一


12. 实战:in4vue 的优化清单

按收益排序:

  1. ✅ 路由懒加载 —— 大概率砍 40%
  2. ✅ Element Plus 按需引入(unplugin-vue-components) —— 已配
  3. ✅ 第三方库 manualChunks 分离 —— 利于缓存
  4. esbuild.drop: ['console'] —— 清理日志
  5. ✅ Nginx / Cloudflare 开 brotli —— 零成本再压 20%
  6. ✅ 图标按需用(@element-plus/icons-vue 单个 import,不要整包)
  7. 🟡 大页面进一步拆分(详情页的"评论区"lazy 组件)
  8. 🟡 图片转 WebP
  9. 🟡 vite-plugin-compression 预压缩(如果自建 Nginx)

13. 常见坑点

现象 原因 解法
懒加载页面切换一卡一卡 每次都从网络下 chunk 空闲时预加载关键路由
manualChunks 配完首屏反而变慢 拆太碎,HTTP 请求变多 合并相关库到一个 chunk
样式丢失 Element Plus CSS 被 tree-shake 走 ElementPlusResolver 自动引入样式
生产包里还有 console.log 没配 esbuild.drop esbuild: { drop: ['console'] }
stats.html 显示某库异常大 可能被重复打包 检查版本一致性(一个库多版本共存)
构建慢 图片/字体进了转换流程 assetsInlineLimit: 0 关内联
sourcemap 把生产代码暴露了 部署时 .map 被公开 sourcemap: 'hidden' 或部署时排除

14. 决策表

痛点 方案
首屏慢 路由懒加载 + 第三方库分离 + brotli
库大 换轻量替代(moment→dayjs)+ tree-shaking
重复打包 pnpm why xxx 查多版本
排查大包 rollup-plugin-visualizer
图片大 WebP + <picture> + 压缩插件
长列表卡 虚拟列表
生产被逆向 关 sourcemap + 压缩
缓存利用率低 稳定的 chunk 拆分(库 vs 业务)

15. 心智模型

构建优化的两个问题:
1. 包里有没有用不上的?          → tree-shaking / 按需引入
2. 首屏必须要吗?能不能等等?      → 懒加载 / manualChunks

运行时优化的两个问题:
3. 一次性渲染太多东西了?         → 虚拟列表 / 分页
4. 不必要的 JS 执行?             → debounce / keep-alive / computed

四个问题都问一遍,项目性能基本就稳了。


小练习

  1. rollup-plugin-visualizer,跑 pnpm build 看 in4vue 当前包里有什么
  2. 按第 3 节改所有路由为懒加载,对比包体积
  3. manualChunks 把 Element Plus / Vue / VueUse 拆开
  4. 删除 console.log:加 esbuild.drop 看构建后是否真的删了
  5. 用 Lighthouse 跑一次 pnpm preview 的页面,记录 FCP/LCP 数字

延伸阅读