1. 打包产物越来越大,问题到底出在哪
先说一个挺常见的场景:项目在开发环境一切正常,热更新快得飞起,结果一执行npm run build,构建产物动辄几 MB,甚至十几 MB。部署到线上以后首屏加载要好几秒,网络稍差一点直接白屏半天。如果你也遇到过这种情况,那这篇文章要聊的内容,你应该用得上。
这个问题的根源,其实是 Vite 在生产环境下默认的打包策略:它会把所有第三方依赖和应用代码打包进同一个 bundle 文件里。Vite 开发模式用 esbuild 做转换,快是快,但 esbuild 不做代码分割。到了生产构建,Vite 会切换到底层的 Rollup 来打包,Rollup 的默认行为又倾向于把一切揉成一个文件。这个"一个文件包含所有依赖"的策略,在项目小的时候无所谓,但一旦你的项目引入了像 ECharts、富文本编辑器、Excel 处理库这类体积较大的依赖,产物体积就会迅速失控。
Vite 生态里解决这个问题的手段,就是代码分割(Code Splitting)和懒加载(Lazy Loading)。代码分割是把代码拆成多个较小的 chunk,让浏览器可以并行下载,并且充分利用缓存;懒加载则是把某些代码延迟到需要使用时才加载,把首屏不需要的逻辑从加载路径里挪出去。两者配合使用,是生产环境构建优化里性价比最高的操作,不需要改动业务逻辑,纯粹从构建配置和组件加载方式入手,就能获得非常明显的收益。
这篇文章的受众,我默认是已经能熟练使用 Vite 搭建项目、但还没系统做过生产构建优化的前端开发者。文章里不会讲太多 Vite 入门内容,重点放在构建配置、分包思路和踩坑经验上。我把之前在一个中后台管理系统的优化过程完整复盘一遍,包括分析工具的使用、分包配置的演进、懒加载的落地方式,以及最后上线前遇到的那些让人头大的问题。
2. 构建分析先行,不拿到数据别急着动手
在没有分析数据的情况下做性能优化,基本等于盲人摸象。我第一次优化这个项目的时候,凭直觉把一堆依赖手写进manualChunks,结果不仅没变小,反而因为分包策略不当导致重复代码增多,总产物体积比优化前还大了几百 KB。后来才明白,任何构建优化都应该从分析当前构建产物开始。
2.1 rollup-plugin-visualizer 的使用方式
分析 Vite 构建产物,最直接的工具是 rollup-plugin-visualizer。这个插件会在构建完成后生成一个可交互的 HTML 报告,把每个 chunk 的组成、大小、依赖关系都可视化地展示出来。安装很简单:
npm install -D rollup-plugin-visualizer然后在 Vite 配置里加进去:
// vite.config.js import { defineConfig } from 'vite' import { visualizer } from 'rollup-plugin-visualizer' export default defineConfig({ plugins: [ visualizer({ open: true, gzipSize: true, brotliSize: true, filename: 'dist/stats.html' }) ] })配置里的gzipSize和brotliSize建议都打开,因为线上部署一般都会开 gzip 或 brotli 压缩,看压缩后的大小更接近真实传输体积。执行构建后,浏览器会自动打开报告页面。
这个报告里有几个关键信息需要重点关注:
- 最大的几个 chunk 是什么,由哪些依赖构成。
- 是否有代码被重复打包到多个 chunk。
- 是否存在某个第三方库单独占据了几百 KB,但实际只用到了其中一小部分功能。
我第一次跑出来的报告很典型:一个 800 多 KB 的 vendor chunk 里,ECharts 占了大头,其次是 Element Plus,然后是一个 JSON 处理库,还有一个手写代码编辑器。这个项目本质上是个数据展示类后台,图表、表格、表单占了绝大部分页面,但这些依赖全部被打进了一个文件。
2.2 从分析报告里读懂优先级
拿到报告之后,不要急着写manualChunks。先做一个简单的分类:哪些依赖是几乎所有页面都会用的,哪些依赖只服务于少数几个页面。
在我的项目里,Vue、Vue Router、Pinia 属于基础框架,几乎所有页面都依赖,应该打进一个基础 vendor chunk;Element Plus 的组件在多个页面中使用,但它本身是按需引入的,实际打包进去的体积没有想象中大;ECharts 只用于两个图表页面;那个 JSON 处理库只在一个数据导入页面用到;手写代码编辑器也只在一个配置页面用到。
这么一分,优化思路就清晰了:框架代码打成一个基础 chunk 没问题;ECharts、JSON 处理库、代码编辑器这些"大户"应该单独拆出来,配合路由懒加载,让它们只在对应页面被访问时才加载。
// vite.config.js import { defineConfig } from 'vite' export default defineConfig({ build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('echarts')) return 'echarts' if (id.includes('json-editor')) return 'json-editor' if (id.includes('code-editor')) return 'code-editor' if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) { return 'vue-vendor' } } } } } } })等等,如果你刚接触manualChunks,直接用函数形式的写法可能会踩坑。函数形式返回的是 chunk 名,Rollup 会根据返回值把所有匹配模块聚合到同一 chunk。但这样写有个隐患:上面的条件判断是依次执行的,vue的判断放在了echarts后面,如果某个包名恰好同时包含vue和echarts关键词,就会被分到前面的分组。实际项目里这种情况不常见,但为了保险起见,把更具体的包名判断放在前面是个好习惯。
这只是第一版粗略分包,真正的问题是:这样的manualChunks函数,每次新装一个依赖都要手动加判断,维护成本会越来越高。而且 ECharts 本身支持按需引入,直接把整个echarts包拆出来,体积仍然不小。所以后面的优化,我在"第三方库全量拆包"和"按需引入减小依赖体积"之间做了一组对比。
2.3 两种分包思路的对比
分包并不等于把所有第三方库都拆成独立的 chunk。分包的目标是"按需加载"和"缓存复用",而不是"拆得越碎越好"。
| 思路 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 全量拆包 | 每个大依赖单独一个 chunk | 每个 chunk 体积小,缓存粒度细 | chunk 数量多,HTTP 请求增多,维护成本高 |
| 按功能聚合拆包 | 把服务于同一类页面的依赖聚合到一个 chunk | 请求数量适中,加载路径清晰 | 需要提前规划好依赖分组,对业务模块划分要求高 |
我最终采用的是折中方案:框架必备的依赖放一起,按页面功能聚合的依赖独立成 chunk。这样首屏只加载框架 chunk,图表页才加载 ECharts chunk,导入页才加载 JSON 处理库 chunk。每个 chunk 的体积都不算大,浏览器加载路径也很清晰。
3. 手工分包里的细节:同名冲突、加载顺序与依赖关系
手工分包看着简单,真正落地的时候问题不少。我第一次拆分完,构建成功了,但页面运行时报了一堆错,全是xxx is not defined。排查了半天,发现是manualChunks分出来的 chunk 之间存在循环依赖,vue-vendor和echarts两个 chunk 互相引用了对方的代码。
3.1 chunk 之间互相引用导致的报错
这个问题在 Rollup 的manualChunks里不算罕见。某些库的内部实现会依赖另一个库,比如 ECharts 的一些扩展组件引用了 Vue 的响应式 API,当你把 ECharts 拆到独立 chunk 后,它和vue-vendorchunk 之间就形成了引用关系。Rollup 在生成代码时,如果没有把这种跨 chunk 引用处理得当,就会出现运行时错误。
提示:如果你发现手动拆包后运行时报错,先用
--debug构建一遍,或者直接把manualChunks改回对象形式,看是否还报错。通过这种二分法可以快速判断问题是否出在分包逻辑上。
我当时的处理方式是把 ECharts 的按需引入做彻底一点,只用到的图表类型和组件才引入,这样它和 Vue 的关系就简单多了,不再依赖 Vue 的内部 API。这也是"按需引入"除了减体积之外的另一个好处:依赖关系更清晰,跨 chunk 引用更少。
3.2 chunk 命名与浏览器缓存的关系
分包之后,chunk 的命名会直接影响浏览器的缓存命中率。Vite 默认对带有 hash 的 chunk 使用内容哈希,内容不变时 hash 不变,这个机制本身没问题。但如果你在build.rollupOptions.output里自定义了chunkFileNames,要注意不要把所有 chunk 都命名为固定的[name]-[hash].js格式,否则每次构建后所有 chunk 的 hash 都可能变化,缓存就全失效了。
推荐的做法是保留 Vite 默认的 hash 机制,只控制 chunk 的name部分,也就是manualChunks里返回的那个字符串。文件名模板保持下面这种配置即可:
// vite.config.js build: { rollupOptions: { output: { chunkFileNames: 'assets/js/[name]-[hash].js', entryFileNames: 'assets/js/[name]-[hash].js', assetFileNames: 'assets/[ext]/[name]-[hash][extname]' } } }[name]会被替换为 chunk 的名字,而这个名字在manualChunks里指定。内容不变的 chunk 在构建时 hash 不变,浏览器会直接走强缓存,只有真正改动过的代码才会重新下载。配置assetFileNames时要注意[ext]和[extname]的区别,前者不带点,后者带点,写错了静态资源路径会乱。
3.3 不只是手动分包:自动拆分隐藏的坑
从 Vite 4 开始,Rollup 的output.experimentalMinChunkSize选项可以自动合并过小的 chunk,避免小文件碎片化。Vite 5 和 Rollup 4 也默认带了一些智能拆分逻辑。但自动拆分在少数情况下会把某些只在低版本浏览器里才需要的 polyfill 拆到异步 chunk 里,导致兼容性问题。
我在一个需要兼容旧浏览器的项目里遇到过这个情况。Vite 默认把一些 ES 新特性的 polyfill 放进了主 chunk,而自动分包逻辑把一块相关的代码挪到了异步 chunk,结果老浏览器在加载异步 chunk 时直接语法错误。这种情况下的解决办法是:在build.target里明确指定需要支持的浏览器版本,让 Vite 在转换阶段就把语法降级到位,不要依赖运行时 polyfill。
4. 懒加载的三种落地姿势:路由、组件与第三方库
代码分割做好了,实际上是"拆好了等着用",真正让拆分生效的,是懒加载。很多开发者以为懒加载就是路由懒加载,实际上懒加载可以发生在三个层面:路由级别、组件级别、第三方库的按需引入。这三个层面往往需要配合使用。
4.1 路由懒加载是第一步
路由懒加载是最基础的一步,做法也最简单:把import改成动态import。在 Vue 项目里,配合defineAsyncComponent或者 Vite 的import.meta.glob可以批量处理。我之前用import.meta.glob一次性把所有页面组件都切成了动态导入:
// router/index.js const modules = import.meta.glob('../views/**/*.vue') const routes = [ { path: '/dashboard', component: modules['../views/dashboard/index.vue'] }, { path: '/chart', component: modules['../views/chart/index.vue'] } ]import.meta.glob返回的是一个对象,key是文件路径,value是一个动态 import 函数。默认情况下,这些模块会被拆成多个独立 chunk。如果某个页面依赖了多个组件和工具函数,它们会被一起打包进这个页面的 chunk 里。
这里有一个 Vite 的细节:import.meta.glob默认是全量匹配,如果你只想匹配某些目录下的文件,可以写成import.meta.glob('../views/**/index.vue')这样的模式。配合eager: true选项可以跳过懒加载直接静态导入,这个选项适合那些需要在首屏就渲染的页面。
4.2 组件级的懒加载要分场景
路由懒加载解决了页面级别的问题,但很多组件本身就很重。比如一个只在弹窗里出现的复杂表单,一个只在某个操作完成后才渲染的图表,如果它们被直接 import 进页面组件里,就失去了路由懒加载的意义。
组件级懒加载最常见的实现是defineAsyncComponent。在 Vue 3 里可以这样写:
// 一个只在弹窗里用的复杂表单组件 import { defineAsyncComponent } from 'vue' const HeavyForm = defineAsyncComponent(() => import('./components/HeavyForm.vue'))React 项目则用React.lazy加Suspense的组合:
import React, { Suspense, lazy } from 'react' const HeavyChart = lazy(() => import('./components/HeavyChart')) function AnalyzePage() { return ( <Suspense fallback={<div>加载中...</div>}> <HeavyChart /> </Suspense> ) }但组件懒加载有一个不能忽视的问题:组件切换时会有一段加载白屏时间,哪怕网络再快,用户也会感觉到卡顿。所以组件级懒加载要谨慎选择对象,不要对每个小组件都懒加载,否则反而是负优化。一般原则是:组件体积超过 50KB(压缩后),且不在首屏渲染路径上,才适合组件级懒加载。
4.3 第三方库的按需引入才是大头
很多时候,真正拖慢加载速度的不是你的业务代码,而是第三方库本身。以 ECharts 为例,完整包压缩后也有 300 多 KB,用它的时候通常会import * as echarts from 'echarts',但实际用到的图表类型只有折线图和柱状图。换成按需引入后,这部分代码能缩小到原来的三分之一。
// 按需引入,只加载用到的图表和组件 import * as echarts from 'echarts/core' import { LineChart, BarChart } from 'echarts/charts' import { GridComponent, TooltipComponent, LegendComponent } from 'echarts/components' import { CanvasRenderer } from 'echarts/renderers' echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, LegendComponent, CanvasRenderer])Element Plus 这类组件库也是同理。全量引入组件库,打包体积至少多出 200 多 KB;改成按需引入后,配合unplugin-vue-components自动按需注册组件,体积能降下来不少:
npm install -D unplugin-vue-components unplugin-auto-import// vite.config.js import Components from 'unplugin-vue-components/vite' import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' export default defineConfig({ plugins: [ Components({ resolvers: [ElementPlusResolver()] }) ] })使用unplugin-vue-components之后,代码里不需要手动 import 组件,组件库会自动按需引入,配合 Vite 的打包机制,体验非常顺滑。
5. 预加载策略与静态资源处理:容易忽略的最后一公里
代码分割和懒加载做完了,产物体积下去了,加载速度也快了不少,但还有一个环节容易被忽略:预加载策略和静态资源处理。很多开发者把分包做完就收工,结果首屏依然慢,原因就出在这里。
5.1 动态 chunk 的预加载链接处理
当路由懒加载生效后,浏览器在加载首屏页面时并不会主动去下载其他路由的 chunk。但用户的行为是不可预测的,他可能秒点某个按钮进入下一页,这时候如果没有预加载,还要等网络请求回来才能渲染。Vite 和 Rollup 在构建时会对动态 import 生成的 chunk 注入preload链接,这个行为默认是开启的。但如果你自定义了build.rollupOptions.output里的chunkFileNames,有些情况下 Vite 生成的预加载策略会失效。
更稳妥的做法是在首页或公共组件里手动预加载最可能被访问到的页面:
// 首页挂载后,预加载下一个最可能访问的页面 chunk const nextPage = () => import('../views/chart/index.vue') nextPage()这段代码执行后,浏览器会在空闲时下载这个 chunk,用户点击进入图表页时瞬间渲染,体验上几乎无感知。
5.2 静态资源的路径与体积问题
生产环境部署时,静态资源路径经常出问题。如果你的站点没有部署在域名根路径下,而是部署在子目录,比如https://example.com/admin/,那么 Vite 默认的base: '/'会导致所有静态资源路径 404。
正确做法是在vite.config.js里配置base:
export default defineConfig({ base: '/admin/' })export default defineConfig({ base: './' // 相对路径,适合嵌套部署 })相对路径方案在大部分场景下都能适配,但如果你用了import.meta.env.BASE_URL之类需要绝对路径的地方,相对路经会让逻辑出错。部署前确认一下资源路径是最基本的生产准备。
静态资源本身的体积也不能忽视。很多项目把图片、字体文件统统丢进src/assets,然后通过 import 引用,Vite 会把这些资源做一次 base64 内联(默认小于 4KB 才内联),超出限制的会单独输出成文件。如果你有一堆 3.9KB 的小图标,全都内联进代码里,代码体积就会悄悄膨胀。可以通过配置build.assetsInlineLimit调整内联阈值:
export default defineConfig({ build: { assetsInlineLimit: 4096 // 默认值,单位字节 } })实际业务里,我的建议是阈值设置成 4096 或者更低,不要盲目调高。内联小资源确实能减少请求数,但内联过多会把图片变成 base64 字符串塞进 JS,反而让 JS 文件变大,解析变慢。平衡点是你自己观察构建报告后决定的。
5.3 第三方 chunk 的缓存策略
前面提到浏览器的强缓存依赖 chunk 的 hash,具体到部署层面还有一层学问。vue-vendor这类基础 chunk,内容在很长一段时间内都不会变化,可以设置一个很长的Cache-Control: max-age=31536000, immutable。而业务代码的 chunk 变化频繁,缓存时间设置短一些,比如no-cache加上ETag。
在 Nginx 里可以这样配置:
location /assets/ { # 带 hash 的文件设置一年强缓存 add_header Cache-Control "public, max-age=31536000, immutable"; }这样配置的前提是文件名里有 hash,内容变了文件名就变,浏览器和 CDN 都会正确更新。如果文件名没有 hash,千万别设置这么长的缓存,不然用户永远看不到新版本。
6. 上线前的真实测量:改造前后的对比
聊了这么多理论和配置,最终还是要看真实的数据。我拿之前那个中后台系统做了两轮优化:第一轮只做拆包和路由懒加载,第二轮加上组件懒加载和第三方库按需引入。下面是一组对比数据,基于一个典型的开发环境(未压缩模式)和线上环境(gzip 压缩后):
| 指标 | 优化前 | 第一轮优化 | 第二轮优化 |
|---|---|---|---|
| 首页 JS 体积(gzip 后) | 约 520 KB | 约 260 KB | 约 120 KB |
| 首屏请求数 | 12 | 15 | 9 |
| 首屏完成时间(模拟 3G) | 约 6.8s | 约 3.9s | 约 2.1s |
| 图表页额外加载体积 | 无(已在首屏) | 约 350 KB | 约 130 KB |
首屏请求数在第一轮反而变多了,是因为拆出了多个异步 chunk,第二次优化后通过合并小 chunk 和预加载,请求数才回落。这组数据再次印证了一个观点:请求数和体积之间有一个动态平衡,不是请求越少越好,也不是体积越小越好。
第二轮的优化重点其实不是分包配置,而是按需引入。ECharts 按需引入后,图表相关代码从 350KB 降到 130KB;Element Plus 用自动按需引入后,体积减少也相当可观。这些都是优化前完全没预料到的收益。
所以整个优化过程,我的结论是:先分析,再拆包,再引入按需逻辑,最后用预加载补齐体验。四个步骤环环相扣,每一步都要用数据验证,而不是凭感觉做判断。
项目上线后的一个月里,后台系统的首屏加载时间始终维持在 2s 以内(4G 网络环境),比优化前的表现好了很多。数据的提升是一方面,另一方面是开发体验确实变好了:构建速度没有明显下降,开发环境热更新也不受影响,因为开发模式下这些配置基本不生效。
7. 几个容易反复踩的坑,以及最后的实操体会
优化过程中踩过的坑不少,我把比较典型的几个整理成表格,方便以后查阅:
| 问题 | 现象 | 原因 | 解决方法 |
|---|---|---|---|
| 手动分包后循环引用 | 页面加载时xxx is not defined | manualChunks 分组导致跨 chunk 循环引用 | 检查依赖关系,做好按需引入,减少跨 chunk 引用 |
| 组件懒加载白屏闪烁 | 组件切换时有明显 loading | 组件体积大且频繁切换 | 把组件懒加载限制在体积较大且低频访问的场景 |
| 动态 import 路径变量化 | 动态 import 的 chunk 没有被拆分 | Vite 无法识别动态 import 中的变量路径 | 使用import.meta.glob列出明确的模块路径 |
| 静态资源 404 | 刷新页面资源路径全部错误 | 部署路径不是域名根路径 | 配置base,或者在服务器端做路由回退 |
| 自动拆包导致的兼容性问题 | 低版本浏览器语法错误 | 拆出的异步 chunk 包含未降级代码 | 明确build.target,升级到 Vite 5+ 版本并检查配置 |
最后再分享一个配置层面的小技巧:如果你用的是 Vite 5 及以上版本,不妨试试experimentalMinChunkSize这个选项,它可以把过小的 chunk 自动合并,减少请求数。但也别太依赖它,合并策略在极少数情况下还是会产生不太理想的结果,构建完记得看一眼报告。
export default defineConfig({ build: { rollupOptions: { output: { experimentalMinChunkSize: 20480 // 小于 20KB 的 chunk 自动合并 } } } })设置成 20KB 后,那些只有几百字节的工具函数 chunk 会被自动填充到更大的 chunk 里,HTTP 请求数会明显减少。不过要注意,这个选项在 Rollup 官方文档里标记为实验性特性,某些边界场景下行为可能不符合预期,建议你在真实项目里验证后再用于生产环境。
做了这么多轮优化,我最深的体会是:代码分割和懒加载并不是一个"配一次就完事"的工作。项目在迭代,依赖在更新,页面在增加,构建产物的分布也在不断变化。每隔一段时间跑一次构建分析,观察产物体积变化,结合访问数据调整分包策略,才是长期维护的正确姿势。工具是死的,配置是活的,理解了背后的原理,用起来才不会被框架牵着鼻子走。