Vue 项目包太大怎么查:先出报告,再谈首屏优化
包体积问题最怕靠猜。首屏慢了,有人怀疑图片,有人怀疑 UI 库,有人想先改压缩参数;可只要没有一张真实的体积分布图,这些判断都只是各凭经验下注。
这次后台首页第一次打开要等十几秒,缓存没问题,真正慢的是一个 2MB 出头的主包。在这种情况下,继续靠感觉讨论“可能是谁太重了”没有意义,先把包拆开看清楚,才知道优化该从哪里下刀。
后面只围着一句话展开:**体积出问题,先出报告,再动手。**而让我把“猜”变成“证据”的工具,就是 webpack-bundle-analyzer。
第一步:先把包看清楚
Bundle Analyzer 的价值不在那张彩色方块图好不好看,而在于它把"感觉很大"变成可讨论的证据:
- 哪个第三方库最重
- 哪些模块重复出现
- 哪个页面 chunk 明显偏大
这些问题不先搞清楚,后面的优化就是瞎忙。
接入很简单。裸 webpack 项目直接挂插件:
1const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 2 3module.exports = { 4 plugins: [ 5 new BundleAnalyzerPlugin({ 6 analyzerMode: 'static', 7 openAnalyzer: false, 8 reportFilename: 'bundle-report.html', 9 }), 10 ], 11};
我们项目是去年底用 Vue CLI 3 重建的,配置收在 vue.config.js 里,我按环境变量开关:
1const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); 2 3module.exports = { 4 configureWebpack: (config) => { 5 if (process.env.ANALYZE === 'true') { 6 config.plugins.push(new BundleAnalyzerPlugin()); 7 } 8 }, 9};
要看报告时才开:
1ANALYZE=true npm run build
为什么非要环境变量开关,是我自己踩出来的。第一天图省事,直接 new BundleAnalyzerPlugin() 写死在 plugins 里提交了——analyzer 默认的 analyzerMode 是 server,构建完会起一个本地服务等你看图。本地没事,CI 上每次构建都卡在那等它起服务,直接挂住超时,运维在群里@我的时候我脸都红了。分析插件别默认在每次构建都打开,老老实实用开关。
analyzerMode 三种模式各有用处:server 起本地服务自动开浏览器,本地随手看方便;static 生成独立 HTML 文件,适合存档、发给同事、丢进 CI 产物;json 只输出 stats 数据,适合接体积监控、卡阈值。我现在 CI 上用 static 加 openAnalyzer: false,构建产物里带一份报告,需要时下载来看,不打断流程。
如果连插件都不想装,还有个零依赖的路子:webpack --json > stats.json 导出统计数据,丢到官方的 webpack analyse 在线工具里,也能看个大概,临时排查够用。
报告出来了:真正的问题不是大家猜的任何一个
第一份报告打开,我盯着那张 treemap 看了五分钟,然后在组里群发了一张截图。
看图我有固定的四问:
- 主包里有没有低频页面代码
- 第三方库是否明显超过预期
- 同类库是否重复出现
- 某个业务模块是否被错误打进公共包
四问下来,答案跟组里所有人的猜测都不一样。图片是清白的(图片根本不在 JS 包里);Element UI 确实大但不是最大;最大的一块方块写着 moment/locale——一个时间格式化库,连全部语言包一起,占了三百多 KB。第二大的问题是报表页的图表库整个躺在主包里,而报表是个一周没几个人点的低频页面。第三个问题是路由压根没拆,三十多个页面全在一个 app.js 里。
这些东西不看图根本想不到。还有一个教训值得写下来:不要只盯最大的方块。一个中等体积的库如果被首屏强制加载,比一个很大的懒加载 chunk 更影响体验——衡量的尺子是"首屏必须下载多少",不是"总共有多大"。
动手:一个一个拔钉子
moment 的语言包
moment 有个老毛病:require('moment') 会把几十个 locale 全带上。当年的标准解法是 IgnorePlugin 把语言包干掉:
1const webpack = require('webpack'); 2 3new webpack.IgnorePlugin(/^\.\/locale$/, /moment$/)
需要中文再单独 import 'moment/locale/zh-cn'。三百多 KB 一下降到几十 KB。更彻底的是换成 dayjs,API 几乎同款、体积差一个数量级,但全项目替换要回归测试的地方太多,我跟组长商量后决定这期先用 IgnorePlugin 止血,dayjs 列进下期重构。止血和根治可以分两步走,别把优化任务做成重构任务。
路由和低频重模块拆分
图表库这种"报表页需要,不代表首页也要跟着下载"的模块,用动态 import 拆出去:
1const ReportChart = () => import(/* webpackChunkName: "report-chart" */ '@/components/ReportChart.vue');
路由也按页面拆:
1const OrderList = () => import(/* webpackChunkName: "orders" */ '@/views/orders/List.vue'); 2const OrderDetail = () => import(/* webpackChunkName: "orders" */ '@/views/orders/Detail.vue');
给同名 webpackChunkName 是个常用技巧:订单列表和订单详情用户多半连着看,放一个 chunk 里,进列表时把详情也加载了,点进详情就不用再等。反过来,报表、导出 Excel 这种用户半天点不了一次的功能,单独拆,绝不能拖累首屏。同一业务流程聚合、低频重模块独立,这是我拆分时的两条线。
webpackChunkName 这个魔法注释别拼错——写错了不会报错,只是默默生成一个奇怪命名的 chunk,分析图上你都认不出它是谁。我习惯 chunk 名跟业务目录对齐,看图时一眼能定位。
拆完还可以再进一步:webpack 4.6 之后支持 webpackPrefetch 魔法注释,让浏览器在空闲时用 <link rel="prefetch"> 提前把懒加载 chunk 拉回来,用户点过去时秒开:
1// 首页大概率会进订单页,空闲时预取 2const OrderList = () => import(/* webpackChunkName: "orders", webpackPrefetch: true */ '@/views/orders/List.vue');
注意 Vue CLI 3 默认已经给所有异步 chunk 加了 prefetch,低频大 chunk 反而应该在 chainWebpack 里把 prefetch 插件的名单改掉,别让报表图表库也被"空闲预取"占用户带宽——prefetch 是好东西,但要挑着用。
工具库和组件库的按需
工具库不要整包引入:
1// 不推荐 2import _ from 'lodash'; 3 4// 更可控 5import debounce from 'lodash/debounce';
组件库这块踩的人特别多,多说两句。Element UI 全量 import ElementUI from 'element-ui' 一下几百 KB 全进来;配了 babel-plugin-component 之后写 import { Button } from 'element-ui',只会打进用到的组件。但有个坑:很多人 JS 配了按需,样式却还是 import 'element-ui/lib/theme-chalk/index.css' 整包引,JS 瘦了 CSS 没瘦,分析图上 CSS 那块还是一大坨。按需引入要 JS 和样式一起按需,这俩是配套的。我们项目就是这个半吊子状态,补配之后 CSS 也掉了一截。
还有一条这次没走但值得记下的路:externals + CDN。把 vue、element-ui 这类稳定大件从包里剔出去,改成 index.html 里一个 CDN 的 <script>:
1externals: { 2 vue: 'Vue', 3 'element-ui': 'ELEMENT', 4}
包立刻小一大块,还能蹭 CDN 缓存。代价是引入了外部依赖——CDN 抖一下整个后台白屏,版本也要手动跟 package.json 对齐。我们是内网门店场景,公网 CDN 反而不可靠,评估后没上。方案没有绝对好坏,得看部署环境。
重复打包和 vendor
分析图上同一个库名出现在好几个 chunk 里,就是重复打包的信号。这时候看 optimization.splitChunks,把多个 chunk 共用的第三方库抽到 vendor:
1optimization: { 2 splitChunks: { 3 chunks: 'all', 4 cacheGroups: { 5 vendors: { 6 test: /[\\/]node_modules[\\/]/, 7 name: 'vendors', 8 priority: 10, 9 }, 10 }, 11 }, 12}
不过 vendor 也不是越大越好:全塞一个 vendor 里,改一行业务代码不影响它,缓存利用率高;但要是某个常变的库混进来,vendor 哈希老变,缓存就白搭了。这块要结合各依赖的实际更新频率权衡,没有标准答案——这跟月中缓存那篇里 chunkhash 的道理是同一条线。
对数据前先对口径:gzip 和实际加载
改到第三天,跟组长汇报进度时闹了个乌龙:我说主包还有 1MB,他说他看着只有 300 多 KB,两边对着数字吵了十分钟,最后发现看的根本不是同一个尺寸。
Bundle Analyzer 右上角能切换三种尺寸:stat(原始文件)、parsed(webpack 处理后的产物大小,默认显示这个)、gzip(压缩后传输体积)。我看的是 parsed 的 1MB,他看的是 gzip 的 300KB。对体积数据之前,先确认大家看的是同一个口径。
分不同口径是有实际意义的:真正决定用户下载时间的是 gzip(或 brotli)后的体积,所以评估首屏成本看 gzip size;parsed size 更多用来定位"谁占的大"。另外还要区分 initial chunk(首屏必须加载)和 async chunk(按需加载)——优化首屏时优先看 initial,一个很大的 async chunk 没那么紧急,除非它在高频路径上。
想让这个口径长期不跑偏,webpack 自带的 performance 配置可以当"体积预算"用,超了就在构建时告警:
1performance: { 2 hints: 'warning', 3 maxEntrypointSize: 500 * 1024, // 入口资源超 500KB 就报 4 maxAssetSize: 300 * 1024, 5}
我把它配进了 CI,谁的提交把首屏包顶爆了,构建日志里当场现形,不用等下一次运营投诉。
优化后要验证,别自我感觉良好
一周任务的最后一天,我没有急着报喜。优化不是删完就结束,至少要确认:
- 构建是否通过
- 路由懒加载页面是否能打开
- 旧浏览器兼容是否受影响
- 首屏资源是否真的减少
- 高频页面是否增加了明显等待
拆包可能减少首屏体积,也可能让后续页面多一次请求。最终要看用户路径,而不是只看总包变小。
我的验证不靠感觉,靠 DevTools 的 Network 面板:勾上 Disable cache、网络限速调到「Fast 3G」,刷新看首屏到底下载了哪些 JS、总大小多少、白屏到可交互花了多久。光看 webpack 输出的体积数字不够,那是"理论值",真实体验得在限速的浏览器里跑一遍。优化前后我各录了一次,对比首屏请求列表,确认图表库那些大块头确实挪进了 async chunk、initial 真的瘦了。顺手还跑了一遍 Chrome 自带的 Lighthouse,性能分从 40 出头爬到了 70 多——这个分数发周报里,比"我感觉快了"有说服力得多。
最终数字:首屏 JS 从 2.1MB(parsed)降到 700KB 出头,gzip 后 230KB 左右。周五远程连上门店那台老电脑实测,首页转圈从十几秒缩到四秒上下。运营的原话是"能忍了",对一台 2015 年的门店收银机,我觉得这是很高的评价。
别让优化反噬可维护性
最后提个醒,也是我差点犯的错。包体积优化容易上头,越拆越细,第四天我一度写出了七八条 cacheGroups 规则、魔法注释满天飞的配置,实习生看了一眼说"这我以后不敢动"。我删回了三条。
我的原则是:每一处拆分都得能说出"为什么拆、拆给谁省"。说不出收益的拆分就别做。低频重模块懒加载、第三方大库按需、公共依赖抽 vendor,这几样性价比最高,先做这些。变量名混淆、tree-shaking 这些交给生产模式默认配置就行,自己别瞎调。优化是为了让用户更快,不是为了让配置更花哨。
回头看这一周,Webpack Bundle Analyzer 最大的作用,是把"感觉有点大"变成"我知道大在哪里"。组里三个人三种猜测,没有一个猜中 moment 的语言包——前端优化最怕靠直觉。真正有效的包体积优化,是"先分析,再拆分,再验证",不要让优化变成一轮凭感觉的代码搬家。这句话我写进了这次的优化记录里,跟那份 bundle-report.html 放在一起。