代码分割实战技巧提升前端性能

令狐美丽 优化 阅读 1,558
赞 18 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

项目上线半年,用户反馈越来越多:首页加载慢、点击按钮半天没反应、手机上直接卡死。我自己用低端安卓机试了下,点个菜单要等两秒才弹出来,瀑布流页面一滚动就掉帧,简直没法用。首屏时间测了下,平均 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 可能下一步会试试,但现在这套方案已经够用了。

以上是我个人对这个代码分割优化的完整实践分享,过程中踩了不少坑,也验证了一些网上说的“最佳实践”其实并不适合我们场景。有更优的实现方式欢迎评论区交流,一起进步。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论