5

Taro性能优化之复杂列表篇

 2 years ago
source link: https://www.51cto.com/article/718867.html
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

作者 | Kenny,携程高级前端开发工程师。2021年加入携程,从事小程序/H5相关研发工作。

随着项目的不断迭代,规模日益增大,而基于Taro3的运行时弊端也日渐凸显,尤其在复杂列表页面上表现欠佳,极度影响用户体验。本文将以复杂列表的性能优化为主旨,尝试建立检测指标,了解性能瓶颈,通过预加载、缓存、优化组件层级、优化数据结构等多种方式,实验后提供一些技术方案的建议,希望可以给大家带来一些思路。

二、问题现状及分析

我们以酒店某一多功能列表为例(下图),设定检测标准(setData次数及该setData的响应时效作为指标),检测情况如下:

图片
图片

setData次数

渲染耗时(ms)

第一次进入列表页

下拉长列表更新

多屏列表下 筛选项更新

多屏列表下 列表项更新

由于历史原因,该页面的代码,由微信的原生转成的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)

减少耗时百分比

第一次进入列表页

9.23%

进行完这一步的操作,平均能减少200ms左右,效果较小,因为页面的节点数没变,setData主要的耗时还分布于渲染时间。

3.3  优化页面的节点数

根据微信官方文档的说明,一个太大的节点树会增加内存使用的同时,样式重排时间上也会更长。建议一个页面节点数量应少于 1000 个,节点树深度少于 30 层,子节点数不大于 60 个。 

在微信开发者工具中分析该页面两个模块存在大量的节点数。一个是筛选项模块,一个是长列表的模块。因为这部分功能较多,且结构复杂,我们采用了选择性渲染。如在用户浏览列表式,筛选项不生成具体节点。点击展开筛选的时候再渲染出节点,对于页面列表的体验有一定程度的缓解。另一方面,对于整体布局的书写上,有意识的避免嵌套过深的写法,如RichText使用,部分选择图片代替等。

3.4  优化筛选项相关

3.4.1  改变动画方式

在重构筛选项的过程中,发现在一些机型上,小程序的动画效果不太理想,比如当打开筛选项tab的时候,需要实现一个向下拉出的效果,早期在实现的时候,会出现两个问题:

  • 动画会闪一下 然后再出现
  • 筛选页面节点过多时,点击响应过慢,用户体验差
图片

旧的筛选项的动画是通过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耗时新

减少耗时百分比

长列表下筛选项展开

5.47%

长列表下点击筛选项

17.92%

3.5  长列表的优化

早期酒店列表页引入了虚拟列表,针对长列表渲染一定数目的酒店。核心的思路是只渲染显示在屏幕的数据,基本实现就是监听 scroll 事件,并且重新计算需要渲染的数据,不需要渲染的数据留一个空的 div 占位元素。

  • 加载下一页有轻微的卡顿:
图片

通过数据发现,下拉更新列表平均耗时1900ms左右:

setData次数

setData耗时

下拉列表更新

针对这个问题,解决方案是,提前加载下一页的数据,将下一页存入内存变量中。滚动加载的时候直接从内存变量中去取,然后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次数(新)

下拉列表更新

setData耗时(旧)

  setData耗时(新)

  减少耗时百分比

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);

本次复杂列表的性能优化我们前后经历较久,尝试了各种可能的优化点。从列表页的预加载,筛选项数据结构和动画实现的改变,到长列表的体验优化和原生的结合,提升了页面的更新和渲染效率,目前仍密切关注,继续保持探索。

以下为最终效果对比(右侧为优化后):

图片
图片

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK