代码分割实战技巧提升前端性能
优化前:卡得不行
项目上线半年,用户反馈越来越多:首页加载慢、点击按钮半天没反应、手机上直接卡死。我自己用低端安卓机试了下,点个菜单要等两秒才弹出来,瀑布流页面一滚动就掉帧,简直没法用。首屏时间测了下,平均 5.2 秒,最大内容绘制(LCP)超过 6 秒,这哪是现代 Web 应用,简直是 2003 年的静态站。
打包构建完的 JS 文件总大小接近 4.8MB,其中 main.js 一个文件就占了 3.7MB。啥概念?整个 Vue 框架本体才不到 100KB,我们却打了个三倍多的包出去。用户打开首页时,浏览器得先把这坨玩意全下载完、解析完、执行完,页面才能动——不卡才怪。
找到瘼颈了!
我先用 Chrome DevTools 的 Performance 面板录了一段页面加载过程,发现主线程长时间被 script evaluation 占满,而且集中在页面刚打开那几秒。Network 面板里也看得清清楚楚,几个 MB 级的 JS 文件排着队加载,最后一个才结束,前面的资源再小也得等着。
接着看了下 webpack-bundle-analyzer 的报告,好家伙,所有路由组件、视频播放器、PDF 预览、富文本编辑器全被打进了一个 bundle。有个管理后台的模块根本不需要在首页加载,结果它依赖的 antd 和 moment.js 全塞进来了。这时候我就知道问题在哪了:没有做代码分割(Code Splitting),所有代码一股脑全塞进一个文件。
其实之前也想过拆,但一直拖着,因为怕改出问题。这次是真扛不住了,用户体验已经到临界点了,必须动刀。
动手拆分:按路由 + 按需加载
我决定先从最明显的入手:路由级代码分割。我们用的是 Vue Router + webpack,实现起来其实很简单,就是把原来的同步 import 改成动态 import,让 webpack 自动做 code splitting。
优化前的写法:
import Home from './views/Home.vue'
import About from './views/About.vue'
import Admin from './views/Admin.vue'
const routes = [
{ path: '/', component: Home },
{ path: '/about', component: About },
{ path: '/admin', component: Admin }
]
这样写,webpack 会把这三个组件全都打进主包。
改成动态导入后:
const routes = [
{ path: '/', component: () => import('./views/Home.vue') },
{ path: '/about', component: () => import('./views/About.vue') },
{ path: '/admin', component: () => import('./views/Admin.vue') }
]
就这么一行改动,webpack 就会为每个 import() 单独生成 chunk 文件,并在路由切换时动态加载。注意这里用了箭头函数包裹,这是 Vue Router 的标准写法,否则不会触发懒加载。
但这还不够,有些组件虽然不在路由里,但体积也不小,比如那个 PDF 预览组件。我也给它加上了动态引入:
// 在需要时才加载
async showPdf() {
const { renderPdf } = await import('./utils/pdf-renderer')
renderPdf(this.pdfUrl)
}
还有第三方库,像 lodash,之前是直接全量引入:
import _ from 'lodash'
改成按需引入:
import debounce from 'lodash/debounce'
import throttle from 'lodash/throttle'
或者更省事的,配了 babel-plugin-lodash,自动帮你做 tree-shaking,亲测有效,省了 120KB 左右。
踩坑提醒:异步组件 loading 状态别忘了
改完之后,页面确实快了,但新问题来了:跳转到 /admin 的时候,页面白屏几百毫秒,因为要等 chunk 下载。这时候你得加 loading 状态,不然用户以为卡了。
我用了 Vue 的异步组件语法配合 loading 组件:
const Admin = () => ({
component: import('./views/Admin.vue'),
loading: { template: '<div class="loading">加载中...</div>' },
delay: 200
})
delay 设置 200ms 是为了防止快速跳转时闪一下 loading,体验更顺滑。这个细节我踩过好几次坑,一开始没设 delay,loading 像抽风一样一闪而过,反而更烦人。
进阶操作:预加载关键 chunk
路由拆完后,首页加载时间降到了 2.1 秒左右,但还是不够理想。我又看了下 waterfall 图,发现虽然 main.js 小了,但浏览器并不会提前请求那些异步 chunk,直到真正需要时才发起请求。
于是上了预加载策略。对于首页可能用到的下一个页面(比如登录后的 dashboard),我在首页就让它预加载:
<link rel="prefetch" href="/chunks/dashboard.[hash].js">
或者用 webpack 的 magic comment 写法:
const Dashboard = () => import(/* webpackPrefetch: true */ './views/Dashboard.vue')
注意这里是 prefetch,不是 preload。preload 是高优先级,会影响首屏;prefetch 是空闲时加载,更适合“可能要用”的场景。我一开始用错了 preload,结果首页更慢了,折腾了半天才发现。
另外,API 请求也可以配合做预加载。比如用户进入首页后,大概率会点搜索,那我在页面空闲时就提前 fetch 一下搜索接口的 schema:
if ('requestIdleCallback' in window) {
requestIdleCallback(() => {
fetch('https://jztheme.com/api/search/schema')
})
}
优化后:流畅多了
改完这一套组合拳后,重新跑性能测试:
- 首屏 JS 下载从 3.7MB 降到 410KB
- 首屏时间从 5.2s 降到 800ms
- LCP 从 6.1s 降到 1.4s
- TBT(总阻塞时间)从 900ms 降到 120ms
手机端体验提升最明显,原来要等两三秒的操作,现在基本是秒响应。用户投诉少了,留存也回升了。
当然也不是完美无缺。有次发布后出现 chunk 加载 404,查了好久发现是 CDN 缓存没清干净。后来我们加了构建脚本自动带版本号清理缓存。这种问题线上才会暴露,开发环境根本测不出来。
性能数据对比
以下是优化前后关键指标对比:
| 指标 | 优化前 | 优化后 | 提升 |
|---|---|---|---|
| 首屏 JS 大小 | 3.7 MB | 410 KB | ↓ 89% |
| 首屏时间 | 5.2s | 800ms | ↓ 84.6% |
| LCP | 6.1s | 1.4s | ↓ 77% |
| TBT | 900ms | 120ms | ↓ 86.7% |
总结一下
这次优化核心就三点:路由级代码分割、第三方库按需引入、关键 chunk 预加载。最难的不是技术实现,而是说服团队接受“稍微复杂一点”的异步写法。不过数据摆在面前,谁也没话说。
顺便提一嘴,webpack 5 的 module federation 虽然强大,但我们项目结构还没到微前端的程度,暂时没用。倒是 lazy-loaded web component 可能下一步会试试,但现在这套方案已经够用了。
以上是我个人对这个代码分割优化的完整实践分享,过程中踩了不少坑,也验证了一些网上说的“最佳实践”其实并不适合我们场景。有更优的实现方式欢迎评论区交流,一起进步。

暂无评论