作者简介
Kenny,携程高级前端开发工程师。2021年加入携程,从事小程序/H5相关研发工作。
一、背景
随着项目的不断迭代,规模日益增大,而基于Taro3的运行时弊端也日渐凸显,尤其在复杂列表页面上表现欠佳,极度影响用户体验。本文将以复杂列表的性能优化为主旨,尝试建立检测指标,了解性能瓶颈,通过预加载、缓存、优化组件层级、优化数据结构等多种方式,实验后提供一些技术方案的建议,希望可以给大家带来一些思路。
二、问题现状及分析
我们以酒店某一多功能列表为例(下图),设定检测标准(setData次数及该setData的响应时效作为指标),检测情况如下:
指标 | setData次数 | 渲染耗时(ms) |
---|---|---|
第一次进入列表页 | 7 | 2404 |
下拉长列表更新 | 3 | 1903 |
多屏列表下 筛选项更新 | 2 | 1758 |
多屏列表下 列表项更新 | 2 | 748 |
由于历史原因,该页面的代码,由微信的原生转成的taro1,后续迭代至taro3。项目中存在小程序原生写法可能忽略的问题。根据上面多次测出的指标值,以及视觉体验上来看,存在以下问题:
2.1 首次进入列表页的加载时间过长,白屏时间久
- 列表页请求的接口时间过长;
- 初始化列表也是setData数据量过大,且次数过多;
- 页面节点数过多,导致渲染耗时较长;
2.2 页面筛选项的更新卡顿,下拉动画卡顿
- 筛选项中节点过多,更新时setData数据量大;
- 筛选项的组件更新会导致页面跟着一起更新;
2.3 无限列表的更新卡顿,滑动过快会白屏
- 请求下一页的时机过晚;
- setData时数据量大,响应慢;
- 滑动过快时,没有从白屏到渲染完成的过渡机制,体验欠佳;
三、尝试优化的方案
3.1 跳转预加载API:
通过观察小程序的请求可以发现,列表页请求中,有两个请求耗时较为长。
在Taro3的升级中,官方有提到预加载Preload,在小程序中,从调用 Taro.navigateTo 等路由跳转 API 后,到小程序页面触发 onLoad 会有一定延时(约300ms,如果是分包新下载则跳转时间更长),因此一些网络请求可以提前到发起跳转时一起去请求。于是我们在在跳转前,使用Taro.preload预先加载复杂列表的请求:
// Page A const query = new Query({ // ... })
Taro.preload({ RequestPromise: requestPromiseA({data: query }), })
// Page B componentDidMount() { // 在跳转的过程中,发出请求,因为返回的是一个promise,所以需要在B页面承接: Taro.getCurrentInstance().preloadData?.RequestPromise?.then(res => { this.setState(this.processResData(res.data)) }) }
用同样的检测方式反复测试后,使用preload的时,能提前300~400ms提前拿到酒店的列表数据。
左边是没使用preload的旧列表,右边是预加载的列表,能明显看出预加载后的列表会快一些。
然而在实际的使用中我们发现preload存在部分缺陷,对于承接页面,如果接口较为复杂,会对业务流程的代码有一定的入侵。究其本质,是前置了网络请求,所以我们可以对网络请求部分加入缓存策略,即可达到该效果,且接入成本会大大降低。
3.2 合理运用setData
setData 是小程序开发中使用最频繁、也是最容易引发性能问题的API。setData 的过程,大致可以分成几个阶段:
- 逻辑层虚拟 DOM 树的遍历和更新,触发组件生命周期和 observer 等;
- 将 data 从逻辑层传输到视图层;
- 视图层虚拟 DOM 树的更新、真实 DOM 元素的更新并触发页面渲染更新。
数据传输的耗时与数据量的大小正相关,旧的列表页第一次加载的时候,一共请求了4个接口,setData短时间里有6次,数据量偏大的有两次,我们尝试的优化方式为,将数据量大的两次分开,另外五次发现都是一些零散的状态和数据,可以作为一次。
指标 | setData次数 | setData耗时(ms) | 减少耗时百分比 |
---|---|---|---|
第一次进入列表页 | 3 | 2182 | 9.23% |
- 动画会闪一下 然后再出现
- 筛选页面节点过多时,点击响应过慢,用户体验差
旧的筛选项的动画是通过keyframes方式实现了一个fadeIn的动画,加在最外层,但是无论如何在动画出现的那一帧,都会闪一下。分析下来,因为keyframes执行动画造成的卡顿:
.filter-wrap { animation: .3s ease-in fadeIn;}
@keyframes fadeIn { 0% { transform: translateY(-100%) } 100% { transform: translateY(0) }}
于是,尝试换了一种实现方式,通过transition来实现transfrom:
.filter-wrap { transform: translateY(-100%); transition: none; &.active { transform: translateY(0); transition: transform .3s ease-in; }}
3.4.2 维护简洁的state
操作筛选项的时候,每操作一次都需要根据唯一id从筛选项的数据结构中循环遍历,去找到对应的item,改掉item的状态,然后将整个结构重新setState。官方文档中提到关于setState,应该尽量避免处理过大的数据,会影响页面的更新性能。
针对这一问题,采取的办法是:
- 预先将复杂的对象扁平化,示例如下:
{ "a": { "subs": [{ "a1": { "subs": [{ "id": 1 }] } }] }, "b": { "subs": [{ "id": 2 }] },
// ... }
扁平化后的筛选项数据结构:
{ "1": { "id": 1, "name": "汉庭", "includes": [], "excludes": [], // ... }, "2": { // ... },
// ... }
- 不改变原有的数据,利用扁平化后的数据结构维护一个动态的选中列表:
const flattenFilters = data => { // ...
return { [id]: { id: 2, name: "全季", includes: [], excludes: [] // ... },
// ... }}
const filters = [], filtersSelected = {}const flatFilters = flattenFilters(filters)
const onClickFilterItem = item => {
// 所有的操作需要先拿到扁平化的item const flatItem = flatFilters[item.id]
if (filtersSelected[flatItem.id]) { // 已选中,需要取消选中 delete filtersSelected[flatItem.id] } else { // 未选中,需要选中 filtersSelected[flatItem.id] = flatItem // 取消选中排斥项 const idsSelected = Object.keys(filtersSelected) const idsIntersection = intersection(idsSelected, flatItem.selfExcludes) // 交集 if (idsIntersection.length) { idsIntersection.forEach(id => { delete filtersSelected[id] }) }
// 其他逻辑 (快筛,关键词等) }
this.setState({filtersSelected})}
上面是一个简单的实现,前后对比,我们只需要维护一个很简单的对象,对其属性进行添加或者删除,性能有细微的提高,且代码更为简单整洁。在业务代码中,类似这种通过数据结构转换提升效率的地方有很多。
关于筛选项,可以对比下检测的平均数据,减少200ms~300ms,也会得到一些提升:
指标 | setData耗时旧 | setData耗时新 | 减少耗时百分比 |
---|---|---|---|
长列表下筛选项展开 | 1023 | 967 | 5.47% |
长列表下点击筛选项 | 1758 | 1443 | 17.92% |
3.5 长列表的优化
早期酒店列表页引入了虚拟列表,针对长列表渲染一定数目的酒店。核心的思路是只渲染显示在屏幕的数据,基本实现就是监听 scroll 事件,并且重新计算需要渲染的数据,不需要渲染的数据留一个空的 div 占位元素。
- 加载下一页有轻微的卡顿:
通过数据发现,下拉更新列表平均耗时1900ms左右:
指标 | setData次数 | setData耗时 |
---|---|---|
下拉列表更新 | 3 | 1903 |
针对这个问题,解决方案是,提前加载下一页的数据,将下一页存入内存变量中。滚动加载的时候直接从内存变量中去取,然后setData更新到数据中。
- 滑动速度过快会出现白屏(速度越快白屏时间越久,下方左图): 虚拟列表的原理就是利用空的View去占位,当快速回滚的时候,渲染的时候当节点过于复杂,特别是酒店带有图片,渲染就会变慢,导致白屏,我们进行了三种方案的尝试: 1) 使用动态的骨架图代替原有的View占位 下方图右:
2) CustomWrapper
为了提升性能,官方推荐了CusomWrapper,它可以将包裹的组件与页面隔离,组件渲染时不会更新整个页面,由page.setData变为component.setData。
自定义组件是基于Shadow DOM实现的,对组件中的DOM和CSS进行了封装,使得组件内部与主页面的DOM保持了分离。图片中的#shadow-root是根节点,成为影子根,和主文档分开渲染。#shadow-root可以嵌套形成节点树(Shadow Tree)
<custom-wrapper is="custom-wrapper"> #shadow-root <view class="list"></view> </custom-wrapper>
包裹的组件被隔离,这样内部的数据的更新不会影响到整个页面,可以简单看下低性能客户端下的表现。效果还是明显的,同一时间点击,右侧弹窗出现的耗时平均会快200ms ~ 300ms (同一机型同一环境下测出),机型越低端越明显。
(右侧是CustomWrapper下的)
3) 使用小程序原生组件
用小程序的原生组件去实现这个列表Item。原生组件绕过Taro3的运行时,也就是说,在用户对页面操作的时候,如果是taro3的组件,需要进行前后数据的diff计算,然后生产新的虚拟dom所需要的节点数据,进而调用小程序的api去对节点进行操作。原生组件绕过了这一些列的操作,直接是是底层小程序对数据的更新。所以,缩短了一些时间。可以看一下实现后的效果:
指标 | setData次数(旧) | setData次数(新) |
---|---|---|
下拉列表更新 | 3 | 1 |
setData耗时(旧) | setData耗时(新) | 减少耗时百分比 |
---|---|---|
1903 | 836 | 56.07% |
可以看出原生性能提升很大,平均更新列表缩短1s左右,但是使用原生也有缺点,主要表现为以下两个方面:
- 组件包含的所有样式 需要按照小程序的规范写一遍,且与taro的样式相互隔离;
- 在原生组件中无法使用taro的API,比如createSelectorQuery这种;
对比三种方案,性能提升逐步加强。考虑到使用Taro原本的意义在于跨端,如果使用原生,就没办法达到这个目的,不过我们在尝试是否可以通过插件,在编译时生成对应原生小程序的组件代码,以此解决这一问题,最终达到最优效果。
3.6 React.memo
当复杂页面子组件过多时,父组件的渲染会导致子组件跟着渲染,React.memo可以做浅层的比较防止不必要的渲染:
const MyComponent = React.memo(function MyComponent(props) { /* 使用 props 渲染 */})
React.memo为高阶组件。它与React.PureComponent非常相似,但它适用于函数组件,但不适用于 class 组件。
如果你的函数组件在给定相同props的情况下渲染相同的结果,那么你可以通过将其包装在React.memo中调用,以此通过记忆组件渲染结果的方式来提高组件的性能表现。这意味着在这种情况下,React 将跳过渲染组件的操作并直接复用最近一次渲染的结果。
默认情况下其只会对复杂对象做浅层对比,如果你想要控制对比过程,那么请将自定义的比较函数通过第二个参数传入来实现。
function MyComponent(props) { /* 使用 props 渲染 */}
function areEqual(prevProps, nextProps) { /* 如果把 nextProps 传入 render 方法的返回结果与 将 prevProps 传入 render 方法的返回结果一致则返回 true, 否则返回 false */}
export default React.memo(MyComponent, areEqual);
四、总结
本次复杂列表的性能优化我们前后经历较久,尝试了各种可能的优化点。从列表页的预加载,筛选项数据结构和动画实现的改变,到长列表的体验优化和原生的结合,提升了页面的更新和渲染效率,目前仍密切关注,继续保持探索。
以下为最终效果对比(右侧为优化后):
【推荐阅读】
- 携程小程序生态之Taro跨端解决方案
- 携程活动搭建平台的前端“开放性”建设探索
- 携程基于 GraphQL 的前端 BFF 服务开发实践
- 携程微信小程序如何进行Size治理
“携程技术”公众号
分享,交流,成长