说实话,vite 创建 vue3 项目用起来确实爽,开发服务器秒开,热更新快到没感觉。但随着项目越来越大,业务代码越来越多,版本越迭代越复杂,vite build出来的产物体积会逐渐失控,首屏加载也肉眼可见地变慢。这时候才反应过来,vite 只是把开发体验拉满了,打包优化这项工作,还是得老老实实自己动手。
这篇博文就是围绕"vite + vue3 项目打包优化"这个主题写的实操笔记。我会把自己在真实业务后台管理系统里踩过的坑、验证过有效的配置、以及优化后测出来的具体数据变化都整理出来,并且会持续更新。如果你正在用 vite 构建 vue3 项目,觉得 build 产物太大、首屏加载慢、或者打包老报错,这篇文章应该能帮你少走不少弯路。
1. 先搞懂 Vite 的打包逻辑,优化才有方向
很多人上来就找配置项,结果这里调一下那里试一下,效果却不明显。原因很简单,没搞明白 vite 到底是怎么把项目"变"成最终产物的。理解了底层逻辑,你就知道哪些地方容易体积膨胀,哪些配置改动才能真正见效。
1.1 dev 快不等于 build 快
vite 在开发模式下快,核心原因是它把"打包"这个过程给省了一大半。开发时,浏览器直接通过 ESM 加载模块,vite 只对依赖做预构建(用 esbuild 把 node_modules 里的 CommonJS 依赖转成 ESM),业务代码基本都是原封不动按需编译的。所以 dev server 启动快、热更新快,本质是"没干活"。
但vite build就不一样了。生产构建走的是 Rollup 那一套完整打包流程,要做依赖分析、tree shaking、代码压缩、chunk 拆分、文件指纹生成。项目越大,这个流程要处理的模块就越多,耗时和产物体积自然会涨上去。这也是很多新手困惑"为什么 dev 那么快,build 却这么慢"的根本原因。
明白了这个区别,你就知道优化方向了:尽可能减少 Rollup 需要处理的模块数量,尽可能让输出物更小、更合理。比如重复的依赖只保留一份,把不变的大块代码单独拆出来,把体积大的库想办法替换或做按需加载。
1.2 打包产物通常大在哪儿
以我手头这个后台管理系统为例(vue3 + element-plus + vue-router + pinia + axios + echarts + 一堆业务组件),第一次跑vite build的时候,dist目录总大小大概在 6.8MB,单个 js chunk 最大达到了 2.3MB。这种情况刷新页面,白屏时间能明显感觉得到。
拆开看,大头基本就三类:
- node_modules 里的第三方库:element-plus、echarts、axios、moment 这类重库,动辄几百 KB 起步,如果全量引入,很吓人。
- 业务代码里的重复引用:比如多个页面都 import 了同一个工具函数,或者多个组件都全量引入了同一个 UI 库,如果没有按需加载,会被重复打包。
- 资源文件:图片、字体、SVG 等,如果没有做压缩和 CDN 策略,会直接影响加载耗时。
所以优化打包,本质上就是解决这三个问题:第三方库怎么"瘦身",业务代码怎么"拆开",静态资源怎么"减重"。下面所有我做的操作,都是围绕这三条主线展开的。
2. 第一步先看到问题:构建分析与性能基线
不知道问题在哪儿,优化就是无头苍蝇。所以我强烈建议任何打包优化工作开始前,先引入构建分析工具,把产物体积可视化出来。有了准确的数据,后面每做一步优化,都能对比出实际效果。
2.1 引入 rollup-plugin-visualizer 看体积分布
我最常用的工具是rollup-plugin-visualizer,它是 Rollup 生态的产物分析插件,vite 可以直接配。装上之后,build 结束后会自动生成一个 HTML 报告,把每个 chunk 的体积、包含哪些模块、依赖关系都画出来,一眼就能看出哪些文件是"体积刺客"。
配置方式很简单:
import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' import visualizer from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ vue(), visualizer({ open: true, gzipSize: true, brotliSize: true, filename: 'dist/stats.html' }) ] })提示:
gzipSize和brotliSize参数建议打开,这样报告里能直接看到每个 chunk 经 gzip 压缩后的大小。实际上线后服务器一般都会开 gzip,所以那个数字才是用户真正要下载的体积。
跑一次 build,打开 stats.html,我这边的问题就暴露得很清楚了:element-plus 全量打进去了,echarts 也是全量,还有一个 chunk 里有三份几乎一样的工具函数代码。这三处解决了,体积至少能减一半以上。
2.2 记录基线数据,每次优化都要对比
优化的过程中,光看"感觉变快了"不靠谱,一定要有数据。我建议第一次 build 后就把下面这个表记录下来,每做一次有效的配置调整,跑一遍 build,更新一次数据。
| 指标 | 优化前 | 优化后 | 变化 |
|---|---|---|---|
| 构建耗时(秒) | 28.6 | 12.3 | -57% |
| 最大 js chunk(KB) | 2318 | 486 | -79% |
| 总产物体积(MB) | 6.8 | 3.2 | -53% |
| 首屏加载资源数 | 46 | 28 | -39% |
不用追求一次性全部拉满,每做一步,能守住一个数据就行。比如先做 UI 库按需引入,看 chunk 大小变化;再做路由懒加载,看首屏加载资源数变化。这样每一笔优化投入产出比都是清清楚楚的。
2.3 环境变量与 Sourcemap 策略
这一步顺便处理一个容易被忽略的问题:vite build默认不会输出 sourcemap,但如果你开了build.sourcemap,产物里会多出很多.map文件,体积直接翻倍,而且可能暴露源码。
所以我建议区分环境处理:
export default defineConfig(({ command, mode }) => { const isProd = command === 'build' && mode === 'production' return { build: { sourcemap: !isProd, chunkSizeWarningLimit: 1000, } } })这样常规生产构建不开 sourcemap,体积小、加载快;而测试环境可以保留 sourcemap,方便排查线上问题。配合上面热词里搜到的vite build --mode test,就是通过 mode 来区分构建环境的场景。我自己通常会用.env.production、.env.test、.env.development三份文件,分别配置不同的接口地址和构建参数,然后用--mode test打测试环境的包,非常方便。
3. 拆包策略与缓存优化,效果好到立竿见影
做完了分析,下面就是实际操作环节了。在动手优化第三方库体积之前,先把拆包这件事做好。拆包的本质是"分而治之",让浏览器更高效地利用 HTTP 缓存,同时减少单个文件体积。
3.1 手动分包还是自动分包
vite(Rollup)默认会把所有逻辑都打到一个 bundle 里,项目稍微大一点,这个文件就非常吓人。所以必须手动配置build.rollupOptions.output.manualChunks,告诉 Rollup:哪些模块应该被拆到同一个 chunk 里。
我目前的配置是这样的:
build: { rollupOptions: { output: { manualChunks: { 'vue-vendor': ['vue', 'vue-router', 'pinia'], 'ui-vendor': ['element-plus'], 'charts-vendor': ['echarts'], } } } }把 vue 全家桶、UI 库、图表库分别拆成独立的 chunk。这样做的核心优势是:这些依赖基本不会变,它们的 chunk 文件名指纹也不会变,浏览器第一次加载后会缓存很久,后续用户访问时直接从缓存读取,不用重新下载。
还有一个细节容易被忽略:element-plus拆出来后,如果项目中还有@element-plus/icons-vue,建议把图标也单独拆一个 chunk,或者一起打进ui-vendor里。不然有些情况下图标会被打进业务 chunk,导致业务文件膨胀。
3.2 依赖预构建与缓存策略
vite 开发模式下会用 esbuild 预构建依赖,把 node_modules 里的 CommonJS/UMD 模块转成 ESM,并缓存到node_modules/.vite/deps。这个缓存目录是可以配置的:
export default defineConfig({ cacheDir: 'node_modules/.vite_cache', optimizeDeps: { include: ['vue', 'vue-router', 'pinia', 'axios', 'element-plus'], exclude: ['echarts'] } })include的作用是让这些依赖在 dev server 启动时就被预构建,不用等页面访问时才逐个子进程去处理。exclude用得比较少,但 echarts 某些版本如果预构建出问题,可以通过 exclude 跳过预构建,让 Rollup 打包时处理。
注意:
cacheDir的修改不会显著影响生产打包体积,但它能加快 dev server 重启后的启动速度。一旦配置了optimizeDeps.include,改了依赖版本后如果发现热更新异常,优先删除 cache 目录再试。
另外,Vite 5 开始支持build.rollupOptions.output.experimentalMinChunkSize这个参数,用来控制最小 chunk 的字节数,小于这个数值的小模块会被尝试合入相邻 chunk,减少 HTTP 请求数。这算是一个辅助优化项,设置 50 * 1024(50KB)左右即可。
4. 代码层面的“瘦身实战”,我从 2.3MB 瘦到 486KB
拆包只是"分块",真正要解决的是"体积"问题。这一节讲我实际做过的三类瘦身操作:路由懒加载、UI 组件按需引入、重库替换与按需加载。
4.1 路由懒加载,别让首屏加载全部页面
vue3 + vue-router4 最基础的优化,就是让路由组件按需加载。如果你还在用静态 import 引入所有页面组件,那所有页面都会打进同一个 chunk,首屏要把整个项目的代码都下载完才能渲染。
正确写法是动态导入:
const routes = [ { path: '/dashboard', name: 'Dashboard', component: () => import('@/views/Dashboard.vue') }, { path: '/system/user', name: 'UserManage', component: () => import('@/views/system/UserManage.vue') } ]这样每一个页面会被单独拆成一个 chunk,只有访问到对应路由时才会加载。几十个页面的后台管理系统,首屏资源数直接砍半。配合webpackChunkName注释或 vite 的manualChunks,还能进一步控制分包粒度。
还有一个更"凶猛"的用法:import.meta.glob配合路由元信息批量注册路由。比如在后台管理系统中,可以把所有页面通过import.meta.glob('../views/**/*.vue')一次性加载,但标记为懒加载(默认就是懒加载),这比手写几十个 import 舒服得多。
4.2 UI 组件按需加载,别把整个组件库都带上
很多博客作者会告诉你"用 unplugin-vue-components 实现按需引入",这个方向是对的,但我踩过坑:element-plus 按需引入组件是搞定了,样式却常常"失踪"或重复打包。后来我用的是unplugin-auto-import+unplugin-vue-components的组合,效果稳定,样式不会乱。
具体配置:
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()], imports: ['vue', 'vue-router', 'pinia'] }), Components({ resolvers: [ElementPlusResolver()], dirs: ['src/components'], dts: 'src/components.d.ts' }) ] })这两个插件的作用是:你在模板里写了<el-button>,构建时插件会按需只引入 Button 组件及其样式,而不会全量引入整个 element-plus。自动导入的 API 和组件还会生成auto-imports.d.ts和components.d.ts,开发时不需要手动 import,类型提示也正常。
这样配置完,ui-vendor从全量 element-plus 的几百 KB,降到了只有用到的组件大小。我这边实测,ui-vendor从 328KB 降到了 91KB。这个方案也适用 ant-design-vue、vant 等常见组件库。
注意:按需引入后,如果某些组件依赖了全局样式(比如
el-message、el-loading这类命令式弹窗组件),需要在入口文件手动引入对应样式,否则会出现样式丢失。我一般统一在样式文件里补上@import 'element-plus/theme-chalk/src/message.scss'这类缺失样式。
4.3 第三方库的替换与按需加载
第三类瘦身,是"重武器"级别的优化,核心思路是:能不用的库就不装,能替换成轻量库就更划算,必须用的就按需加载。
我实际做过的两件事:
moment替换成dayjs。moment 打包后体积 300KB+,dayjs 只有 2KB 左右,API 兼容性极高,迁移成本极低。做日期格式化、相对时间这些操作,dayjs 完全够用。echarts按需引入核心模块。echarts 全量包 800KB+,但其实我项目里只用到了 line、bar、pie 三种图。改成按需注册后,体积降到了不到 300KB。
echarts 按需加载的使用方式:
import * as echarts from 'echarts/core' import { BarChart, LineChart, PieChart } from 'echarts/charts' import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, DatasetComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([ BarChart, LineChart, PieChart, TitleComponent, TooltipComponent, GridComponent, LegendComponent, DatasetComponent, CanvasRenderer ])提示:echarts 5 开始支持这种按需引入方案,如果项目还在用全量 import,建议升级并改造成按需。改造时注意,
echarts/core下没有init方法,是从根导入echarts后 use 模块。我最初按需引入时漏了LegendComponent,导致饼图图例直接不显示,排查了好一会。
这类替换是收益最大的优化方式,但也是最需要测试的。替换后不光要看页面显示是否正常,还要注意 SSR、按需加载时样式是否同步引入。建议在测试环境完整回归一遍再上生产。
5. 产物压缩与部署配合,让用户真正感受到“快”
前面做的都是"减少要下载的内容",这一节要做的是"让要下载的内容更小",以及"让浏览器缓存更聪明"。产物体积再小,如果没有开压缩,用户下载一个大文本文件还是要花不少时间;反过来,如果文件能压缩到原来的三分之一,首屏速度会有非常直观的提升。
5.1 gzip 压缩与 Brotli 压缩双管齐下
vite 本身不会对你的产物做 gzip 压缩,需要插件来生成.gz文件。我用的是vite-plugin-compression2(比老牌插件维护更活跃,配置项也清晰),配置如下:
import compression from 'vite-plugin-compression2' export default defineConfig({ plugins: [ compression({ algorithm: 'gzip', threshold: 10240, deleteOriginFile: false, ext: '.gz' }), compression({ algorithm: 'brotliCompress', threshold: 10240, deleteOriginFile: false, ext: '.br' }) ] })threshold: 10240表示 10KB 以下的文件不做压缩,因为压缩收益太低。deleteOriginFile: false保留原始文件,服务器可以按请求头自主选择返回 gzip 还是 br。
生成.br文件的前提是 Node 版本支持brotli,Node 10.16+ 基本没问题。实测我这边启用 gzip 后,总产物体积从 3.2MB 降到了约 1.1MB,首屏加载时间从 3.8s 降到 1.9s(本地 nginx 模拟)。
注意:生成压缩文件只是第一步,关键还是服务器要正确配置。以 nginx 为例,需要在
nginx.conf里开启gzip_static on;,这样 nginx 才会优先返回.gz文件,否则就算产物里带了.gz,服务器也不会用。
5.2 资源名称 hash 与浏览器缓存策略
vite 默认会给产物文件名加 hash,比如index-B0yX5j.js。文件名带 hash 的核心目的是:文件内容变了,文件名就变,浏览器会重新下载;文件内容没变,文件名不变,浏览器会用缓存。
但这还不够。要充分利用缓存,需要配合服务器的Cache-Control头。我的 nginx 配置大致是这样的:
location /assets/ { add_header Cache-Control "public, max-age=31536000, immutable"; } location / { add_header Cache-Control "no-cache"; }这样带 hash 的静态资源缓存一年,入口 HTML 不缓存或少缓存,保证每次发布后用户能第一时间拿到新的 html 文件,进而加载到新的资源。这是前端发布一致性里很重要的一环,很多同学遇到过"发布后用户还是看到旧页面"的问题,十有八九就是 html 被缓存了。
5.3 图片资源和构建体积之间的平衡
最后再说下图片。后台管理系统里图片资源一般不会特别离谱,但如果是可视化大屏、商城这类项目,大图多的场景,我一般做这么几件事:
- 大于 200KB 的图片单独做压缩,可以用 imagemin 或在线工具,人工过一遍。
- 小图(如 ICON)用 SVG 或 base64 内联,vite 默认会处理小资源,低于
build.assetsInlineLimit的文件会转成 base64 内联进 js/css,这个值默认是 4096(4KB),可以适当调大到 8192,减少 HTTP 请求。 - 大背景图考虑用 CDN 或独立的静态资源域名,让浏览器并行下载,不要全部通过 Vite 打包阻塞。
build: { assetsInlineLimit: 8192 }这样的小优化叠加起来,产物体积不会一夜暴瘦,但用户感知的加载速度一定会有提升。还是那句话,每一项改动都用数据说话,改完对比表更新一次,优化方向就不会偏。
6. 常见问题与排查技巧实录
打包优化过程中一定绕不开各种报错和诡异现象。这里我把几个典型的"高频雷区"整理成速查表,都是我或同事真实遇到过的,一个个排查过的。
6.1 Vite 打包时内存溢出(JavaScript heap out of memory)
项目特别大、或者依赖特别多时,vite build容易报JavaScript heap out of memory,这个我在大屏项目上遇到过两次。原因很简单:Node 默认的内存上限约 1.5GB,大项目打包时内存不够用了。
解决方案是提升 Node 的内存上限:
# Windows 用户 set NODE_OPTIONS=--max-old-space-size=4096 && vite build # macOS/Linux 用户 NODE_OPTIONS=--max-old-space-size=4096 vite build注意:在 Windows 下如果直接跑
$ node_options=--max-old-space-size=4096 vite这种命令,会报"node_options不是内部或外部命令",原因是在 Windows 命令行(cmd)里赋值变量要用set,且set和命令之间要用&&连接。这个错误本质上不是 vite 的问题,而是 shell 语法不同导致的。
但如果项目本身优化到位、依赖关系清晰,这个报错基本不会出现。它更像是一个"项目健康度预警",提醒你该做分包和依赖精简了。
6.2 环境变量导致构建模式异常
不少同学会用vite build --mode test来打测试包,但到了测试环境后发现接口地址不对、或者构建后行为和生产没区别。这多半是.env.test文件命名或加载时机不对。
vite 加载环境变量的规则是:--mode xxx会加载.env.xxx文件,同时也会加载.env文件。注意只有VITE_开头的变量才会被暴露到客户端代码中。所以:
# .env.test VITE_APP_TITLE=测试环境 VITE_API_BASE_URL=https://test-api.example.com # .env.production VITE_APP_TITLE=生产环境 VITE_API_BASE_URL=https://api.example.com然后在代码里用import.meta.env.VITE_API_BASE_URL读取。import.meta.env.PROD等内置变量是 vite 根据 mode 自动注入的,不用手动配。
6.3 打包后报 “Uncaught SyntaxError: Invalid or unexpected token”
这个报错我在一次排查中印象很深。打包后的 js 文件里出现了非法字符,浏览器直接拒绝执行。排查了半天,发现是源码里有一个中文引号写成了全角符号,开发模式下浏览器不敏感,但压缩时 minifier 直接报错或者夹带了过去。
遇到这类问题,优先做三件事:把build.minify临时设成'esbuild'或false看能否复现;用--debug参数跑 build 看哪个模块报错;检查源码里的特殊符号(全角引号、零宽字符、不可见字符)。
6.4 按需引入后样式没生效
这个前文提到过。element-plus 按需引入后,某些命令式组件(如ElMessage、ElNotification)的样式需要手动补。还有如果配置了pxtorem之类的插件却发现 echarts 的字体没转换,多半是 echarts 通过 canvas 渲染的文本不走 CSS 单位,px 转换工具对它天然无效,不要纠结,canvas 里的字体大小用原生数字值就行。
6.5 严格检查依赖只有一个版本
最后说一个隐蔽的问题:项目里如果同时装了vue的多个版本(比如某个依赖内部依赖了vue@2),或者某个库被重复打包多次,Rollup 分析报告里会看得一清二楚。解决办法是检查package.json,用npm dedupe或者手动锁定版本。比如若依 vue3 ts 版本报错,很多时候就是因为 ts 的依赖版本不匹配导致的,处理方式就是让 vue、typescript、vite 的版本对齐官方推荐的组合。
最后再分享一个小心得
打包优化这活儿没有尽头,也没必要追求"极致小"。我个人的原则是:在保障功能完整、代码可维护的前提下,把该拆的拆开、该省的省掉、该压的压住,让用户首屏体验从"等得难受"变成"基本流畅",就足够了。我这边优化到 3.2MB 总量、1.9s 首屏之后,就没再继续往下卷了。非要比极限,还能上 CDN、做流式加载、上 PWA 离线缓存,但这些都是后期锦上添花的事,得结合团队收益来判断值不值得做。
后续如果发现新的构建问题或验证了新的优化方案,我会继续回来更新这篇文章。如果你在 vite 打包上踩过什么冷门但有效的坑,也欢迎一起交流。