前端按需加载实战指南从代码分割到动态导入的完整解决方案
优化前:卡得不行
上个月上线了一个移动端活动页,首页带轮播图、商品瀑布流、底部弹层表单,还塞了三个第三方 SDK(埋点、客服、广告)。测的时候用自己 iPhone 13 还行,结果一发灰度,客服就炸了:安卓低端机用户反馈“点不动”“划两下就白屏”“等五秒才出来商品”。我拿红米 Note 9 真机连 Chrome DevTools 远程调试,首屏加载完直接卡住 3 秒多,Network 面板里 JS 脚本加起来 2.4MB(gzip 后 860KB),光 app.js 就占了 1.7MB。不是懒加载没做,是根本没拆——整个项目用一个 Webpack entry,import 全写在 index.js 里,连轮播组件都跟支付逻辑打包在一起。
找到病根了!
先跑 Lighthouse,分数 32,Performance 项直接挂红:“Reduce JavaScript execution time” 和 “Eliminate render-blocking resources” 警告叠成山。接着开 Performance 面板录了一次冷启,发现:TTFB 320ms(还行),但 Script Evaluation 占了 2100ms,其中 1600ms 花在解析和执行一堆压根没用上的模块上——比如「订单导出」按钮的 Excel 导出逻辑,在首页根本不会触发;还有「客服聊天窗口」的 WebSocket 初始化,一进来就自动连,但 95% 的用户根本不会点它。
再切到 Coverage 面板,勾选 “Show unused code (by coverage)”,刷新页面……好家伙,node_modules/echarts 用了不到 5%,src/utils/pdf-generator.js 完全灰色,src/components/OrderExportModal.vue 整个文件 0% 执行。这哪是加载,这是把整本新华字典塞进手机壳里再开机。
试了几种方案,最后这个效果最好
一开始想搞 Webpack Magic Comments + dynamic import,结果发现我们项目是 Vue CLI 4.5,Webpack 4,不支持 /* webpackPrefetch: true */ 这种新语法,改配置又怕影响其他业务线,拖不起。后来翻 Vue 官方文档,发现 defineAsyncComponent 在 Vue 2.6+ 里其实已经能很好配合 import() 做组件级按需加载,而且不用动构建配置——这才是我要的“改三行代码就能上线”的方案。
核心思路就一条:把非首屏、低频、高体积的模块,全部从主包里踢出去,用 import() 懒加载,配合 Suspense 或 loading state 控制体验。
先干最重的:echarts。首页只用柱状图,但装了完整版,1.2MB。改成:
// 优化前
import * as echarts from 'echarts'
// 优化后
const loadEcharts = () => import('echarts/lib/echarts')
const loadBarChart = () => import('echarts/lib/chart/bar')
const loadTitle = () => import('echarts/lib/component/title')
然后在组件 setup 里动态注册:
export default {
name: 'SalesChart',
async mounted() {
const [echarts, bar, title] = await Promise.all([
loadEcharts(),
loadBarChart(),
loadTitle()
])
this.chart = echarts.init(this.$refs.chart)
this.chart.setOption({ /* ... */ })
}
}
再处理弹层类组件。原来 CustomerServiceModal.vue 是直接 import 后挂 v-if 显示,但它本身带 WebSocket 和 UI 库依赖,打包进去 420KB。改成异步组件:
// 优化前
import CustomerServiceModal from '@/components/CustomerServiceModal.vue'
// 优化后
const CustomerServiceModal = () => import('@/components/CustomerServiceModal.vue')
Vue Router 页面也动刀。原先是所有路由写死在 router/index.js 里:
// 优化前
{
path: '/order-export',
component: () => import('@/views/OrderExport.vue')
}
这不够——/order-export 页面本身还引用了 xlsx(380KB)和 file-saver(22KB)。于是我把导出逻辑进一步拆成子模块:
// OrderExport.vue 内部
methods: {
async handleExport() {
const { exportToExcel } = await import('@/utils/export-excel.js')
exportToExcel(this.tableData)
}
}
注意这里没用 import() 加载整个 xlsx,而是封装一层工具函数,在真正点击导出时才拉取。实测这样比路由级懒加载更细粒度,首屏 JS 减少了 410KB。
踩坑提醒:这三点一定注意
- 不要在
created或mounted里写同步import()——会报错,必须用await或.then(); - 异步组件不能直接当普通组件用 props,比如
<CustomerServiceModal :visible="show" />会失效,得套一层 wrapper 或用v-if控制显示时机; - Webpack 默认不会给
import()加 chunk name,打包后全是123.js,查问题困难。加一句注释:/* webpackChunkName: "customer-service-modal" */,就能看到清晰文件名。
优化后:流畅多了
改完上线灰度,红米 Note 9 上实测:首屏可交互时间(TTI)从 5.2s 降到 820ms,JS 执行时间砍到 410ms,首屏 JS 包体积从 2.4MB → 980KB(gzip 后 340KB)。Lighthouse Performance 分数涨到 78。客服反馈“点哪里都快了”,有个用户还说“以为换新手机了”。当然不是完美——滚动过程中偶尔有 1~2 帧掉帧(主要是图片懒加载 + IntersectionObserver 触发密集),但这属于另一块优化范畴了,这次先放过它。
性能数据对比
| 指标 | 优化前(红米 Note 9) | 优化后(红米 Note 9) | 提升 |
|---|---|---|---|
| 首屏完全加载(FCP) | 3800ms | 1200ms | ↓ 68% |
| 可交互时间(TTI) | 5200ms | 820ms | ↓ 84% |
| JS 执行耗时 | 2100ms | 410ms | ↓ 80% |
| 首屏 JS 总体积(gzip) | 860KB | 340KB | ↓ 60% |
补充一句:这些数字是在弱网(3G,1Mbps)下跑出来的。如果切到 WiFi,TTI 能压到 400ms 左右,但用户真正在意的是“3G 下还能不能用”,所以重点盯弱网场景。
以上是我踩坑后的总结,希望对你有帮助
这个方案不是最优解——比如 Service Worker 缓存 + preload 关键资源会更稳,但团队没人力搞 PWA;比如用 Vite 替换 Webpack 能获得更快的 HMR 和更细的分包,但迁移成本太高,老板不让动基建。所以最后选了“改最少、见效最快、风险最低”的路:组件级 + 工具函数级懒加载,配合理性的 chunk 切分。上线一周零事故,灰度放量到 100% 后也没收到新卡顿反馈。
如果你也在用 Vue 2 + Webpack 4,或者被类似的大包折磨过,欢迎评论区交流。有更轻量的方案、更好的 chunk 命名策略、甚至怎么让懒加载失败时优雅 fallback,我都想听听。毕竟优化这事,没人真能一次到位——我昨天还在 console.log 里删掉一行忘了关的 debug 日志,它偷偷占了 12KB 包体积……
