起因
一个 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 / 信任问题,
现代项目少用。
踩过的坑
-
chunk 太碎:每页一个 chunk + 每包一个 chunk → 总文件数几百。
HTTP/1.1 client 多次连接慢。HTTP/2 没事;HTTP/1 视乎服务端。 -
manual chunks 改后 hash 变 → cache 失效。
manualChunks改一次
全 dependency cache 一次性失效,所有用户重下。慎重。 -
dynamic import 路径动态化 → vite 静态分析不到 → 不切 chunk:
ts const m = await import(`./pages/${name}`) // ❌ vite 不知道哪些 page
要给 vite 提示:
ts const m = await import(`./pages/${name}.tsx`) // 静态可分析 // 或:vite-ignore 注释 + 自己管 -
bundle 在 dev 看不出问题:dev 模式 vite 不打包,每个 module
独立 HTTP。production 才看真实大小。永远以 production build 衡量。 -
react 包很小但 hydrate 慢:bundle size 不是 LCP 唯一决定因素。
React 启动 / hydrate 也耗时。RSC + 减少 client component 是补充
优化方向。
登录后参与评论。