做前端构建优化时webpack-bundle-analyzer 几乎是标配。打开这个插件的报告第一眼看到的是右下角那块密密麻麻的矩形树图再往右上角看会发现可以切换 Stat、Parsed、Gzipped 三种视图。很多同学折腾半天也没弄明白这三者究竟差在哪优化时到底该看哪个数字。这篇文章就专门围绕这三个尺寸来拆讲清楚它们分别代表什么、怎么读、怎么用以及基于它们做体积优化的一套实操思路。适合正在做 webpack 构建分析、或者被首屏体积困扰的同学新手可以补全原理老手可以直接拿后面的排查清单用。1. 为什么同一个包会出现三个体积数字从一次性能排查说起1.1 一个典型的体积排查现场之前帮某内部管理系统做性能优化首屏加载接近 3 秒团队对报告上的数字争论了很久。Stat 显示 3.8MBParsed 显示 1.6MBGzipped 只有 480KB。团队分成两个流派一派认为 480KB 已经很小不用管另一派认为 3.8MB 是雷必须拆。其实两派都有道理但争论的根源是把三个口径混在一起用了。Stat、Parsed、Gzipped 不是三种不同的包而是同一个包在构建流水线不同阶段留下的“快照”。理解这一点后面所有决策都不会跑偏。1.2 构建流水线中的三个环节一个现代 webpack 项目从源码到用户浏览器至少要经过三段处理loader 转换TypeScript 编译成 JavaScript、JavaScript 按 browserslist 降级、CSS 被处理成 JS 模块、静态资源被内联或外链。这时候 webpack 模块系统里记录的体积是 Stat。压缩与摇树TerserPlugin 这类工具会删除死代码、缩短变量名、合并表达式同时 webpack 会根据模块的 ESM 关系做 tree-shaking。此时产物里每个模块实际占用的体积是 Parsed。网络传输压缩产物发布到服务器或 CDN 后如果开启了 gzip 或 brotli会在传输前再做一次文本压缩。webpack-bundle-analyzer 无法拿到服务器实时压缩结果但可以用本地的 gzip 算法对 Parsed 做估算这就是 Gzipped。把这三个体积对应到用户感知上更直白一些Stat 是源码处理的“原始账本”Parsed 是浏览器必须解析执行的 JavaScript 体量Gzipped 是用户手机上的网络下载量。三者解决的问题不一样不能简单画等号。2. Stat、Parsed、Gzipped 分别是什么原理层面的拆解2.1 Stat Sizeloader 处理后的原始尺寸Stat Size 是 webpack 模块在构建内部记录的 size也就是模块代码经过所有 loader 处理之后、压缩介入之前的体积。很多人误以为 Stat 就是“源码大小”实际上它往往比源码更大原因至少有四个babel 会把高级语法降级一降级就会注入 helper 函数比如_classCallCheck、_extends这些代码会被计入模块体积。引入 polyfill 或 core-js 相关模块时完整引用的部分会全部被坦克进 Stat。CSS-in-JS、动态注入样式的库会把样式逻辑加进模块。如果 devtool 配置了内联 source map模块里还会带上 sourceMappingURL 或内联映射内容Stat 会明显虚胖。Stat 最核心的价值有两个。第一用来发现“原始体重”超标的依赖比如一个库只用到某个函数却整体引入了整个包。第二把 Stat 和 Parsed 放一起看能判断压缩和摇树环节到底有没有干活。如果 Stat 很大、Parsed 很小说明死代码被删得多项目里可能存在大量未使用的导出或者历史遗留版本引用如果 Stat 和 Parsed 差不多大说明压缩器也没找到多少可删的东西这时候要么代码本来就精简要么 tree-shaking 没有正常工作。需要强调的是Stat 不代表用户下载量也不代表浏览器执行量它只是分析“源头膨胀”的参考维度。2.2 Parsed Size压缩摇树后的模块实体体积Parsed Size 是模块在最终 bundle 产物中占用的体积也就是经过 minify 和 tree-shaking 之后这个模块的代码留在产物里的实际大小。理解 Parsed 要抓住两个关键点第一它是浏览器真正要接收、解析、编译、执行的那段 JavaScript 的近似体量。虽然它不直接等于 parsing 耗时但体积越大下载后的 parse/compile 成本大概率越高尤其对低端安卓机和老版本浏览器影响明显。第二它的数值比 Stat 小是正常的因为压缩器做了大量删除和重写。比如 Terser 会把const isProduction process.env.NODE_ENV production; if (isProduction) { console.log(production branch); } else { console.log(dev branch); }这种代码在构建时直接折叠成一个分支另一个分支彻底消失。这就是为什么 webpack-bundle-analyzer 默认显示 Parsed 视图因为它是三个指标里最接近“运行时成本”的代理指标。但 Parsed 也有盲区。最常见的盲区是依赖里的 CommonJS 模块webpack 无法做静态分析只能把所有代码保留进产物导致 Parsed 明显高于预期。所以看到某个模块 Parsed 比想象中大第一时间要去看它的模块格式是 ESM 还是 CommonJS。2.3 Gzipped Size网络传输层的压缩估算Gzipped Size 是对 Parsed 文本执行 gzip 压缩后的估算体积。webpack-bundle-analyzer 内置了 gzip 实现能在本地对每个模块做压缩估算所以你在报告里看到的是一个“近似值”不是服务器/CDN 的真实响应体字节数。gzip 的原理简单说就是 LZ77 加 Huffman 编码。它对重复文本极其敏感minified 之后的 JavaScript 变量名大量重复所以压缩率高得吓人很多纯 JS 包能压到 25% 到 35%。反过来说如果代码里嵌入的是 base64 字体、图片或者已经高度压缩的内联资源gzip 的收益会低很多甚至体积变大。这里有一个很容易踩的坑报告里的 Gzipped 不等于真实网络传输体积。服务器/CDN 的压缩等级可能与本地默认估算不同。有些 CDN 对动态响应不启用压缩只压缩静态文件。如果开启的是 brotli实际体积会比 gzip 再小 15% 到 20% 左右。图片、字体这类二进制资源在 gzip 视图下基本看不出差异不能在分析器里用它评估静态资源。Gzipped 最该回答的问题是用户需要下载多少字节才能开始执行这包代码。如果首屏预算卡在 200KB 以内Gzipped 就是用来检查预算是否超标的直接指标。3. 实操把 webpack-bundle-analyzer 正确接入构建流程3.1 安装与最小配置绝大多数项目要做的是生产环境分析建议把它加到开发依赖里npm install -D webpack-bundle-analyzerwebpack 5 项目可以直接用 4.x 版本老项目如果还在 webpack 4需要注意版本兼容建议锁定 3.x。接入配置写在 webpack.prod.js 里const { BundleAnalyzerPlugin } require(webpack-bundle-analyzer); module.exports { plugins: [ new BundleAnalyzerPlugin({ analyzerMode: static, reportFilename: ../reports/bundle-report.html, openAnalyzer: false, generateStatsFile: true, statsFilename: ../reports/stats.json, defaultSizes: gzip, }), ], };初始化配置里我建议默认值这样设置报告放到仓库外的 reports 目录避免污染构建产物openAnalyzer 关掉不要在每次构建时自动弹浏览器除非你想打断自己的思路generateStatsFile 开启后面做 CI 对比和命令行分析都用得到。3.2 核心参数说明拿到最顺手的分析姿势常用参数按重要程度排序下面这些字段建议都配一遍参数可选值作用analyzerModeserver / static / json / disabledserver 本地起交互服务static 生成 HTML 报告json 输出数据文件defaultSizesstat / parsed / gzip报告默认展示哪种体积口径我习惯用 gzipgenerateStatsFiletrue / false额外产出 stats.json供二次分析使用statsFilename路径字符串控制 stats.json 输出位置openAnalyzertrue / false是否自动打开浏览器excludeAssets正则排除不想参与统计的资源比如 vendor 里的地图logLevelinfo / warn / silent控制插件输出噪音关于 analyzerMode 的选择我个人的使用习惯是日常迭代用 disabled配合 generateStatsFile 产出 stats.json这样构建速度不受影响真正要看包体积时再跑一次 static 模式生成 HTML需要交互式审查模块归属关系时用 server 模式。server 模式默认监听 127.0.0.1:8888端口占用时会自动换号算是一个小贴心设计。3.3 用命令行直接分析已有的 stats.json实战里最方便的方式之一是本地拉取 CI 里生成的 stats.json 再分析。命令行分析命令如下npx webpack-bundle-analyzer reports/stats.json dist --mode gzip第二个参数 dist 是资源根目录如果 report 里的文件路径是相对路径它需要用这个参数来定位。--mode 可以切换默认视图可选 stat、parsed、gzip还支持 --host、--port、--no-open 等参数。实测下来命令行的作用主要是两个一是打开服务器模式方便在浏览器里交互查看模块依赖链二是把同一份 stats.json 传给团队成员大家不需要重新跑构建就能看同一份数据。配合 jq 之类工具还可以对 stats.json 做自定义查询比如统计某个特定前缀下的模块总量这个后面讲。4. 用三种尺寸定位问题体积优化不能只看一个大数字4.1 一个典型的优化案例从三个数字里找线索某模拟项目里业务模块按需加载没做报表页面所有代码被 import 到了主包里。打开报告后三个模式下的数据是这样Stat 视图某图表库相关模块 800KB明显是完整包引入。Parsed 视图图表库降到 320KB但主 chunk 仍然接近 1MB说明 tree-shaking 部分生效但还有大量无关业务代码被留了进来。Gzipped 视图主 chunk 压缩后 320KB单看网络传输可以接受但在中低端设备上执行这 1MB 未压缩代码的成本并不低。这种情况下只看任何一个数字都会得出相反结论只看 Gzipped 觉得没问题只看 Stat 又觉得必须拆到极致。正确做法是先把主 chunk 从路由层面拆开让报表页使用动态 import 单独成包再用按需引入的方式处理图表库经过一轮调整后三个数字才同时降到合理区间。4.2 三个指标各自暴露的问题口径对照表把三个指标组合起来看能快速判断是哪个环节出了问题指标观察重点典型问题优先动作Stat 偏大依赖数量、loader 注入、重复包完整引用了大库、多个版本共存、babel 降级严重按需引入、检查重包、优化 browserslistParsed 偏大tree-shaking 是否生效、模块格式CommonJS 依赖、sideEffects 配置缺失、重复封装换 ESM 包、修正 sideEffects、精简业务冗余Gzipped 偏大网络传输预算、文本重复度内联资源过多、整包引用、未启用 CDN 压缩拆包、动态加载、开启 brotli/gzip每个指标都不是孤立存在的。Stat 大且 Parsed 小说明压缩器手脚很勤快但源头还是娇气Stat 小但 Parsed 大说明摇树环节没干活问题出在依赖格式或 sideEffects 标记上Gzipped 和 Parsed 差距很小说明代码里混入了大量压缩率低的资源比如 base64 文件。4.3 优化执行顺序从用户可感知的端开始倒推说一个我每次都会用的执行顺序这比上来就删库要稳先看 Gzipped 是否超出预算。如果首屏预算卡在 200KBGzipped 已经 500KB那不用管 Stat 和 Parsed先拆包和动态加载。Gzipped 达标但页面还是慢切到 Parsed 视图找出体积大且执行逻辑重的模块。压缩器救不回执行成本代码体量在那里parse/compile 的成本就在那里。切到 Stat 视图检查有没有依赖被整包引入。特别留意那些名字里带 all、full、complete 的导出基本都是把整个库拖进来的元凶。最后处理重复依赖。Stat 里出现多个相似体积的相同库名版本用包管理器的 ls 命令确认是不是多版本共存。每一步都有明确目的不会出现“把体积砍到很小但页面依旧卡”的尴尬。5. 常见问题与避坑记录分析器不会写在文档里的细节5.1 报告数字和浏览器 Network 面板对不上这个问题几乎每周都有人问。原因有几个第一Network 面板里的 transfer size 是网络传输层字节数如果 CDN 开了 brotli它比 analyzer 的 gzip 估算还小。第二服务器未必对每个文件都启用了压缩特别是动态资源。第三 analyzer 的 Gzip 是基于 Parsed 做的本地估算和服务器压缩参数、压缩等级都可能不同。排查思路是先确认响应头里的 content-encoding 是不是 gzip 或 br再看 Content-Length 和传完的字节数最后对比 analyzer 里同一模块的 Gzipped。只要数量级对得上就说明项目本身没问题差异来自压缩环境和估算逻辑。5.2 Parsed 体积明显比预期大tree-shaking 失效在报告里经常看到某个库 Stat 和 Parsed 相差不大几乎等于没摇树。最常见的三个原因依赖包是 CommonJS 格式webpack 没法静态分析所有代码都被保留进产物。package.json 里没有定义 sideEffectswebpack 不敢假设模块是没有副作用的所以保守保留。babel 的 preset-env 配置把 modules 转换成了 commonjsESM 信息在转译阶段就丢了。解决方案也直接优先选用提供 ESM 产物的库确认依赖的 package.json 里 sideEffects 标记正确babel 配置保持 modules: false让 webpack 自己处理模块语法。处理完再重新生成报告Parsed 通常会有肉眼可见的下降。5.3 分析器不能长在生产线配置里很多人图省事把 BundleAnalyzerPlugin 直接放在公共配置中结果每次构建都要多跑一遍分析流程CI 时间长不说还会在构建日志里暴露内部依赖结构。建议用一个环境变量来控制开关const { BundleAnalyzerPlugin } require(webpack-bundle-analyzer); module.exports { plugins: [ new BundleAnalyzerPlugin({ analyzerMode: process.env.ANALYZE ? static : disabled, generateStatsFile: process.env.ANALYZE ? true : false, openAnalyzer: false, }), ], };平时构建完全不带分析成本需要检查时执行ANALYZE1 npm run build即可。5.4 不要把 Gzipped 用在图片和字体上这是一个很容易误导人的地方。gzip 对 PNG、JPEG 这类已经压缩过的二进制资源几乎无效甚至可能让体积略微增大。如果你在报告里看到一张图片的 Gzipped 和 Parsed 差不多这不是异常是资源本身已经没有文本压缩空间了。分析图片和字体的体积应该直接看文件大小而不是依赖打包分析器。字体文件主要的优化方向是子集化、按需加载和合适的格式图片则是压缩、裁剪和 CDN 优化和 Gzipped 关系不大。5.5 source map 会严重污染分析数据如果生产构建的 devtool 配置为 inline-source-map 或 eval-source-map报告里会出现大量 source map 相关节点Stat 和 Parsed 都会被抬高干扰判断。分析场景下建议临时关闭 source map或者单独为分析任务设置独立的 webpack 配置把 devtool 置为 false只保留模块关系分析。等分析结束再还原配置即可。这个动作能让你少摔很多跤。6. 我的实操体会与建议用下来的个人体会是defaultSizes 设置成 gzip 最省心因为一眼就能看到用户真实的网络成本但优化决策永远要三个口径交叉看。我会先拿 Gzipped 卡预算再用 Parsed 找执行热点最后用 Stat 查依赖源头这个顺序在几个不同类型项目上都很稳。另一个很实用的习惯是让体积检查成为持续流程。每次发版前生成一次 stats.json简单比对一下 Gzipped 总量比“凭感觉”靠谱得多。分析器只是工具真正的价值是让每次构建结果处于可比较、可追踪的状态。如果你还没把三个数字搞明白就急着删依赖大概率会浪费一下午却改错地方。先把口径对齐再动手优化。