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 默认的 analyzerModeserver,构建完会起一个本地服务等你看图。本地没事,CI 上每次构建都卡在那等它起服务,直接挂住超时,运维在群里@我的时候我脸都红了。分析插件别默认在每次构建都打开,老老实实用开关。

analyzerMode 三种模式各有用处:server 起本地服务自动开浏览器,本地随手看方便;static 生成独立 HTML 文件,适合存档、发给同事、丢进 CI 产物;json 只输出 stats 数据,适合接体积监控、卡阈值。我现在 CI 上用 staticopenAnalyzer: 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。把 vueelement-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 放在一起。