摘要:Vite 产物 JS 堆到近 2MB,我以为是 UI 库太大。打开 rollup-plugin-visualizer 的 Treemap 一看——最大色块居然是 mammoth(docx 预览)。这篇文章讲怎么看这张「体积地图」,以及我在真实项目里怎么定位、怎么拆。
业务越做越多,打包产物也越来越肥。
我负责的H5(React 18 + Vite),生产构建后 dist/prod 里 JS 合计大概 2MB。首屏倒不至于卡死——路由和 vendor 都拆过了——但一打开体积报告,心里还是咯噔一下:这堆东西里,到底谁在吃体积?
翻 package.json 没用。antd-mobile、react-dom、axios……名字都眼熟,分不出谁最大。
直到跑了一次 rollup-plugin-visualizer,Treemap 上最大的那一块标着 vendor-mammoth,将近 490KB。
协议预览页为了本地转 docx,动态 import('mammoth') 了一下。功能是低频的,体积却悄悄躺成了全项目第一名。
这篇不讲空泛的「性能方法论」,只讲三件事:
- visualizer 怎么配、怎么看
- 我项目里真实定位到的「大块头」
- 配完容易踩的坑
它到底是个啥?先用人话讲
rollup-plugin-visualizer 干的事很简单:
你
vite build一次,它给你吐一张 stats.html。
打开之后,每个依赖/模块是一块色块,色块越大 = 占的体积越大。
可以把它想成小区平面图:谁占的房间大,一眼就看出来。不用猜,不用一个个 import 试。
Vite 底层用 Rollup 打生产包,所以这个插件对 Vite 项目直接可用。
安装和配置
pnpm add -D rollup-plugin-visualizer
# 或 npm i -D rollup-plugin-visualizer
我项目里的写法(只在 build 时启用,本地 dev 不跑):
// vite.config.ts
import { defineConfig, type PluginOption } from 'vite'
import { visualizer } from 'rollup-plugin-visualizer'
export default defineConfig(({ command }) => ({
plugins: [
// ... react() 等其它插件
...(command === 'build'
? [
visualizer({
open: true, // 构建完自动打开报告
gzipSize: true, // 显示 gzip 后大小(必开)
brotliSize: true, // 显示 brotli 后大小
filename: 'stats.html', // 报告落在项目根目录
}) as PluginOption,
]
: []),
],
}))
跑一次:
pnpm build
# 或 vite build
浏览器会弹出 stats.html。没有自动打开就自己双击项目根目录下的这个文件。
两个关键点:
- 把
visualizer放在plugins靠后的位置(最好接近数组末尾)。放太前,其它插件还没处理完,报告容易偏。 gzipSize: true一定要开。 原始 490KB,gzip 后可能只有 120KB 出头——用户真正下载的更接近 gzip/brotli。只看原始体积会吓一跳,也会误判优化收益。
不想每次正式构建都生成报告?用环境变量开关更稳(见文末踩坑):
ANALYZE=true vite build
四种视图,日常只盯 Treemap
报告页顶部可以切视图:
| 视图 | 长什么样 | 什么时候用 |
|---|---|---|
| Treemap(默认) | 矩形拼图,面积 = 体积 | 日常找「大块头」,90% 场景够用 |
| Sunburst | 圆环一层层钻 | 想看嵌套关系 |
| Network | 点和线 | 想追「这个包是被谁引进来的」 |
| List | 列表 / 可导出 | 做 CI 对比 |
新手别一上来切 Network,线多容易晕。先看 Treemap:找最大的几块 → 点进去看包名 → 再决定动不动刀。
打开报告后,顶部还有 Rendered / Gzip / Brotli 三个单选:
- 默认 Rendered:构建后、压缩前的体积(色块对比最直观,适合找元凶)
- 切到 Gzip / Brotli:更接近用户实际下载(适合跟同事同步「到底省了多少」)
我排查时先用 Rendered 锁定最大色块,再用 Gzip 确认它是不是「吓人大、压缩后也大」。
这是我项目里真实的 Treemap(右下粉色大块就是 mammoth,旁边紫色是它拖进来的 bluebird):
看图时注意两件事:
- 面积最大的不一定在正中间——我这张里巨无霸在右下角,不盯一眼容易漏。
- 鼠标悬停会弹出单文件详情(Rendered / Gzip / Brotli)悬停在「元凶」色块上。
真实案例:元凶不是 UI 库,是 mammoth
项目背景: H5,React + Vite,UI 用 antd-mobile,日期用 dayjs(没有 moment)。业务里有一个「协议 / 文档预览」页,本地 .docx 要转成 HTML 展示。
1. 打开 Treemap,最大色块在右下角:mammoth
生产产物里,最大的几个 JS 大致是这样(本机 dist/prod,未 gzip):
| 文件(示意) | 原始体积 |
|---|---|
vendor-mammoth-*.js | ~490 KB |
vendor-antd-mobile-deps-*.js | ~188 KB |
vendor-react-dom-*.js | ~127 KB |
vendor-antd-mobile-*.js | ~103 KB |
vendor-dayjs-*.js | ~7 KB |
图上还能看到:mammoth 旁边紧挨着 bluebird、jszip、underscore 一类依赖——不是业务直接装的,是 mammoth 自己拖进来的。所以 Treemap 里「一大坨粉色 + 紫色」,本质上是同一条 docx 预览链路。
mammoth 单独 gzip 后大约 127 KB,仍然不小。antd-mobile(中间褐色那些 Picker / Tabs / Button)全家桶加起来也不小,但我原本以为体积问题全怪 UI 库——图一出来,结论就反了。
2. 代码里其实已经动态 import 了
预览页大概是这样写的:
// 协议预览:本地 docx → HTML
const mammoth = await import('mammoth')
const result = await mammoth.convertToHtml({ arrayBuffer })
路由也是 React.lazy 懒加载的。所以:
- 首屏不会一进来就下载 mammoth(这点是对的)
- 但 构建产物里它仍然在,Treemap 照样会把它画成最大色块
- 用户一旦点开协议预览,还是要扛这近 500KB
「拆包 / 懒加载」解决的是何时下载,不是「这个库变小了」。两件事别混。
3. 我后来怎么处理
结合业务,落地了这几步:
- 确认懒加载生效:mammoth 只在 docx 分支里
import(),不要在顶层import mammoth from 'mammoth'。 - Vite
manualChunks单独拆vendor-mammoth:避免它被吸进某个业务分包,缓存和排查都更清晰(我项目vite.config.ts里已经这么干了)。 - 业务侧减负:能走线上 HTML / iframe 的协议,就不要塞本地 docx;本地 docx 只留给必须离线预览的少数场景。
优化完之后,体感是:
- 首屏路径更干净,协议页第一次打开仍要等 mammoth,但不会污染其它路由
- 体积报告里「谁最大」变得可解释,后面加依赖也有对照基线
精确到「首屏从 x 秒到 y 秒」取决于网络和页面,这里不强行编造;**「最大色块从瞎猜变成可指认」**这件事本身,已经值回排查那一下午。
如果你的图里不是 mammoth:三个更常见的坑
不同项目元凶不一样。Treemap 里如果冒出下面这些,可以直接按套路砍:
套路 A:moment 全家桶
只用了 format,却把 locale 全打进来,轻松 200KB+。换 dayjs,API 几乎一样:
// ❌
import moment from 'moment'
moment().format('YYYY-MM-DD')
// ✅ 我项目里就是这条路,vendor-dayjs 大约 7KB
import dayjs from 'dayjs'
dayjs().format('YYYY-MM-DD')
套路 B:同一个库打了两份
Treemap 里出现两个 lodash,版本还不同——多半是项目依赖一份、第三方又带一份,没被 hoisting 掉。
pnpm why lodash
# 或 npm ls lodash
版本差不大时,可用 pnpm.overrides / npm overrides 统一版本。
套路 C:lodash 全量引入
// ❌ 整库进来
import _ from 'lodash'
_.debounce(fn, 300)
// ✅ 按需
import debounce from 'lodash/debounce'
debounce(fn, 300)
我这边没有直接依赖 lodash,只有 antd-mobile → ahooks 带进来的一点点,单独拆成 vendor-lodash 后大约 3~4KB——说明「有 lodash」不等于「一定很大」,仍以 Treemap 为准。
踩坑记录
坑 1:visualizer is not a function
// ❌
import visualizer from 'rollup-plugin-visualizer'
// ✅
import { visualizer } from 'rollup-plugin-visualizer'
命名导出,不是默认导出。
坑 2:CI 上 ERR_MODULE_NOT_FOUND
插件在 devDependencies。有的 CI 生产安装会跳过 devDependency,结果 vite.config 一 import 就炸。
解法:分析时才加载。
export default defineConfig({
plugins: [
// ...
process.env.ANALYZE === 'true' &&
visualizer({ open: true, gzipSize: true }) as PluginOption,
].filter(Boolean),
})
# 本地要看图
ANALYZE=true vite build
# CI 正常发版
vite build
坑 3:Node 版本和插件大版本不对齐
偶发 require() of ES Module ... not supported。插件较新版本对 Node 更挑剔。升级 Node,或暂时锁插件大版本,二选一。
坑 4:以为「懒加载了 = Treemap 里不该出现」
这是我自己绊过的认知坑。
mammoth 明明是 await import(),怎么报告里还那么大?因为 visualizer 统计的是构建产物里有什么,不是「首屏会不会下载」。懒加载只影响加载时机,不删除模块。
所以看图时要问两个问题:
- 它大不大?(面积)
- 它会不会进首屏?(路由 / 动态 import / 是否误写到公共入口)
只回答其中一个,优化会走偏。
坑 5:exclude 不生效
Vite 改写路径后,exclude 规则可能匹配不上。优先用 include 收窄,或升级插件后再试。
进阶:别优化一次就停
今天砍完,下周加个 SDK 可能又肥回去。
可以让 visualizer 输出列表,方便 CI 对比:
visualizer({
filename: 'stats.yaml',
template: 'list',
gzipSize: true,
})
体积相对上周暴涨就报警。人会忘,流水线不会。
总结
- 别猜,看图。 Treemap 面积就是体积。
- 看 gzip。 原始体积负责吓人,gzip/brotli 更接近用户下载。
- 懒加载 ≠ 变小。 它解决的是「何时下载」;要变小,得换库、按需、删场景。
- 用环境变量控制插件。 分析时开,发版时关。
一句话:打包体积的元凶,往往不是你以为的那个。