做前端这几年,Vite基本已经是我搭建Vue3项目的默认选择了。开发服务器启动快、HMR反馈几乎无感,体验过一次之后确实很难再回webpack那一套。但有一个问题很容易被低估:项目做大了以后,vite build的出包速度和产物体积,是需要单独花精力去调的,不然上线前你会在构建机上等到怀疑人生。
我手里有几个Vue3后台管理系统,页面数量过百,全局引用的库又有element-plus、echarts这类重家伙。最初一次vite build要40多秒,产物4MB以上,首屏加载被同事吐槽了好几次。这篇文章就是把最近几轮优化的思路、具体配置和踩过的坑整理一遍,适合刚把Vue3项目迁到Vite、或者正在被构建时间和包体积折磨的同学参考。后面有新进展我会持续更新。
1. 动手优化前,先量化现状与定位瓶颈
1.1 基准数据要记牢
刚接手一个Vite构建项目,别急着改配置。第一步先把现状量化出来:构建总耗时、产物总体积、最大chunk体积、首屏实际加载的资源数,这四个数据记下来,后面每一步优化做完都要回来对照,才知道改动到底有没有效果。
构建总耗时和产物体积最直观,看终端里vite build跑完输出的那行即可。
$ npm run build > vite build vite v6.0.3 building for production... transforming... ✓ 642 modules transformed. dist/index.html 0.45 kB │ gzip: 0.29 kB dist/assets/index-D1x2c3e4.js 1382.50 kB │ gzip: 412.35 kB dist/assets/vendor-9f8a7b6c.js 2064.10 kB │ gzip: 601.22 kB ✓ built in 38.62s看到这样的输出,心里基本就有数了:单个vendor chunck超过2MB,gzip后601KB,首屏加载绝对会卡。于是我还额外用浏览器DevTools的Network面板看了一遍线上页面,确认首屏需要等待的request数量,以及最大的几个JS文件的下载时间,把这些问题记进优化清单里。
这一步不要偷懒。很多时候我们凭感觉觉得“包很大”,但到底大在哪,不做记录后面排查会很痛苦。
1.2 用构建日志定位耗时大头
如果构建时间太长,建议给Vite挂一个临时插件,把构建各阶段的耗时量化出来。
// vite.config.js import { defineConfig } from 'vite' import vue from '@vitejs/plugin-vue' export default defineConfig({ plugins: [ vue(), { name: 'build-timer', buildStart() { console.time('build-time') }, closeBundle() { console.timeEnd('build-time') } } ] })比起肉眼盯着终端发呆,这种打点方式能准确告诉你哪一部分最耗时间。我实测下来,Vite生产构建的主要耗时通常集中在三块:
- 依赖预构建与分析:如果项目里第三方依赖特别多,初次构建时对依赖的扫描和转换要占不少时间。
- 源码转换与打包:业务代码量越大,这一阶段越慢,尤其是使用了大量SFC和TS文件时。
- 压缩混淆:默认用esbuild压缩,如果切换成terser,压缩阶段的耗时可能翻倍甚至更多。
建议先跑一次npx vite --debug build看看输出,--debug模式会打印很多底层细节。也可以临时加一个耗时插件来定位。定位完瓶颈之后再针对性地调整,比盲目套网上的优化配置靠谱得多。
2. 依赖预构建与构建提速的核心配置
2.1 预构建缓存与 optimizeDeps 的取舍
Vite的依赖预构建是它的一个标志性机制,开发模式下用esbuild对node_modules里的依赖做预打包,把CommonJS/UMD格式转成ESM,并缓存到node_modules/.vite目录。这带来的好处是开发服务器启动时不需要像webpack那样全量编译,浏览器请求依赖时直接走缓存,速度自然快。
生产构建时同样会对依赖进行预扫描和处理,所以optimizeDeps的配置对构建速度也有影响。项目初期我不建议手动维护一长串include和exclude,多数情况下默认配置已经够用。真正需要介入的场景,我遇到过两种:
一是某个依赖在开发阶段反复触发“重新加载”,或者生产构建报“Failed to resolve import”,这时需要在include里手动指定依赖名,强制Vite对它的依赖关系做完整预构建。
二是某个库本身已经是规范的原生ESM,并且体积很小,可以尝试exclude掉减少一次预构建耗时。但要提醒一句:exclude操作要谨慎。如果这个库内部依赖关系复杂,排除预构建之后反而会导致开发服务器加载大量零散文件,性能直接下降。
如果你修改了optimizeDeps配置,但构建结果还是老样子,多半是缓存没失效。清理方式有两种:删掉node_modules/.vite目录,或者执行npx vite --force强制重建缓存。这算是我踩过最频繁的一个坑,尤其是重复切换分支时,总要记得先清理一次。
2.2 压缩器选 esbuild 还是 terser
Vite 5和Vite 6默认生产压缩器都是esbuild,但对老项目或从webpack迁移过来的项目,很多人会习惯性地去配置terser,因为webpack生态里terser-webpack-plugin太普遍了。这里就涉及一个取舍:esbuild压缩速度快,产物体积略微偏大;terser压缩速度慢不少,但产物能再小一点。
我个人的经验是:除非上线要求压缩到极致,否则用默认esbuild,不要折腾terser。原因很简单,esbuild的压缩速度是terser的好几倍,对构建时间的收益非常明显,体积差距通常在1%到3%之间。对绝大多数业务系统来说,这点体积差远不如构建速度重要。
如果确实要用terser,配置也很简单:
// vite.config.js export default defineConfig({ build: { minify: 'terser', terserOptions: { compress: { drop_console: true, drop_debugger: true } } } })注意,drop_console在生产环境去掉console.log,也是常用优化之一。但不建议把所有console全部删掉,如果线上需要临时排查问题,一点日志都没有也很被动。比较稳妥的方案是保留warn和error级别的输出,只去掉log和debug。
2.3 chunkFileNames 与 output 路径规范
构建输出文件名的命名规则,很多人会忽略,但它和浏览器缓存策略直接相关。Vite默认生成的产物文件名自带hash,比如index-D1x2c3e4.js,这是为了内容更新后保证浏览器能拿到新文件。不过,分包之后如果不对chunk文件做统一命名,可能上线后排查问题时分不清哪个chunk对应哪个模块。
我在项目里的配置一般是这样的:
build: { rollupOptions: { output: { entryFileNames: 'assets/js/[name]-[hash].js', chunkFileNames: 'assets/js/[name]-[hash].js', assetFileNames: 'assets/[ext]/[name]-[hash][extname]', manualChunks: {} } } }entryFileNames管入口文件,chunkFileNames管路由懒加载拆分出来的chunk,assetFileNames管图片、字体等静态资源。把JS和CSS、图片分目录存放,配合CDN部署时写缓存规则会清晰很多。
3. 产物体积优化:懒加载、按需引入与手动分包
3.1 路由懒加载和组件按需加载
后台管理系统最常见的体积问题是:所有页面代码全部打到一个chunk里,首屏加载时把整个系统的代码都下载一遍。解决的第一个动作就是路由懒加载,让用户访问哪个路由,才去加载对应页面的代码。
// router/index.js import { createRouter, createWebHashHistory } from 'vue-router' const routes = [ { path: '/', component: () => import('@/views/HomeView.vue') }, { path: '/report', component: () => import('@/views/ReportView.vue') } ] export default createRouter({ history: createWebHashHistory(), routes })对于Vue3项目,一二级路由都建议写成动态import()形式,这样每个页面会被单独打包成chunk。页面里如果有比较大的弹窗组件、抽屉组件,也不要全量注册进父组件,可以等打开弹窗时再用defineAsyncComponent异步加载。
除了路由级别的拆分,还有一个容易忽略的点:一些大型图表组件、富文本编辑器,如果只在某个页面里用,不要写在入口文件里全局注册。我曾经维护过一个可视化大屏项目,最开始在main.js里全局注册了ECharts,结果每个页面都背着ECharts跑,首屏白白多出几百KB。改成按需引用之后,体积下降非常明显。
3.2 第三方库按需引入的三个案例
element-plus按需引入
很多后台管理系统都在用element-plus,如果直接在main.js里app.use(ElementPlus)整包引入,光这一个库的体积就非常可观。官方推荐的方式是用unplugin-vue-components和unplugin-auto-import做自动按需引入。
// vite.config.js 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()] }) ] })配置完成后,组件模板里直接写<el-button>,样式和逻辑会自动按需引入,不再需要全局引入element-plus/dist/index.css。这里有个小坑:如果你同时在某个文件里手动import了ElMessage,别忘了它对应的样式也要按需引入,否则会出现“有逻辑没样式”的诡异问题。
echarts按模块引入
ECharts整包引入的体积很大,但通常一个项目只会用到柱状图、折线图、饼图、散点图里的几种。更好的做法是按模块注册:
// utils/echarts.js import * as echarts from 'echarts/core' import { BarChart, LineChart, PieChart } from 'echarts/charts' import { TitleComponent, TooltipComponent, GridComponent, LegendComponent, DataZoomComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([ TitleComponent, TooltipComponent, GridComponent, LegendComponent, DataZoomComponent, BarChart, LineChart, PieChart, CanvasRenderer ]) export default echarts这样打包时会自动tree-shaking掉不需要的部分。注意echarts/core和echarts/charts这些子路径必须是ESM格式才能被正确摇树,Vite下默认就是支持的。
lodash等工具库
如果用lodash整包引入,可以改成lodash-es,配合Vite的tree-shaking,只保留实际使用的方法。例如:
import { debounce, cloneDeep } from 'lodash-es'3.3 manualChunks 手动分包的两种写法
按需引入做完之后,剩余的还是有一批公共依赖。如果全部打到一个vendor里,可能这个vendor还是很大;如果完全交给Vite自动拆分,又可能出现chunk数量过多、加载碎片化的问题。因此,手动分包是优化产物体积和缓存命中率的关键一步。
常见的配置有两种:
数组式:直接指定包名,适合依赖数量少的项目。
build: { rollupOptions: { output: { manualChunks: { vue: ['vue', 'vue-router', 'pinia'], element: ['element-plus'], charts: ['echarts', 'zrender'] } } } }函数式:更灵活,可以按模块路径动态归类。
build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('echarts') || id.includes('zrender')) { return 'charts' } if (id.includes('element-plus') || id.includes('@element-plus')) { return 'element-plus' } if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) { return 'vue-vendor' } return 'vendor' } } } } }我在实际项目中用函数式更多。但这里有一个很关键的注意点:如果函数式写法里最后写了return 'vendor',就等于把所有未匹配到的node_modules依赖都塞进vendor,这个vendor很容易变成2MB以上的巨型chunk。所以如果不是特别有必要,可以让部分小依赖跟着业务代码走,不要一股脑全塞进vendor。
分包数量也要控制。拆得太细,浏览器并行请求数会增加,HTTP/1.1下请求并发有限,会造成阻塞。我一般会控制在5到8个chunk以内,既保证缓存粒度,又不至于把loading瀑布流拉得太长。
3.4 gzip压缩与静态资源处理
产物压缩是性价比相当高的优化手段。Vite官方不内置gzip,但社区插件vite-plugin-compression可以直接接入。
// vite.config.js import viteCompression from 'vite-plugin-compression' export default defineConfig({ plugins: [ vue(), viteCompression({ algorithm: 'gzip', threshold: 10240, // 只压缩10KB以上文件 deleteOriginFile: false }) ] })threshold: 10240表示只有超过10KB的文件才生成gzip版本,避免让小文件也多做一次压缩,反而拖慢构建。deleteOriginFile不建议设成true,否则服务器如果不做透明解压,原文件也没了,线上会直接白屏。
如果服务端已经开了gzip或者brotli,前端静态生成.gz文件就不是必须的,两种方案二选一即可,不要重复做。另外,图片、字体这类资源,gzip收益有限,真正大头是JS和CSS。建议再用vite-plugin-compression的algorithm: 'brotli'生成一份br文件,配合CDN部署时体积还能再小不少。
静态资源方面,Vite默认assetsInlineLimit是4096字节,小于4KB的图片会转成base64内联到代码里,减少请求数。如果项目里有大量小图标,可以把阈值适当调高到8KB或10KB,减少小图片的HTTP请求。大图片、PDF这类资源还是建议放到public目录,或直接走对象存储/CDN,不要把大文件打进产物。
4. 构建内存与多环境模式的细节调优
4.1 内存溢出与 max-old-space-size 的正确姿势
项目大到一定程度,构建时经常报“JavaScript heap out of memory”。这不是配置写错了,是Node默认堆内存上限不够用。最直接的解决办法是调大Node的内存上限。
在package.json里,把build脚本改成:
{ "scripts": { "build": "node --max-old-space-size=4096 node_modules/vite/bin/vite.js build" } }这里4096单位是MB,可以根据服务器内存调整,一般4GB到8GB足够。另一种方式是用环境变量:
export NODE_OPTIONS=--max-old-space-size=4096 && vite buildWindows的CMD里写成:
set NODE_OPTIONS=--max-old-space-size=4096 && vite buildPowerShell里写法不同:
$env:NODE_OPTIONS="--max-old-space-size=4096"; vite build搜索引擎里经常看到有人搜$ node_options=--max-old-space-size=4096 vite,然后Windows下直接报“node_options不是内部或外部命令”,这就是把类Unix shell的语法拿到Windows CMD里执行了。在Windows下只要用上面的set写法就能避免这个错误。
4.2 sourcemap到底该不该开
生产构建默认不开sourcemap,这对减小产物体积、保护源码编译结果都有好处。但如果你接入了错误监控系统,又需要通过sourcemap还原线上压缩前的代码,该怎么办?
我建议的方案是:生产环境用sourcemap: 'hidden',把map文件单独保存到监控平台,不在浏览器暴露。
build: { sourcemap: 'hidden' }hidden模式和普通模式一样会生成.map文件,区别在于生成的JS文件里不会带有sourceMappingURL注释,浏览器就不会主动去下载map文件,也就变相降低了源码暴露风险。监控平台需要分析错误栈时,再手动上传对应的map文件。
如果公司压根没有错误监控体系,那生产环境直接sourcemap: false,省体积、省构建时间,最简单。
4.3 多环境构建:vite build --mode test
很多团队除了生产和开发环境,还会有测试环境、预发布环境。Vite的环境模式通过--mode参数控制,不同模式会加载对应的.env文件。
项目根目录下可以创建:
.env # 所有环境通用 .env.development # 开发环境 .env.test # 测试环境 .env.production # 生产环境然后构建脚本里指定模式:
{ "scripts": { "build:test": "vite build --mode test", "build:prod": "vite build --mode production" } }在每个环境文件里定义变量:
# .env.test VITE_APP_TITLE=测试环境 VITE_API_BASE=https://test-api.example.com代码里通过import.meta.env.VITE_APP_TITLE读取。注意,只有以VITE_开头的变量才会暴露给前端代码,其它变量不会被打包进去。
这里有个容易踩的坑:如果vite build --mode test之后发现打出来的包还是走的production配置,记住,--mode只控制环境文件的加载,构建本身还是会走生产模式逻辑,包括压缩、tree-shaking等。所以不用担心用--mode test会打出开发模式包。
5. 实战中常见的构建问题与排查技巧
5.1 首屏加载慢的定位路径
首屏慢,先看浏览器Network面板,按资源大小排序,找出下载耗时最长的几个文件。如果是我们自己的chunk,就用可视化工具看体积构成。这里推荐rollup-plugin-visualizer:
// vite.config.js import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ vue(), visualizer({ open: true, gzipSize: true }) ] })构建完成后,插件会自动打开一个交互式的依赖体积分析页面,能直接看到每个模块占的字节数。这个工具是我做体积优化的第一选择,比猜靠谱得多。
定位到大模块之后,分别处理:如果是第三方库,考虑按需引入或单独分包;如果是自己业务代码,考虑路由懒加载或组件异步化。
5.2 懒加载和分包失效的排查方向
有时配置了路由懒加载,但构建产物里发现所有页面代码还是堆在一起,大概率是动态import()的路径没写对。Rollup要求动态导入必须使用静态可解析的字面量路径,不能是纯变量拼接,否则无法有效拆分chunk。
手动分包配置了但觉得没生效,可以从这几个方向排查:
manualChunks函数里有没有逻辑漏掉的依赖,导致所有模块都被归到默认chunk里。- 是否有其它插件覆盖了
output配置。 - 修改配置后没有清缓存,运行一次
rm -rf node_modules/.vite再重新构建。
5.3 Windows 下 node_options 报错还原
很多刚在Windows上配置Vite的同学会遇到下面的报错:
'node_options' 不是内部或外部命令,也不是可运行的程序或批处理文件。这不是Vite本身的问题,而是命令写法不对。$ node_options=...这种写法来自Linux/macOS的shell,Windows的CMD并不支持。解决方案在4.1小节已经给过了,Windows下用set NODE_OPTIONS=--max-old-space-size=4096 && vite build,或者直接把--max-old-space-size参数写进package.json的build脚本里,这样团队所有成员统一命令,不用各自记环境变量。
5.4 常见问题速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 构建报“JavaScript heap out of memory” | Node堆内存不足 | 调大--max-old-space-size,或调整构建脚本 |
| 产物存在超大vendor chunk | 所有依赖被集中打包 | 使用manualChunks按库拆分,控制单个chunk体积 |
| 首屏加载许多小chunk | 分包过细 | 合并小chunk,减少HTTP请求数 |
配置了vite build --mode test却读到生产变量 | 同名变量在不同.env中被覆盖 | 检查.env.test与.env.production中变量名是否冲突 |
代码里用了process.env却报错 | Vite默认不注入Node变量 | 使用import.meta.env,或通过define注入 |
| 某些依赖在构建时解析失败 | 依赖是CJS/UMD且预构建未处理完善 | 在optimizeDeps.include中补充依赖名 |
6. 持续迭代的优化清单与下一步规划
6.1 把优化固化进CI
优化做完之后,最怕两件事:一是后续开发中无意间引入了大依赖,导致体积悄悄反弹;二是团队成员各自在本地构建,没人维护构建时长。因此,我建议把体积和构建时间检查纳入CI流程,给团队定一个性能预算。
一个简单做法是在CI脚本里加一个体积检查脚本,例如:
// scripts/check-size.mjs import { readdirSync, statSync } from 'node:fs' import { join } from 'node:path' const assetsDir = 'dist/assets' let totalSize = 0 for (const file of readdirSync(assetsDir)) { const size = statSync(join(assetsDir, file)).size totalSize += size } const budget = 800 * 1024 // 800KB if (totalSize > budget) { console.error(`产物总大小 ${(totalSize / 1024).toFixed(2)}KB,超过预算 ${budget / 1024}KB`) process.exit(1) } console.log(`产物体积 ${(totalSize / 1024).toFixed(2)}KB,在预算范围内`)再配合定时任务监控CI构建日志中的built in耗时,出现明显增长时可以及时回溯是哪次提交引入的。
6.2 后续想验证的方向
Vite生态更新速度很快,最近我在关注的方向有两个。一是Vite官方正在推进的rolldown打包器,它在底层用Rust实现,理论上打包速度会比现在的Rollup方案更快,后续如果稳定,我打算在非核心项目上试水。二是随着项目规模继续增长,可以考虑在架构层面把后台系统拆分成微前端,不同业务模块独立构建、独立部署,进一步缩短单次构建时间。
优化本身没有终点。构建工具的版本、业务代码的规模、团队的交付节奏都在变,所以这篇文章叫“持续更新”。目前这套配置在我的几个后台项目里已经稳定跑了几个月,构建时间从40多秒压到了10秒左右,产物总体积也降了将近一半。以后有新坑、新进展,我会继续补充。
最后分享一个体会:优化项目时最容易犯的错,就是看到网上的优化技巧就往上堆,结果配置越来越复杂,问题反而增多。我的习惯是每加一个优化项,都记下改动前后的基准数据,收益不明显就回滚。打包优化不是炫技,能用最少的配置换来可感知的速度提升,才是真正有价值的优化。