☰
Webpack打包分析指南:Stat、Parsed、Gzipped三种体积的优化实践
2026/10/14 3:55:28 网站建设 项目流程

1. 为什么打包分析报告里会有三种体积?先看它们到底是谁

最近在优化一个后台管理项目,webpack 打包出的入口文件足足有 3MB 多,打开速度一直不理想。我把webpack-bundle-analyzer的 treemap 报告调出来之后,发现每个模块下面并排显示着三列数字:Stat Size、Parsed Size、Gzipped Size。第一次看到这三列数字的人很容易懵,以为 Stat 最大的就该先优化,实际上完全不是这么回事。

Webpack-bundle-analyzer 是一个基于 webpack stats 数据生成可视化图表的小工具,它能把最终 bundle 里每个模块的体积按三种口径展示出来。Stat、Parsed、Gzipped 这三个词分别对应模块在三个不同阶段的体积:磁盘里的原始文件体积、经过构建压缩后真正打进包的体积、再经过 gzip 压缩后通过网络传输的体积。别小看这三组数字,它们能直接帮你定位哪些依赖是虚胖,哪些依赖才是真正的加载瓶颈。

这篇文章我就用实战视角把三个指标拆开讲清楚,顺便把安装、配置、报告解读和基于这三组数字的优化顺序一次说全。无论你是刚接手一个老旧打包配置,还是想把首屏包体积再压低几个百分点,看完你至少不会再对着 treemap 发呆。

1.1 第一次打开 treemap 的现场:三列数字的含义

我第一次用 webpack-bundle-analyzer 时,浏览器弹出一个矩形块图,每个色块代表一个模块或文件,鼠标悬停在上面会浮动显示模块路径和三个体积。比如某个lodash.js,Stat Size 是 1.2MB,Parsed Size 却是 240KB,Gzipped Size 又变成 80KB。如果不了解背后机制,肯定会有人指着 Stat Size 说“这库太大,必须换掉”,但真正影响浏览器解析和执行的其实是 Parsed Size,真正影响下载时长的是 Gzipped Size。

这个工具的底层原理不复杂:webpack 在构建时会输出一份 stats.json,里面记录了每个模块的源码路径、所属 chunk、原始大小、转换后大小等信息。webpack-bundle-analyzer 再读取这些信息,按照模块路径的依赖树关系画出树状矩形图。由于它展示的是三个维度的“同一个人”,所以我们完全可以借这三组数字来判断一个模块的“脱水率”和“网络压缩率”。

1.2 三个指标对应代码的不同生命周期阶段

要理解三者的区别,最好的方式是把一段代码从硬盘走到浏览器的过程分成几个阶段。

第一阶段是源码阶段。模块还以import之前的原始文件状态躺在磁盘里,包含缩进、注释、长长的变量名、未使用的函数。这个阶段的体积就是 Stat Size,本质上是“这个文件写成了多大”。

第二阶段是 webpack 处理后的运行时阶段。模块经过 loader 转换(比如 babel 把 ES6 转成 ES5)、Tree Shaking 摇掉没用的导出、Terser 压缩变量名和删除注释,最后变成一段会被 JS 引擎真正解析的代码。这个阶段的体积就是 Parsed Size,也就是“最终塞进 bundle 的那部分到底多大”。

第三阶段是网络传输阶段。浏览器请求 JS 时,服务器如果开启了 gzip 或 brotli 压缩,会先把这段代码压缩成更小的字节流再传给浏览器。这个压缩后的体积就是 Gzipped Size,它决定了用户等多久才能拿到这个文件。

把三个阶段放在一起看,Stat 是裤子称的裸重,Parsed 是脱了外套称的净重,Gzipped 是打包进旅行箱后再过一次安检称的重量。三者不具备必然的等比关系,因为压缩算法对重复文本的敏感度不一样。

2. 三种体积的完整拆解:Stat、Parsed、Gzipped 到底在算哪一段

这一章我们把每个指标单独拉出来,说说它们到底计算的是哪些字节,以及为什么经常会出现 Stat 远远大于 Parsed、Gzipped 又远远小于 Parsed 的情况。

