我排查了一下午,发现项目打包体积翻倍的元凶是它

摘要: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') 了一下。功能是低频的,体积却悄悄躺成了全项目第一名。

这篇不讲空泛的「性能方法论」,只讲三件事:

  1. visualizer 怎么配、怎么看
  2. 我项目里真实定位到的「大块头」
  3. 配完容易踩的坑

它到底是个啥?先用人话讲

rollup-plugin-visualizer 干的事很简单:

你 vite build 一次,它给你吐一张 stats.html 。

打开之后,每个依赖/模块是一块色块,色块越大 = 占的体积越大。

可以把它想成小区平面图:谁占的房间大,一眼就看出来。不用猜,不用一个个 import 试。

Vite 底层用 Rollup 打生产包,所以这个插件对 Vite 项目直接可用。


安装和配置

bash 复制代码
pnpm add -D rollup-plugin-visualizer
# 或 npm i -D rollup-plugin-visualizer

我项目里的写法(只在 build 时启用,本地 dev 不跑):

ts 复制代码
// 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,
        ]
      : []),
  ],
}))

跑一次:

bash 复制代码
pnpm build
# 或 vite build

浏览器会弹出 stats.html。没有自动打开就自己双击项目根目录下的这个文件。

两个关键点:

  1. 把 visualizer 放在 plugins 靠后的位置(最好接近数组末尾)。放太前,其它插件还没处理完,报告容易偏。
  2. gzipSize: true 一定要开。 原始 490KB,gzip 后可能只有 120KB 出头------用户真正下载的更接近 gzip/brotli。只看原始体积会吓一跳,也会误判优化收益。

不想每次正式构建都生成报告?用环境变量开关更稳(见文末踩坑):

bash 复制代码
ANALYZE=true vite build

四种视图,日常只盯 Treemap

报告页顶部可以切视图:

视图 长什么样 什么时候用
Treemap(默认) 矩形拼图,面积 = 体积 日常找「大块头」,90% 场景够用
Sunburst 圆环一层层钻 想看嵌套关系
Network 点和线 想追「这个包是被谁引进来的」
List 列表 / 可导出 做 CI 对比

新手别一上来切 Network,线多容易晕。先看 Treemap:找最大的几块 → 点进去看包名 → 再决定动不动刀。

打开报告后,顶部还有 Rendered / Gzip / Brotli 三个单选:

  • 默认 Rendered:构建后、压缩前的体积(色块对比最直观,适合找元凶)
  • 切到 Gzip / Brotli:更接近用户实际下载(适合跟同事同步「到底省了多少」)

我排查时先用 Rendered 锁定最大色块,再用 Gzip 确认它是不是「吓人大、压缩后也大」。

这是我项目里真实的 Treemap(右下粉色大块就是 mammoth,旁边紫色是它拖进来的 bluebird):

