Vite bundle 分析:找出"为什么我的 JS 包是 1.2 MB"

起因

一个 React app build 完 main.js 1.2 MB(gzipped 320 KB),首屏卡顿。
"哪些包是大头?删得掉吗?"——光看 dist/ 看不出。

rollup-plugin-visualizer 给 vite build 加可视化报告:
treemap 看每个 dependency 的大小占比,10 分钟能砍 30-60%。

解决方案

npm i -D rollup-plugin-visualizer

vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { visualizer } from 'rollup-plugin-visualizer'

export default defineConfig({
  plugins: [
    react(),
    visualizer({
      open: true,                       // build 后自动开浏览器
      gzipSize: true,
      brotliSize: true,
      filename: 'dist/stats.html',
    }),
  ],
})
npm run build
# 浏览器自动开 dist/stats.html

显示一张 treemap:每个矩形大小 = 该 module 占 bundle 的比例。
hover 看具体 KB(raw / gzip / brotli)。

常见"巨包" + 处理

A. moment.js 占 200 KB+

node_modules/moment/  → 200 KB(含全部 locale)

修复:换成 day.js(2 KB,API 兼容)或 date-fns(tree-shakeable)。

- import moment from 'moment'
+ import dayjs from 'dayjs'

- moment().format('YYYY-MM-DD')
+ dayjs().format('YYYY-MM-DD')

如果坚持用 moment,至少剔除 locale:

// vite.config.ts
build: {
  rollupOptions: {
    plugins: [
      // 忽略 moment locale
      {
        name: 'remove-moment-locale',
        resolveId(id) {
          if (id.includes('moment/locale')) return false
        },
      },
    ],
  },
}

省 150 KB+。

B. lodash 全 import 占 80 KB

import _ from 'lodash'              // ❌ 整个 lodash
_.debounce(...)

import debounce from 'lodash/debounce'   // ✅ 只 import 用到的
debounce(...)

或者用 lodash-es + ESM tree-shaking:

- "lodash": "^4.17.21"
+ "lodash-es": "^4.17.21"
import { debounce, throttle } from 'lodash-es'
// Vite tree-shake 后只 bundle debounce + throttle

C. icon 库一次进 1000+ icon

import * as Icons from 'react-icons/fa'    // ❌ 全部

import { FaUser, FaBell } from 'react-icons/fa'  // ✅ 只用到的

或换成 lucide-react(更轻 + tree-shakeable):

import { User, Bell } from 'lucide-react'

D. chart 库(chart.js / echarts)整套 import

import * as echarts from 'echarts'   // ❌ 1MB+

→ 按需 import:

