Vite 构建优化:代码分割与打包分析
1. 为什么前端包体积重要
Java 后端一个 jar 50MB 无所谓,内网速度足够。前端每 KB 都决定用户体验:
- 首屏时间:手机 4G 下,100KB 的 JS 约 1 秒下载 + 解析
- TTI(Time To Interactive):JS 下载完 + 执行完 + 可交互,决定"页面能点吗"的感受
- SEO:Google 给慢页面降权
- 流量成本:每个月 PV 大的项目,带宽费实打实
目标参考:
- 首屏 JS < 200KB(gzip 后)
- 首屏总资源 < 500KB
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,树状图直观展示每个模块多大。
常见发现:
moment.js几百 KB,其实你只用了 1 个格式化——换成dayjs(10KB)lodash全量引入 —— 改lodash-es的按需 importelement-plus某个巨型图标库占了 50KB —— 按需引入图标- 某个没人用的废弃组件还在 import
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']
规则:
- 用 ESM 命名导入
- 包本身要是 ESM(package.json 里
"type": "module"或"module"字段) - 无副作用(
package.json里"sideEffects": false告诉打包器"导入我不会产生额外影响")
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' }),
]
看压缩效果:
- 原始: 500 KB
- gzip: ~150 KB
- brotli: ~120 KB
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.ts 里 build 常用选项:
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 的取舍
- 生产关:增加包体积(原始代码级别),给逆向工程提供便利
- 生产开
hidden:生成 .map 但 JS 不引用,只 Sentry 等工具内部上传用 - 开发开:Vite dev 默认开,DevTools 里看原始代码行号
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。
核心指标:
- FCP (First Contentful Paint) —— 首次内容绘制,< 1.8s 算好
- LCP (Largest Contentful Paint) —— 最大内容绘制,< 2.5s 算好
- TBT (Total Blocking Time) —— 总阻塞时间,< 200ms 算好
- CLS (Cumulative Layout Shift) —— 布局偏移,< 0.1 算好
Google 用这些当 SEO 排名因子之一。
12. 实战:in4vue 的优化清单
按收益排序:
- ✅ 路由懒加载 —— 大概率砍 40%
- ✅ Element Plus 按需引入(
unplugin-vue-components) —— 已配 - ✅ 第三方库
manualChunks分离 —— 利于缓存 - ✅
esbuild.drop: ['console']—— 清理日志 - ✅ Nginx / Cloudflare 开 brotli —— 零成本再压 20%
- ✅ 图标按需用(
@element-plus/icons-vue单个 import,不要整包) - 🟡 大页面进一步拆分(详情页的"评论区"lazy 组件)
- 🟡 图片转 WebP
- 🟡
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
四个问题都问一遍,项目性能基本就稳了。
小练习
- 装
rollup-plugin-visualizer,跑pnpm build看 in4vue 当前包里有什么 - 按第 3 节改所有路由为懒加载,对比包体积
- 配
manualChunks把 Element Plus / Vue / VueUse 拆开 - 删除
console.log:加esbuild.drop看构建后是否真的删了 - 用 Lighthouse 跑一次
pnpm preview的页面,记录 FCP/LCP 数字
延伸阅读
- Vite - 构建生产版本
- Rollup - 输出选项
- rollup-plugin-visualizer
- web.dev - 核心 Web 指标
- Bundlephobia(选库前先看体积)
- You Might Not Need Lodash(看能不能直接用原生 JS 替代)