1、背景
供应链管理后台以下简称 SCM
随着业务不断迭代,SCM 的页面也变得越来越多,置身于持续增加、优化系统功能层面的需求,渐渐忽视了使用者对系统体验层面的诉求。
近期通过线上反馈渠道收集到的问题中,有不少都是关于页面打开速度比较慢,为了能够提升系统使用体验和效率,我们对 SCM 的打开速度做了些针对性的迭代优化。
2、现状
目前 SCM 使用 Vue 2 全家桶,基于 vue-cli-service 开发、构建,菜单数量繁多,通过业务域拆分为若干个子应用(React 技术栈)的迁移工作也在有序进行中。
通过效率数据看板可以查看 SCM 的秒开率统计数据(关于秒开指标 FMP 的计算方式可以参考首屏统计的前世今生)。通过下图可以看见,优化前的秒开率基本都在 20% 以下,而且数据会跟着发版频次有所波动。
3、思路
提起前端性能优化,大家脑中或多或少的都会冒出一些想法,随手一搜,也能看到各种最佳实践之类的万字长文。为了避免出现工作做了很多,却没对性能提升有显著效果的情况,在优化工作开始之前,首先是要对系统做诊断,并确定优化要达到的关键结果及衡量指标。这里我们只需要用到两个工具来辅助查优化工作,通过不断优化,不断验证以达到想要的效果。
使用 Chrome DevTools 的 Performance 选项卡找出页面性能瓶颈。如下图所示,通过 Network 区域显示的静态资源/接口请求的瀑布流,及 Main 区域主线程运行过程中每个 Task 的执行明细,能够很方便的找出影响页面性能的因素。关于使用如果使用 Performance 可参考官方教程 Analyze runtime performance。
使用 webpack-bundle-analyzer 查看编译打包后的文件,验证打包策略是否合理、是否存在冗余模块等。使用 vue-cli-service 的项目可在打包命令后添加 –report 开启;umi 项目中可在打包命令前添加 ANALYZE=1 开启;其它 webpack 项目可安装 webpack-bundle-analyzer 依赖包按需使用。
4、优化之路
首先要解决的如何在最短时间内获取到页面所需的最小资源。
4.1 静态资源
- 控制 html 文件大小
由于 TCP slow start 算法的限制,应尽可能把 html 文件大小控制在 14kB 以内,使得 html 内容能在一个 TCP packet 中发送到浏览器(可参考why-your-website-should-be-under-14kb-in-size)
优化前的打包策略是把 runtime chunk 提取到了 html 文件中,估计是之前为了减少请求个数,但因为接了 Assets后,已全站静态资源 CDN 化后可基本忽略该方面的影响。故剔除 html 文件中的 runtime chunk 内容后,文件体积由 18kB 降至 1kB 以内。
- 公共包体积优化
通过打包分析工具显示,@du/earth 这个包占用了公共包近 40% 的体积大小。
熟悉 Vue 的同学都知道,常见的 Vue 的组件注册方式有两种:
- Vue.component(id, [definition]),全局注册
- {components: { ‘component-a’: ComponentA }},局部注册
第一种常用于项目依赖的基础组件库,如element-ui,由于组件库本身会提供 install 方法,可以使用 Vue.use(ElementUI) 很方便的把全部组件注册到全局。如果没做组件按需打包的话,这种引入方式会把 element-ui 整包打入。
import Vue from 'vue'
import ElementUI from 'element-ui'
Vue.use(ElementUI)
但在代码分析的过程中,发现@du/earth(可理解为基于 element-ui 的高阶组件)也是采用这种全局注册的方式,在一番查找比对后(大大的体力活儿),项目代码里却只用到了其中 4 个组件。
import Vue from 'vue'
// 优化前
import Earth from '@du/earth'
Vue.use(Earth)
// 优化后
import DuForm from '@du/earth/lib/du-form'
// ...
Vue.component('du-form', DuForm)
// ...
优化效果如下图,chunk-libs 的提交直接由 1.4MB 降到 730kB,降幅 50%。其实这种基本没啥工作量,但收益却是巨大的。
另外通过页面代码分析,其实有些组件不是首次进入需要用到的,我们可以采用动态引入的方式。例如:
import Vue from 'vue'
// 优化前
import VueBarcode from '@xkeshi/vue-barcode'
Vue.component('barcode', VueBarCode)
// 优化后
Vue.component('barcode', () => import('@xkeshi/vue-barcode'))
还有些依赖包的打包策略不合理,还需要推进依赖包的优化。例如 @du/umi-request 2.x 版本包,会打包进一些 node.js 的模块,因为历史原因,我们暂无法升级到 3.x 版本,所以就找到包的维护者,在 2.x 版本的基础上改变依赖包的引入方式,更新下 2.x 版本。
- Assets 打包优化
借助于发布平台 assets 发布,前端应用可以很好的利用 CDN 特性,带来更快的资源请求速度外,还能支持秒级回滚。但目前的接入方式是需要前端应用自行加入版本号概念,如果版本号更新,每次编译打包后都会把构建产物放到新的版本号目录下,会导致基于 contentHash 的打包策略,无法充分利用浏览器缓存。为了解决这个问题目前有两种解决方式(如果你有更好的方案,欢迎留言分享):
设置 externals。把一些不易变动的基础框架包,通过 unpkg 或自行上传至 CDN,以 script src 的方式写入 index.html 文件中。并在打包配置文件中将这些依赖包设置到 externals 配置项中:
{
externals: {
'vue': 'Vue',
'vuex': 'vuex',
'vue-router': 'VueRouter',
// ...
}
}
使用 DllPlugin 提前打包。对于有些不易变动,但可能需要本地打包的依赖包可使用 webpack DllPlugin 提前打包。在 vue-cli-service 项目中可通过添加 vue-cli-plugin-dll 依赖,并在 vue.config.js 中配置:
{
pluginOptions: {
dll: {
entry: {
vendor: ['vue', 'vue-router', 'vuex', '@du/element-ui'],
},
output: {
path: path.resolve(__dirname, './dll'),
filename: '[name].js',
},
inject: false,
}
}
}
在我们拿到 dll 的产物后,目前是采用上传到 CDN 的方式,引入到 index.html 文件中,后面可以通过 CopyWebpackPlugin 和 HtmlWebpackPlugin 加入打包流程中。
- 小结
大概通过以上的一些优化手段,可以把 chunk-libs 这个公共依赖包的提交降到 200kB 以内。
4.2 页面渲染
SCM 使用的是后台应用典型的顶部-侧边布局-通栏的界面布局。
- 提前渲染主体布局
目前 SCM 的路由是通过获取用户天网权限菜单,和本地已声明的路由执行 merge 操作,并在 Vue 实例化之前添加到 vue-router 的路由表中。当前这种方式就需要在开始主体布局渲染之前要等待菜单接口请求完成。从上报的接口请求时长的数据中显示,菜单的接口请求耗时大约在 200ms 左右(全菜单权限的情况下),如果能够把菜单的数据中缓存下来,对于秒开来讲,这里节省的耗时还是挺明显的。因此,在原菜单接口请求的逻辑里加上优先取缓存的逻辑。伪代码如下:
function getMenuList() {
return new Promise((resolve) => {
const cache = getMenuListFromCache()
if (cache) {
resolve(cache)
store.dispatch('app/menuList', res.data)
}
// 更新菜单栏数据
getMenuListFromApi((res) => {
setMenuListCache(res.data)
store.dispatch('app/menuList', res.data)
})
})
}
另外,优化前嵌套的路由声明方式是把 Layout(菜单栏+顶部栏)作为嵌套视图的一部分,也就是只有等到当前路由被 resolve 后才能渲染主体布局。之所以使用这种方式,是考虑到存在路由页面不需要 Layout 的场景,但在分析过后,这种场景其实相当很少,可兼容处理。伪代码如下:
<!-- 优化前 -->
<!-- App.vue -->
<template>
<router-view></router-view>
<template>
<!-- Layout.vue -->
<template>
<div>
<div class="header">
<header></header>
<tag-view></tag-view>
</div>
<nav-menu></nav-menu>
<div class="app-main">
<router-view></router-view>
</div>
</div>
</template>
<!-- 优化后 -->
<!-- App.vue -->
<template>
<div>
<div class="header">
<header></header>
<tag-view></tag-view>
</div>
<nav-menu></nav-menu>
<div class="app-main">
<router-view></router-view>
</div>
</div>
<template>
- 耗时任务优化
通过 DevTools 的 Performance 可以找出 Long Task,或比较耗时的 js 代码执行片段。这里可以使用本地代码运行,能比较直观看出方法名及实际代码所在文件等。但因为受当前电脑运行状态影响,有时候数据可能会有些起伏,但大体是能找出性能瓶颈的,需要点耐心。
通过分析找出以下比较明显的点:
首次进入渲染的组件数量偏多。可搜索查找 Vue 的 init 方法。解决:对于不必要的组件延迟渲染,待用户交互时再渲染。
{
components: {
'upload': () => import('./upload.vue')
}
}
- 菜单数据较大,reactive 的过程有明显的耗时产生。
解决:菜单数据仅为展示或筛选使用,可以经过 Object.freeze 后再添加到 store 中。
- 小结
页面渲染优化的过程中,是相对比较耗精力的,需要不断通过分析工具的反馈来验证优化的效果。除了通过 Performance 这种每次都需要 recording 后才能分析,其实还可以借助 performance.now() 打点的方式,来快速验证优化效果。
const t1 = performance.now()
// 开始执行耗时代码
// do sth...
// 结束
console.log(performance.now() - t1)
5、效果展示
当前阶段大体通过以上方式,达到的优化效果如下图。虽然绝对值仍不算高,但提升幅度还是很明显的,后面还是会持续根据分析出的问题做迭代优化。
6、写在最后
前端的性能优化涉及面其实挺广的,优化手段也有很多,但不一定都适用自己的项目,而且还要考虑 ROI。二八法则同样适用于做优化这件事,我们可以用 20% 的投入换取 80% 的优化效果,这时候 ROI 是比较高的,因为应用本身可能会有它的复杂度存在。当然优化无止境,后续我们还是要持续跟进用户体验层面的诉求,帮用户解决了功能性的需求的同时,再能提高些效率本身就是件很有价值的事情。