Vite 构建优化:90 秒到 18 秒,产物 4.2 MB 到 420 KB
用 visualizer 定位到五个「全量引入」的第三方库,按需引入加分包把首屏砍掉 90%,构建时间靠关 sourcemap 和缓存降到 18 秒。
一个 Vue 3 项目,开发环境启动 1.2 秒飞快,但生产构建要 90 秒,CI 里更慢(Cold cache 时 3 分半)。产物是 4.2 MB 的 JS + 800 KB 的 CSS,首屏要等 3 秒才白屏。
这篇文章记录把这个项目从「构建 90s、产物 5MB」优化到「构建 18s、产物 380KB」的过程。每一步都有前后数据和取舍说明。
一、先看清楚慢在哪
Vite 的构建阶段叫 build,它走 Rollup。和 dev 阶段的 ESM 按需加载完全不同——build 阶段必须把所有模块打包成 bundle,所以「Vite 启动快」不代表「Vite 构建快」。
第一步是打开构建分析:
npm i -D rollup-plugin-visualizer
// vite.config.ts
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig({
plugins: [
vue(),
visualizer({
filename: 'dist/stats.html',
gzipSize: true,
brotliSize: true,
template: 'treemap', // 或 'sunburst'
}),
],
})
构建后打开 dist/stats.html,一眼看出哪个包最占体积。
这个项目的分析结果:
| 包 | 体积(gzip 后) | 说明 |
|---|---|---|
echarts |
1080 KB | 只用到了折线图和柱状图 |
element-plus |
720 KB | 全量引入 |
moment |
290 KB | 只用了 format |
lodash |
240 KB | 全量引入 |
xlsx |
430 KB | 只在导出功能用 |
| 业务代码 | 380 KB | 正常 |
问题很明确:五个第三方库占了 2.7 MB,全是「全量引入 + 只用一小部分」。
二、第 1 步:按需引入,收益最大
ECharts:从 1080 KB 降到 180 KB
// 之前:全量引入
import * as echarts from 'echarts'
// 之后:按需注册
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
import {
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
} from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([
LineChart, BarChart,
GridComponent, TooltipComponent, LegendComponent, TitleComponent,
CanvasRenderer, // 需要 SVG 就换 SVGRenderer
])
Element Plus:从 720 KB 降到 210 KB
// vite.config.ts
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] }),
],
})
配合按需样式(unplugin-element-plus 处理 ElMessage 这类命令式组件的样式)。
注意:ElMessage / ElMessageBox 这类函数式调用的组件不会被打包器识别为「使用」,需要手动引入样式:
import 'element-plus/es/components/message/style/css'
漏了这句,样式会缺失——按钮正常但弹窗没样式,是个很典型的坑。
moment → dayjs:从 290 KB 降到 7 KB
npm uninstall moment
npm i dayjs
dayjs 的 API 和 moment 基本兼容,迁移成本低:
// moment
moment().format('YYYY-MM-DD HH:mm:ss')
// dayjs
dayjs().format('YYYY-MM-DD HH:mm:ss') // 一样
如果暂时不能替换,可以用 moment 的 ignoreMomentLocale 去掉语言包(省 100 KB),或者用 moment/min/moment-with-locales 的裁剪版。
lodash:从 240 KB 降到 12 KB
// 差:全量引入,即使 tree-shaking 也经常失效
import _ from 'lodash'
_.debounce(fn, 300)
// 好:按方法引入
import debounce from 'lodash/debounce'
import cloneDeep from 'lodash/cloneDeep'
lodash 的 ESM 版本(lodash-es)tree-shaking 效果更好:
import { debounce, cloneDeep } from 'lodash-es'
但 lodash-es 需要构建工具正确处理,CommonJS 环境下会报错。
xlsx / 重型库改成动态导入
// 之前:首屏就加载 430 KB,但 99% 的用户不会点导出
import * as XLSX from 'xlsx'
// 之后:点导出时才加载
async function exportExcel(data) {
const { utils, writeFile } = await import('xlsx')
const ws = utils.json_to_sheet(data)
const wb = utils.book_new()
utils.book_append_sheet(wb, ws, 'Sheet1')
writeFile(wb, 'export.xlsx')
}
这一步把首屏体积直接减掉 430 KB,代价是用户点导出时多等 200ms。
三、第 2 步:手动分包,改善缓存
默认配置下,Vite 会把所有依赖打成一个 vendor.js。任何一个库升级,整个 vendor 的 hash 都变,用户的缓存全部失效。
// vite.config.ts
export default defineConfig({
build: {
rollupOptions: {
output: {
manualChunks: {
// 框架层:几乎不变
'vendor-vue': ['vue', 'vue-router', 'pinia'],
// UI 层:偶尔变
'vendor-ui': ['element-plus'],
// 图表:单独一个,首页可能用不到
'vendor-chart': ['echarts'],
// 工具库
'vendor-utils': ['dayjs', 'lodash-es', 'axios'],
},
},
},
},
})
分包的原则是「变更频率相近的放一起」:
- 框架(vue/vue-router/pinia):一年变几次 → 独立 chunk,长期缓存
- UI 库:半年升级一次 → 独立 chunk
- 业务代码:每周发版 → 主 chunk
效果:发版后用户只需要重新下载 80 KB 的业务代码,其余 1.2 MB 走缓存。
注意 manualChunks 的坑:如果两个 chunk 互相引用,Rollup 会报循环依赖警告,或者产出意料之外的共享 chunk。用对象形式(而不是函数形式)时尤其容易出问题。稳妥做法是函数形式:
manualChunks(id) {
if (id.includes('node_modules')) {
if (id.includes('echarts')) return 'vendor-chart'
if (id.includes('element-plus')) return 'vendor-ui'
return 'vendor'
}
}
四、第 3 步:构建速度优化
3.1 关掉不必要的 sourcemap
build: {
sourcemap: false, // 生产默认 false,但要检查
}
sourcemap 会让产物大 2~3 倍,构建时间增加 30%。如果确实需要线上调试,用 sourcemap: 'hidden'(生成但不写引用注释),上传到 Sentry。
3.2 用 esbuild 压缩替代 terser
Vite 默认用 esbuild 压缩,但很多人手动配了 terser:
// 差:terser 比 esbuild 慢 3~5 倍
build: { minify: 'terser' }
// 好:默认值,快很多
build: { minify: 'esbuild' }
esbuild 压缩后的体积大约多 3~5%(因为不做某些激进优化),但速度快 4 倍。对于 CI 时间敏感的项目,这个交换很划算。
3.3 依赖预构建缓存
optimizeDeps: {
include: ['vue', 'vue-router', 'element-plus', 'echarts/core'],
exclude: ['your-local-lib'], // 本地 link 的包不要预构建
}
include 里的包会被预构建成 ESM,避免 dev 时反复分析。CI 里要缓存 node_modules/.vite:
# GitHub Actions
- uses: actions/cache@v4
with:
path: |
node_modules
node_modules/.vite
key: ${{ runner.os }}-vite-${{ hashFiles('**/package-lock.json') }}
3.4 多线程压缩
npm i -D vite-plugin-compression
import compression from 'vite-plugin-compression'
plugins: [
compression({ algorithm: 'gzip', threshold: 1024 }),
compression({ algorithm: 'brotliCompress', ext: '.br', threshold: 1024 }),
]
构建时生成 .gz 和 .br 文件,配合 Nginx 的 gzip_static / brotli_static 直接返回预压缩文件,省掉运行时的 CPU 开销。
注意:.br 压缩很慢(比 gzip 慢 5~10 倍)。如果构建时间敏感,只生成 gzip,让 Nginx 动态 brotli。
五、第 4 步:路由级代码分割
// 之前:所有路由同步引入
import Home from './views/Home.vue'
import About from './views/About.vue'
import Dashboard from './views/Dashboard.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/dashboard', component: Dashboard },
]
// 之后:懒加载
const routes = [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/about', component: () => import('./views/About.vue') },
{ path: '/dashboard', component: () => import('./views/Dashboard.vue') },
]
首屏加载的 chunk 只包含首页 + 框架 + 公共组件,其他路由按需下载。
配合 webpackChunkName 类似的命名(Vite 用 /* @vite-ignore */ 或 magic comment):
component: () => import(/* webpackChunkName: "dashboard" */ './views/Dashboard.vue')
Vite 里这个注释是 Rollup 的语法,会生成 dashboard-[hash].js。
一个常见的错误:把所有组件都懒加载。
// 差:一个小组件也单独成 chunk,多了一次 HTTP 请求
const Button = defineAsyncComponent(() => import('./Button.vue'))
小组件(< 20 KB)放主 chunk 更好。判断标准:这个组件是否在首屏出现?是否是高频使用的? 是就不要懒加载。
六、第 5 步:CDN 与预加载
6.1 关键资源预加载
<!-- index.html -->
<link rel="preload" href="/assets/vendor-vue-[hash].js" as="script" crossorigin>
<link rel="modulepreload" href="/assets/index-[hash].js">
<link rel="preconnect" href="https://cdn.example.com">
modulepreload 是 ESM 专用的预加载,比 preload 更精确(会预解析依赖树)。Vite 会自动为入口 chunk 注入,但异步 chunk 的首屏依赖值得手动加。
6.2 静态资源上 CDN
build: {
rollupOptions: {
output: {
assetFileNames: 'assets/[name]-[hash][extname]',
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
},
},
}
// 或者用 base 指向 CDN
base: 'https://cdn.example.com/app/',
七、优化结果
| 步骤 | 构建时间 | 首屏 JS(gzip) | 首屏 CSS |
|---|---|---|---|
| 初始 | 90s | 4.2 MB | 800 KB |
| ECharts 按需 | 78s | 3.1 MB | 800 KB |
| Element Plus 按需 | 62s | 2.4 MB | 310 KB |
| moment → dayjs | 60s | 2.1 MB | 310 KB |
| lodash 按方法引入 | 58s | 1.9 MB | 310 KB |
| xlsx 动态导入 | 57s | 1.5 MB | 310 KB |
| 路由懒加载 | 52s | 420 KB | 310 KB |
| 手动分包 + esbuild 压缩 | 34s | 420 KB | 310 KB |
| 关 sourcemap + 缓存 | 18s | 420 KB | 310 KB |
首屏 JS 从 4.2 MB 降到 420 KB 是最大收益——注意这 90% 的降幅几乎全来自「按需引入 + 路由懒加载」,而不是压缩技巧。
构建时间从 90s 降到 18s 主要靠关 sourcemap 和缓存。
八、几个反直觉的结论
1. build.minify: 'esbuild' 比 'terser' 快 4 倍,体积只大 4%
除非你对体积有极致要求,否则用默认的 esbuild。
2. 手动分包不是越多越好
分成 10 个 chunk,浏览器要发 10 个请求。HTTP/2 能缓解,但每个请求仍有开销。经验值:首屏 chunk 3~5 个比较合适。
3. tree-shaking 对 CommonJS 包无效
moment、lodash(非 es 版本)都是 CJS,Rollup 无法静态分析,只能全量引入。这就是为什么「按需引入」在这些库上要靠手动指定路径。
4. define 替换能消除死代码
define: {
__VUE_PROD_DEVTOOLS__: false,
__VUE_OPTIONS_API__: false, // 不用 Options API 时可以关
}
Vue 3 的构建产物里有 __DEV__ 分支,正确设置这些 flag 能让 Rollup 摇掉开发期代码。
5. 图片比 JS 更容易成为瓶颈
一张 2 MB 的 PNG 胜过所有 JS 优化。检查 dist 里的图片:
find dist -type f \( -name '*.png' -o -name '*.jpg' \) -size +200k
转 WebP/AVIF,用 vite-plugin-imagemin 自动处理:
import imagemin from 'vite-plugin-imagemin'
plugins: [
imagemin({
webp: { quality: 80 },
pngquant: { quality: [0.7, 0.85] },
}),
]
九、一份可直接用的配置
把上面的结论收拢成一份 vite.config.ts:
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
import compression from 'vite-plugin-compression'
export default defineConfig({
plugins: [
vue(),
AutoImport({ resolvers: [ElementPlusResolver()] }),
Components({ resolvers: [ElementPlusResolver()] }),
compression({ algorithm: 'gzip', threshold: 1024 }),
],
define: {
__VUE_OPTIONS_API__: false,
__VUE_PROD_DEVTOOLS__: false,
},
optimizeDeps: {
include: ['vue', 'vue-router', 'pinia', 'axios'],
},
build: {
target: 'es2020',
minify: 'esbuild',
sourcemap: false,
chunkSizeWarningLimit: 600,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return
if (id.includes('echarts')) return 'vendor-chart'
if (id.includes('element-plus')) return 'vendor-ui'
if (id.includes('vue') || id.includes('pinia')) return 'vendor-vue'
return 'vendor'
},
assetFileNames: 'assets/[name]-[hash][extname]',
chunkFileNames: 'assets/[name]-[hash].js',
entryFileNames: 'assets/[name]-[hash].js',
},
},
},
})
小结
优化的收益排序很清晰:
| 措施 | 首屏体积收益 | 构建时间收益 | 难度 |
|---|---|---|---|
| 按需引入(ECharts/Element Plus) | 极大 | 中 | 低 |
| 路由懒加载 | 极大 | 中 | 低 |
| 重型库动态导入 | 大 | 小 | 低 |
| 换轻量替代(moment→dayjs) | 中大 | 小 | 中 |
| 手动分包 | 无 | 小 | 中 |
| 关 sourcemap | 无 | 大 | 极低 |
| 缓存 node_modules | 无 | 大 | 低 |
先量体积,再动手。 没有 visualizer 的分析,所有优化都是猜。而这个项目 90% 的体积问题,都藏在「全量引入」这四个字里。
正在加载评论…