import * as echarts from 'echarts/core'
import { BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'

echarts.use([BarChart, GridComponent, TooltipComponent, CanvasRenderer])

只 bundle 用到的 chart 类型 + components。1MB → 200 KB。

E. 重复包

stats.html 里看到 react 出现两次(不同版本)→ 包冲突。

npm dedup
# 或:
npm ls react      # 看依赖树哪几条用了不同 react 版本

resolutions / overrides 强制单版本:

{
  "overrides": {
    "react": "18.3.1",
    "react-dom": "18.3.1"
  }
}

F. 大 polyfill

core-js  → 150 KB

target 现代浏览器后大多不需要:

// vite.config.ts
build: {
  target: 'es2020',    // 现代浏览器
}

砍掉一堆 polyfill。

代码分割:route-level lazy

import { lazy, Suspense } from 'react'

const Dashboard = lazy(() => import('./pages/Dashboard'))
const Settings = lazy(() => import('./pages/Settings'))

<Routes>
  <Route path="/" element={<Home />} />
  <Route path="/dashboard" element={
    <Suspense fallback={<Spinner />}>
      <Dashboard />
    </Suspense>
  } />
</Routes>

每个路由独立 chunk,访问到才下载。
首屏 bundle 只含 Home + 公共代码。

npm run build
# 看到 dist/assets/Dashboard-abc123.js + dist/assets/Settings-def456.js

第三方分割:manualChunks

把 React 等"很少变" 的 dep 抽独立 chunk,业务代码改不影响 cache:

build: {
  rollupOptions: {
    output: {
      manualChunks: {
        react: ['react', 'react-dom', 'react-router-dom'],
        ui: ['@radix-ui/react-dialog', '@radix-ui/react-dropdown-menu'],
        charts: ['echarts'],
      },
    },
  },
}

业务代码更新 → 用户重下 main.js(小)+ 命中 react.js / ui.js cache。

动态 import 大 dep

async function exportPDF() {
  const { jsPDF } = await import('jspdf')   // 用到才下载
  const doc = new jsPDF()
  // ...
}

PDF 导出按钮 click 才 fetch jspdf chunk。

监控 bundle size on PR

# .github/workflows/size.yml
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
- run: npm ci
- run: npm run build
- uses: andresz1/size-limit-action@v1
  with:
    github_token: ${{ secrets.GITHUB_TOKEN }}

PR 显示 "bundle 增加 +12 KB",超 budget 就 fail。
防止某天某人引一个巨包没注意。

.size-limit.json

[
  {
    "name": "main bundle",
    "path": "dist/assets/index-*.js",
    "limit": "200 KB"
  }
]

实测:从 1.2 MB → 380 KB

我们一个真实 React app:

阶段 bundle (raw) gzip
初始 1.2 MB 320 KB
删 moment → dayjs 950 KB 260 KB
lodash 按需 import 850 KB 235 KB
icon 改 lucide 730 KB 200 KB
echarts 按需 480 KB 145 KB
route lazy split (main) 380 KB 115 KB
总改动 -68% -64%

LCP 从 4.5s → 1.8s。改动总共半天。

其它分析工具

webpack-bundle-analyzer 风格

source-map-explorer 也支持 Vite:

npm i -D source-map-explorer
npm run build
npx source-map-explorer dist/assets/index-*.js

需要 source map 开(vite 默认开发开 / production 关;
build sourcemap: true)。

bundlephobia

写代码前查:"如果我加这个包,bundle 变多大":

https://bundlephobia.com/package/moment
# Bundle size: 290.7 KB
# Minified + Gzipped: 71.2 KB

知道代价再决定要不要装。

Vite Bundle Visualizer 升级

Vite 5 + --report 选项:

vite build --report

内置 visualizer。无需额外 plugin。

CDN 拆解

如果 React 等大库走 CDN:

<script src="https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js"></script>
build: {
  rollupOptions: {
    external: ['react', 'react-dom'],
    output: {
      globals: { react: 'React', 'react-dom': 'ReactDOM' },
    },
  },
}

bundle 不含 React → 共享 CDN cache 跨站。但 CDN 多次 DNS / 信任问题,
现代项目少用。

踩过的坑

  1. chunk 太碎:每页一个 chunk + 每包一个 chunk → 总文件数几百。
    HTTP/1.1 client 多次连接慢。HTTP/2 没事;HTTP/1 视乎服务端。

  2. manual chunks 改后 hash 变 → cache 失效。manualChunks 改一次
    全 dependency cache 一次性失效,所有用户重下。慎重。

  3. dynamic import 路径动态化 → vite 静态分析不到 → 不切 chunk:
    ts const m = await import(`./pages/${name}`) // ❌ vite 不知道哪些 page
    要给 vite 提示:
    ts const m = await import(`./pages/${name}.tsx`) // 静态可分析 // 或:vite-ignore 注释 + 自己管

  4. bundle 在 dev 看不出问题:dev 模式 vite 不打包,每个 module
    独立 HTTP。production 才看真实大小。永远以 production build 衡量。

  5. react 包很小但 hydrate 慢:bundle size 不是 LCP 唯一决定因素。
    React 启动 / hydrate 也耗时。RSC + 减少 client component 是补充
    优化方向。

精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。