手把手教你做Bundle分析实现精准体积优化
优化前:卡得不行
项目上线三个月,用户反馈越来越多——页面打开慢,首屏要等好几秒,尤其是一些低配手机上,点个按钮要一两秒才有反应。我自己拿旧款安卓机试了下,确实离谱,首页 loading 从5秒起步,bundle 大小直接飙到3.8MB(gzip后1.6MB),这哪是网页,这是加载游戏吧?
最离谱的是,我们其实没做多复杂的功能,就是个内容展示+表单提交的管理后台,结果打包完发现 lodash 单独占了400KB,moment.js 又塞进来280KB,还有各种组件库全量引入……整个项目像被灌了水的大海绵,虚胖得不行。
找到病根了!
我第一反应是上工具看看到底谁在“偷体积”。先跑了一把 webpack-bundle-analyzer,本地启了个 analyze 模式:
npm run build -- --report
然后浏览器弹出一个可视化 treemap,一眼就看到红色巨块:lodash、moment、echarts、antd 全堆在一起,其中 lodash 因为用了 _.debounce、_.cloneDeep 这种常用方法,但项目里是 import from ‘lodash’,导致全量打包。
另一个问题是动态图表组件用到了 echarts,但我们只用了折线图和柱状图,结果把所有图表类型都打进去,又多了600KB。
还有个隐藏坑:某些第三方库内部引用了 moment,而 moment 自带200多个语言包,默认全打进去。后来查了下,光是 zh-CN 以外的语言包就占了140KB,纯浪费。
开干:拆、剪、换
问题清楚了,接下来就是动手。我定了三个方向:按需引入、代码分割、替换重型依赖。
第一步:lodash 按需引入
原来代码是这么写的:
import _ from 'lodash'
_.debounce(handleScroll, 300)
_.cloneDeep(obj)
改成了按方法单独引入:
import debounce from 'lodash/debounce'
import cloneDeep from 'lodash/cloneDeep'
debounce(handleScroll, 300)
cloneDeep(obj)
或者更省事的,装个 babel 插件自动处理:
// .babelrc
{
"plugins": ["lodash"]
}
配合这个插件,你继续写 import _ from ‘lodash’,它会在编译时自动替换成模块化引入,实测节省了350KB左右。
第二步:moment 替换成 dayjs
moment 真的是性能杀手,不仅体积大,API 还不可变(每次操作生成新对象)。我们项目里只用到了格式化和时差计算,完全可以用 dayjs 替代。
改造很简单,API 基本兼容:
// 原来
import moment from 'moment'
moment().format('YYYY-MM-DD')
// 改成
import dayjs from 'dayjs'
dayjs().format('YYYY-MM-DD')
顺手把语言包也加上:
import 'dayjs/locale/zh-cn'
dayjs.locale('zh-cn')
体积从280KB干到了12KB,省了268KB,血赚。
第三步:组件库按需加载(以 antd 为例)
我们用的是 antd 4.x,之前是直接 import { Button, Modal } from ‘antd’,虽然用了 babel-plugin-import,但配置没开压缩。
检查了下 .babelrc:
{
"plugins": [
["import", {
"libraryName": "antd",
"libraryDirectory": "es",
"style": "css"
}]
]
}
确保 libraryDirectory 是 es 而不是 lib,这样能利用 tree-shaking。另外 style 如果用 less 可以进一步按需加载样式,但我们项目为了省事用了 css,也能接受。
这一步省了将近200KB。
第四步:路由级 code splitting
首页加载时就把所有路由组件全拉下来,明显不合理。我们用的是 React + React Router,改用 React.lazy 动态加载:
const Home = lazy(() => import('./pages/Home'))
const Report = lazy(() => import('./pages/Report'))
const Setting = lazy(() => import('./pages/Setting'))
// 路由渲染
<Suspense fallback={<Spinner />}>
<Route path="/report" component={Report} />
</Suspense>
配合 webpack 的 splitChunks 配置:
// webpack.config.js
optimization: {
splitChunks: {
chunks: 'all',
cacheGroups: {
vendor: {
test: /[\/]node_modules[\/]/,
name: 'vendors',
chunks: 'all',
}
}
}
}
这样一来,首页只加载核心逻辑,其他路由独立打包,首次加载体积从1.6MB降到780KB(gzip后)。
第五步:ECharts 按需引入
原先是:
import * as echarts from 'echarts'
改成了:
import * as echarts from 'echarts/core'
import { LineChart, BarChart } from 'echarts/charts'
import { GridComponent, TooltipComponent } from 'echarts/components'
import { CanvasRenderer } from 'echarts/renderers'
echarts.use([LineChart, BarChart, GridComponent, TooltipComponent, CanvasRenderer])
只引入需要的模块,体积从600KB降到90KB左右,效果立竿见影。
还有几个顺手优化
- 图片资源全部转成 WebP,通过 image-minimizer-webpack-plugin 压缩,平均再降80KB
- 加了 preload 关键 CSS,减少 FOUC
- 第三方脚本如统计代码异步加载,不阻塞主流程
- 接口请求统一走 /api/proxy 代理,避免 CORS 预检请求过多
优化后:流畅多了
改完重新 build,再跑一遍 bundle-analyzer,整个图变得清爽多了,没有红色巨块,全是绿色小方块。
线上灰度发布后,监控数据显示:
- 首屏加载时间从 5.2s 降到 1.1s(3G 网络模拟)
- FCP(First Contentful Paint)从 4.8s 降到 980ms
- LCP 从 5.5s 降到 1.3s
- bundle 总大小从 3.8MB(未压缩)降到 1.1MB,gzip 后 420KB
用户反馈明显变少,客服那边说最近没人投诉卡了。老板看了数据还夸了两句,虽然我知道他根本看不懂这些数字……
踩坑提醒:这三点一定注意
- babel-plugin-import 不生效? 检查是否和其他插件冲突,比如 preset-env 的 useBuiltIns: usage,曾经因为这个配置导致 import 插件失效,折腾了半天发现是加载顺序问题。
- React.lazy 和 Suspense 在生产环境白屏? 记得加 fallback,而且不要在顶层直接用 lazy,建议封装一层 Loadable 组件做错误边界处理。
- dayjs 替换 moment 后时区不对? 注意 dayjs 默认不带时区插件,如果要用,得显式引入:
import dayjs from 'dayjs'; import timezone from 'dayjs/plugin/timezone'; dayjs.extend(timezone)。
性能数据对比
以下是优化前后关键指标对比:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| JS Bundle Size (gzip) | 1.6 MB | 420 KB | 73.7% |
| 首屏加载时间 (3G) | 5.2s | 1.1s | 78.8% |
| FCP | 4.8s | 980ms | 79.6% |
| 请求数量 | 38 | 21 | 44.7% |
最后说两句
这次 bundle 分析优化,说实话不是什么高深技术,都是些老生常谈的手段:按需引入、code splitting、替换重型依赖。但真正落地时,每个环节都有坑,尤其是团队协作项目,有人随手 import _ from ‘lodash’,几天就回退到解放前。
所以我现在在 CI 流程里加了 bundle 体积告警,超过阈值直接 fail,倒逼大家注意。同时也写了份简单的《前端性能自查清单》,新人入职就得过一遍。
以上是我踩坑后的总结,希望对你有帮助。有更好的方案欢迎评论区交流,比如你们是怎么处理 ECharts 或者地图 SDK 的?我也还在摸索。

暂无评论