看图时注意两件事:

  1. 面积最大的不一定在正中间------我这张里巨无霸在右下角,不盯一眼容易漏。
  2. 鼠标悬停会弹出单文件详情(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 了

预览页大概是这样写的:

ts 复制代码
// 协议预览:本地 docx → HTML
const mammoth = await import('mammoth')
const result = await mammoth.convertToHtml({ arrayBuffer })

路由也是 React.lazy 懒加载的。所以:

  • 首屏不会一进来就下载 mammoth(这点是对的)
  • 但 构建产物里它仍然在,Treemap 照样会把它画成最大色块
  • 用户一旦点开协议预览,还是要扛这近 500KB

「拆包 / 懒加载」解决的是何时下载,不是「这个库变小了」。两件事别混。

3. 我后来怎么处理

结合业务,落地了这几步:

  1. 确认懒加载生效 :mammoth 只在 docx 分支里 import(),不要在顶层 import mammoth from 'mammoth'。
  2. Vite manualChunks 单独拆 vendor-mammoth :避免它被吸进某个业务分包,缓存和排查都更清晰(我项目 vite.config.ts 里已经这么干了)。
  3. 业务侧减负:能走线上 HTML / iframe 的协议,就不要塞本地 docx;本地 docx 只留给必须离线预览的少数场景。

优化完之后,体感是:

  • 首屏路径更干净,协议页第一次打开仍要等 mammoth,但不会污染其它路由
  • 体积报告里「谁最大」变得可解释,后面加依赖也有对照基线

精确到「首屏从 x 秒到 y 秒」取决于网络和页面,这里不强行编造;**「最大色块从瞎猜变成可指认」**这件事本身,已经值回排查那一下午。


如果你的图里不是 mammoth:三个更常见的坑

不同项目元凶不一样。Treemap 里如果冒出下面这些,可以直接按套路砍:

套路 A:moment 全家桶

只用了 format,却把 locale 全打进来,轻松 200KB+。换 dayjs,API 几乎一样:

js 复制代码
// ❌
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 掉。

bash 复制代码
pnpm why lodash
# 或 npm ls lodash

版本差不大时,可用 pnpm.overrides / npm overrides 统一版本。

套路 C:lodash 全量引入

js 复制代码
// ❌ 整库进来
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

js 复制代码
// ❌
import visualizer from 'rollup-plugin-visualizer'

// ✅
import { visualizer } from 'rollup-plugin-visualizer'

命名导出,不是默认导出。

坑 2:CI 上 ERR_MODULE_NOT_FOUND

插件在 devDependencies。有的 CI 生产安装会跳过 devDependency,结果 vite.config 一 import 就炸。

解法:分析时才加载。

ts 复制代码
export default defineConfig({
  plugins: [
    // ...
    process.env.ANALYZE === 'true' &&
      visualizer({ open: true, gzipSize: true }) as PluginOption,
  ].filter(Boolean),
})
bash 复制代码
# 本地要看图
ANALYZE=true vite build

# CI 正常发版
vite build

坑 3:Node 版本和插件大版本不对齐

偶发 require() of ES Module ... not supported。插件较新版本对 Node 更挑剔。升级 Node,或暂时锁插件大版本,二选一。

坑 4:以为「懒加载了 = Treemap 里不该出现」

这是我自己绊过的认知坑。

mammoth 明明是 await import(),怎么报告里还那么大?因为 visualizer 统计的是构建产物里有什么,不是「首屏会不会下载」。懒加载只影响加载时机,不删除模块。

所以看图时要问两个问题:

  1. 它大不大?(面积)
  2. 它会不会进首屏?(路由 / 动态 import / 是否误写到公共入口)

只回答其中一个,优化会走偏。

坑 5:exclude 不生效

Vite 改写路径后,exclude 规则可能匹配不上。优先用 include 收窄,或升级插件后再试。


进阶:别优化一次就停

今天砍完,下周加个 SDK 可能又肥回去。

可以让 visualizer 输出列表,方便 CI 对比:

ts 复制代码
visualizer({
  filename: 'stats.yaml',
  template: 'list',
  gzipSize: true,
})

体积相对上周暴涨就报警。人会忘,流水线不会。


总结

  1. 别猜,看图。 Treemap 面积就是体积。
  2. 看 gzip。 原始体积负责吓人,gzip/brotli 更接近用户下载。
  3. 懒加载 ≠ 变小。 它解决的是「何时下载」;要变小,得换库、按需、删场景。
  4. 用环境变量控制插件。 分析时开,发版时关。

一句话:打包体积的元凶,往往不是你以为的那个。


参考

相关推荐
u0111026752 小时前
Vue 3 实现图片裁剪框:拖动、缩放与固定宽高比
前端·javascript·vue.js
汉堡大王95273 小时前
一张图三句需求,我用 Trae Work 做了一块能看日出日落和月相的天文机械表
前端·后端·github
小小善后师3 小时前
用 Canvas + AI 实现登录页的 Logo 粒子动画
前端·vue.js
Amos_Web3 小时前
Rspack 源码解析(十六):多类型资源如何进入 Compilation.assets
前端·rust·前端框架
GAMC3 小时前
chrome-devtools-mcp:让 AI 编码助手真正"看见"浏览器
前端·人工智能
deli0073 小时前
高尔顿板:把球一颗颗丢下去,为什么最后总堆成一座钟形山
前端
java_nnnn3 小时前
JavaEE进阶-CSS初识
java·前端·css·java-ee·html
good_ideal3 小时前
从零实现一个自动提交 PR 的 MCP 工具:用 Skill 串起 Git 与 Azure DevOps
前端
喜欢吃豆3 小时前
Agent 前端协议正在分层:彻底讲清 MCP Apps、AG-UI 与 A2UI
前端·大模型