2.1 Stat Size:最原始的“裸重”

Stat Size 在 Webpack 的语境里通常指模块在被打包前的原始大小。这里要说清楚一件事:它不等于你在编辑器里看到的源码字符数,因为 webpack 可能先经过 loader 把它转换成另一种格式,Stat 一般取的是 loader 处理完成、还没有进入最终 minify 阶段时传给 bundle 的模块内容大小。

举个例子,某个项目里引用了day.js,源码体积可能是 300KB,但实际用到的方法只有一小部分。Stat Size 会把整个 day.js 文件算成一个 300KB 的模块,展示出来就是一个大红块。如果没有 Tree Shaking 和压缩,这个 300KB 会原封不动地进 bundle,Parsed 就会和 Stat 接近;如果配置了压缩,Parsed 会明显降下来。所以 Stat Size 可以理解成“模块给 webpack 的初始输入有多重”,主要反映的是模块绝对体量,以及你的依赖管理是否过度引入。

对于自己写的业务代码,Stat 大小与源码体感差不多,包含空格、注释、可读性好的变量名。对于 node_modules 里的第三方包,Stat 往往是这个包发布时的未压缩版本大小,很多包发布时会带dist/*.js和dist/*.min.js,webpack 默认可能加载的是main字段指向的未压缩版本,Stat 自然就偏大。

2.2 Parsed Size:打包压缩后的“净重”

Parsed Size 是构建产物中真正属于该模块的那部分字节数。在 webpack 生产模式中,bundle 会经过optimization.minimize处理,默认是 TerserPlugin。Terser 会把变量名缩短成单字母,删掉不会被执行的代码,移除注释和多余空格,还可能做常量折叠。同时 webpack 的 Tree Shaking 会在打包阶段把那些import了但没被实际调用的导出移除掉。

因为这一步是“真正上线跑起来之前”的体积,所以 Parsed Size 对浏览器 JS 引擎的解析和编译时间影响最直接。JS 下载完要先解压,然后浏览器要用 CPU 去 parse 源码生成 AST,再编译成字节码。这个阶段处理多少字节,跟 Parsed Size 强相关。Gzipped 再小也帮不了 parse 阶段,因为浏览器拿到数据解压后,还是要面对完整解压后的代码体量。

因此,如果你发现一个模块的 Parsed Size 依然很大,比如超过了 200KB,就算它在 Gzipped 下只有 50KB,也需要认真思考是否需要按需加载或拆包,因为首屏解析性能不会因为 gzip 而变好。

2.3 Gzipped Size:网络传输视角下的“运输重量”

Gzipped Size 是对 Parsed Size 对应的代码内容做 gzip 压缩之后的估计字节数。webpack-bundle-analyzer 内部使用的是 Node.js 的 zlib 库,把该模块解析后的字符串作为输入,执行一次 gzip 压缩,然后统计压缩结果的长度。注意,它是对模块单独做压缩,而不是对整个 bundle 文件做压缩后再拆分,所以最终展示值是一个估算,不是线上用户实际下载时的精确数值。

为什么 gzip 对 JavaScript 效果这么好?因为 JS 代码里包含大量重复的语法结构,比如function、return、const、大括号、分号,以及同一个包内部大量重复的变量命名模式。gzip 算法会寻找重复出现的长字符串,用更短的引用替换。于是代码里越是重复多的文本,压缩比例越高。同一个包,如果变量名全部被 Terser 压缩成单字母,重复度反而不如压缩前高,这也解释了为什么 minify 之后有时 gzip 的绝对体积仍然不小,因为代码里已经没有太多长重复串可压缩了。

Gzipped Size 最直接的应用场景是估算网络传输耗时。假设 4G 网络环境下下载速度约为 5Mbps,那么一个 500KB 的 gzip 文件需要大约 0.8 秒,而一个 1.5MB 的 gzip 文件则需要 2.4 秒。用户感知到的白屏等待,很大程度由这个数字决定。

2.4 三者的常见数值规律与计算方向

正常情况下,Stat > Parsed > Gzipped 是一个基本规律。比如一个手写的工具函数,源码 10KB,经过压缩后 6KB,再 gzip 后 2KB,这很常见。但非正常情况下,你会看到一些模块 Parsed 比 Stat 大,后面第五章会专门讲。

把三个数字放在一张表里看会更直观:

指标计算阶段影响因素典型优化手段
Stat Sizeloader 处理完后、minify 前引用的包体积、源码数量去除未使用依赖、改用轻量库
Parsed Sizeminify、Tree Shaking 后压缩配置、摇树效果、代码拆分开启 production 模式、按需引入
Gzipped Sizegzip 压缩后代码重复度、gzip 级别、是否使用 brotli拆包减少首屏内容、配置 brotli 压缩

我在实践中一般会把 Stat 当作“模块的真实家底”,把 Parsed 当作“优化重点”,把 Gzipped 当作“首屏体感参考”。如果你只看 Stat,容易把一些实际压缩率很高的包误判为元凶;如果只看 Gzipped,又容易忽略解析成本。三种值必须对照着看。

3. 动手生成和分析 webpack-bundle-analyzer 报告

搞清楚三个指标是什么之后,我们来把报告跑起来。其实整套操作非常轻量,只要项目里装了 webpack,五分钟内就能看到 treemap。

3.1 安装与基础配置

先安装依赖:

npm install -D webpack-bundle-analyzer

然后在 webpack.config.js 里引入插件,放在 plugins 数组里即可:

const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer'); module.exports = { plugins: [ new BundleAnalyzerPlugin({ analyzerMode: 'static', reportFilename: 'bundle-report.html', defaultSizes: 'parsed', openAnalyzer: false, excludedAssets: /\.map$/, }) ] };

我的习惯是生产模式构建时通过环境变量来控制是否启用这个插件,避免每次npm run build都弹浏览器。具体的做法是在 package.json 的脚本里传入参数,然后在 webpack.config.js 里根据参数判断:

const isReport = process.env.REPORT === 'true'; plugins: [ isReport && new BundleAnalyzerPlugin({ analyzerMode: 'static', reportFilename: 'bundle-report.html', }) ].filter(Boolean)

这样我可以在需要分析时单独执行:

REPORT=true npm run build

而不需要重新改配置。这个习惯我已经用了很久,比一直开着插件省心得多。

3.2 四种 analyzerMode 的区别

analyzerMode是这个插件比较关键的字段,支持 four 个值:server、static、json、disabled。

  • server是默认模式。构建完成后会在本机启动一个 HTTP 服务,自动打开浏览器加载交互式报告。适合本地手把手点着看,但不利于 CI 环境。
  • static会生成一个静态 HTML 报告文件,适合保存下来或者随构建产物留档,后续可以反复打开,不需要依赖 node 进程。
  • json只输出 stats.json,不展示页面。适合接入自动化脚本或自定义质量门槛。
  • disabled相当于只收集数据不生成报告,其实很少直接用到,更多是和其他工具配合。

defaultSizes也很重要,它决定报告页面默认展示哪一列。可以选stat、parsed、gzip。我建议本地分析时默认用parsed,因为这是我们优化重心的口径。如果你想一眼看哪些包影响首屏加载,可以把默认调成gzip,这样最大的色块很可能就是你需要优先处理的网络瓶颈。

3.3 通过 stats.json 做离线分析

有时候项目非常大,跑一次完整构建就要很久,我不想每次为了看报告都重新构建。这时可以单独生成 stats.json,用命令行工具加载:

npx webpack --profile --json > stats.json npx webpack-bundle-analyzer stats.json

生成 stats.json 时建议带上--profile,否则某些模块的时序数据可能缺失。之后在任意时刻执行第二条命令,就能基于这份历史数据打开报告。这个能力很实用,比如 CI 构建完把 stats.json 传到服务器,你再拉到本地分析,不用在 CI 环境里启动交互服务。

同时你也可以把这份 stats.json 接入自己的体积预算脚本。比如写一个简单的 Node 脚本,读取 entrypoints 的gzipped值,如果超过阈值就报警。这样既能借助 webpack-bundle-analyzer 的展示能力,又能实现自动化的质量门禁。

3.4 怎么读 treemap 里的色块和明细

报告打开后是树状矩形图,每个大矩形是一个 chunk,或者在模块视图下是一个顶级目录。色块面积越大,表示当前口径下的体积越大。鼠标悬停在某个模块上,会浮出详情,包含模块路径、所属 chunk、以及 Stat Size、Parsed Size、Gzipped Size 三个数字,还会显示这个模块占 “所有模块合计” 的百分比。

左边侧边栏可以对三种 size 排序、搜关键包名。我最常用的操作是先搜一下供应商包的名字,比如搜索react,看它占据的色块面积,再切换到gzip排序比较一下。如果某个包在 gzip 排列下排第一,说明它对首屏网络体积影响很大;如果在 parsed 排列下排第一但 gzip 下很小,说明它主要是解析成本高。

treemap 的细节没有太多难度,难的是把三个数字的关系转化为决策。比如 “react-dom” 的 Stat 是 800KB,Parsed 是 130KB,Gzipped 是 45KB。这时如果只纠结 Stat 去换一个更小框架,收益可能并不大,因为真正拖网速的是 45KB,真正拖解析的是 130KB。更好的做法是确认是否已经用了 production 版本,以及能否通过路由懒加载让这 130KB 不在首屏解析。

4. 怎么用这三种数字指导打包优化

工具本身不会帮你减肥,它只能告诉你哪里肉多。但如果我们能正确解读三列数字,优化顺序就会清晰很多。

4.1 先把“虚胖”和“实胖”分开

有些模块 Stat 大,Parsed 也大,Gzipped 也大,这才是真正的“实胖”。比如某个 UI 库完整版,Stat 单文件 2MB,Parsed 压缩后 700KB,Gzipped 后 220KB。它给你的首屏造成双重打击:既拖慢下载,又拖慢解析。这种模块就值得花力气去替换,比如用按需引入版本,避免 import 整个库。

另一些模块 Stat 大,但 Parsed 很小,Gzipped 更小,属于“虚胖”。典型场景是第三方包发布时带了大量文档式注释和未使用代码路径。Terser 处理完后,注入 bundle 的部分只有原来的五分之一。这种模块就算 Stat 显示很吓人,也不太需要干预,因为最终代价已经很低。

还有少数模块 Stat 小,Parsed 反而大,这种情况绝大多数出现在没有开启压缩的构建环境里,后面章节再展开。

4.2 优先处理 Parsed Size 排名靠前的模块

我的执行顺序通常是从报告左侧栏按parsed排序,找出前五大模块,然后逐项问三个问题。

第一,这个模块是否真的需要在首屏执行?如果它只在某个路由的页面里用到,就应该用动态import()拆出去,让它进入独立的 chunk,按需加载。第二,这个模块是否可以换成更小的替代品?比如moment在 gzip 后仍然偏大,换dayjs常常能缩减一大截。第三,这个模块是否引入了太多无关内容?比如使用lodash整包引入,换成lodash-es或只引入用到的函数,treemap 里的面积立刻缩水。

Parsed Size 是优化的核心指标,因为无论网络再怎么压缩,浏览器总要去解析这些代码。把 Parsed 总大小降下来,首屏 JS 执行时间才会缩短。

4.3 再看 Gzipped Size 决定优先级

当多个模块的 Parsed Size 差不多时,我会再用 Gzipped Size 二次筛选。为什么?因为两个 100KB Parsed 模块,一个 gzip 后 80KB,另一个 gzip 后 20KB,前者意味着它都是高频且不太重复的代码,后者则说明代码大量重复、压缩率高。

这里有一个经验:如果某个外部依赖的 Gzipped 占比很高,而 Parsed 也不低,优先考虑网络加载优化,比如把它挪到 CDN、单独成一个 chunk 并设置长期缓存。如果 Gzipped 很低但 Parsed 很高,说明它是“本地膨胀型”,网络不是瓶颈,重点应该放在拆分执行时机,避免首屏就去解析它。

我还喜欢看 Gzipped 报告中前十个模块累计占整个报告的百分比。如果前十个模块占了 70% 以上,说明依赖集中度高,优化范围小,容易见效;如果前十个只占 30%,问题就比较分散,可能需要从整体打包策略上做调整,比如开启 scope hoisting、更细致地拆分公共依赖。

4.4 一个模拟项目的优化复盘

这里用一个实际发生过的最小化场景来说明。某后台管理项目打包出一个入口 chunk,报告数据如下:

指标优化前优化后
入口 chunk Parsed Size2.72 MB1.18 MB
入口 chunk Gzipped Size890 KB431 KB
首屏资源请求数1114

一开始 treemap 里最显眼的三个模块是:完整版图表库、moment.js、一个内部多个图表组件混在一起的业务模块。前期团队为了图方便,用import * as echarts from 'echarts'把全部图表类型都引了进来,Stat 显示超大。后来改成只引入用到的折线图、柱状图模块,Parsed Size 肉眼可见地掉了一大截。

moment.js 则属于“笔记型”问题,它本身在 Gzipped 下不算特别大,但 Parsed Size 接近 240KB。换成 dayjs 后,Parsed Size 直接降到 2KB 多,总体收益相当明显。最后那个业务模块通过路由懒加载切分成了两个异步 chunk,首屏只保留列表页必需的组件。

这次优化里我没有去动 Stat Size,因为 Stat 展示的很多注释和格式本身在生产环境就会被压缩掉。重点始终瞄准 Parsed 和 Gzipped,尤其保证首屏 chunk 的 Gzipped 尽量小。

5. 常见问题排查与避坑实录

使用 webpack-bundle-analyzer 分析体积时,一定会在某些瞬间产生“这数字是不是坏了”的怀疑。下面几个问题是我被问过很多次,也是自己踩过坑的部分。

5.1 为什么某个模块 Parsed Size 比 Stat Size 还大

出现这种反常情况通常有几个原因。

第一个原因是构建模式没设成production。开发模式中 webpack 默认不开启optimization.minimize,Terser 不执行,同时 webpack 会给每个模块套上一层模块包装函数,也就是(function(module, exports, __webpack_require__) { ... })之类的外壳。这层壳本身就是几行代码,加上 loader 转译后增加的一些辅助函数,Parsed 完全可能比 Stat 大。

第二个原因是模块本身在package.json中配置的module字段指向的是一个未压缩的 ESM 源文件,而 webpack 打包时对它做 Tree Shaking 和代码合并之后,部分代码被重复拼接,导致统计口径看起来超过了原始统计值。

第三个原因是 source map。如果配置里把 source map 内容和 JS 内容合在一起,parsed 计算的可能会包含内联的 source map 注释,体积自然膨胀。这个问题可以通过excludedAssets: /\.map$/或者在 devtool 选项里选择外置 source map 来规避。

排查方法很简单,先检查当前构建是否是mode: 'production',再看 TerserPlugin 是否执行。也可以在配置里单独设置optimization: { minimize: true, minimizer: [new TerserPlugin()] }。

5.2 Gzipped 数值和浏览器 DevTools 里的传输大小不一致

经常有人拿着分析器里的 Gzipped Size,对比 Chrome DevTools Network 面板里看到的“编码后大小”,发现不一样。这是正常的,因为两者不是同一种东西。

webpack-bundle-analyzer 统计 Gzipped 时,是对每个模块单独取 Parsed 内容再 gzip,然后把每个模块的 gzip 后字节数加起来。而浏览器下载的整个 JS 文件,是对整个 bundle 文件做一次 gzip 压缩。整个文件里的公共字符串可以在不同模块之间复用,通常整体 gzip 的压缩率会比单个模块累加更好一点点,但也不排除因为压缩器版本或压缩等级不同而产生差异。

更关键的是,如果线上开启了 Brotli 压缩,DevTools 显示的是 Brotli 编码后的大小,而不是 gzip。Brotli 对 JS 文本的压缩率通常比 gzip 再低 10% 到 15%,所以两者数字不一样是正常现象。分析器只是一个估算工具,真正精确的数据要看服务器实际响应头里的content-encoding。

5.3 分包场景下怎么解读三个数字

当项目配置了 SplitChunks,报告里会出现多个 chunk。此时你可能会看到同一个模块出现在几个 chunk 中,webpack-bundle-analyzer 会分别统计它在不同 chunk 里的体积。比如 React 可能在 “vendor” chunk 和 “common” chunk 中各占一部分。

这种时候不要简单地把所有 chunk 的 Parsed 相加来评估总包体积,因为有些 chunk 会共享同一个模块对象,物理上可能只加载一次。应该先确认这个模块是否真的会在用户访问时被重复下载。一般来说,公共依赖会被抽成一个单独的 vendor chunk,浏览器只加载一次,后续页面复用缓存。重点关注入口 chunk 和首屏需要加载的异步 chunk 即可。

另外,动态导入的 chunk 在报告里可能显示为独立色块,Stat 和 Parsed 会比较大。这部分如果不被首屏访问,其实可以放到后面再处理。

5.4 报告太大、页面卡顿怎么办

项目模块数量超过几千个之后,webpack-bundle-analyzer 的静态报告可能会变得非常卡,打开时甚至要转十几秒。我遇到过某个项目 node_modules 非常多,生成的 HTML 到了几十 MB,浏览器直接白屏。

这种情况可以在插件配置里把reportFilename设成一个独立目录,然后对excludedAssets做过滤,排除掉.map文件和其他不需要关心的资源。还可以增加maximumFileSizeToConsume这个选项,避免超大文件被读取。如果只是想在 CI 里跑体积预算,不要打开静态页面,直接用analyzerMode: 'json'输出 stats.json,写脚本处理数据就好。

5.5 同一依赖在 treemap 里出现好几份

分析报告如果显示某个库在多个位置出现重复,往往是项目里同时安装了不同版本的依赖。比如一个深层依赖引用了 react 16.x,而项目本身用的是 react 18.x,npm 会把两个版本都装进 node_modules,webpack 会把它们分别打包。通过报告里的搜索功能可以找到重复模块的路径,看到它们的实际大小。解决思路是使用resolve.alias强制统一版本,或者用DedupePlugin之类的策略清理。

6. 分享几个我实际用下来觉得顺手的技巧

最后一章不写总结,就是几个琐碎但实用的心得。

在使用 webpack-bundle-analyzer 分析体积时,我一般会把defaultSizes设成gzip,因为 Gzipped 排序会暴露那些真正对用户体验影响最大的包。这个视角和用户实际感知最接近。等锁定几个大模块后,再切到parsed看解析成本,决定是拆包还是换库。

第二个技巧是分析完记得生成一份静态报告放进构建产物目录。这样每次发版后都能拿旧的报告跟新报告做对比,用脚本抓两个报告里的 Gzipped 总量,快速发现哪些改动把包体积撑大了。很多项目体积是“温水煮青蛙”式膨胀的,如果没有固定的对比机制,很难发现每次上线多了 20KB 的小变化。

第三个技巧是不要迷信“所有依赖都要按需引入”。有时候为了按需引入,代码反而变得复杂,拆分出很多小 chunk,每个 chunk 都要走一次网络请求。HTTP/2 下这问题不大,但如果白屏阶段需要串行下载多个 chunk,拆太碎可能比一个整包还慢。用报告里的请求数和总体积一起评估,比单纯追求某一个数字更靠谱。

Stat、Parsed、Gzipped 这三个数字本质上是一个模块的三种不同人生阶段。掌握他们之间的差异,比单纯会配置工具更重要。碰见一个体积可疑的模块时,先别急着换库,打开报告看三列数字再下结论,许多优化决策都会自然变得顺理成章。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询