Taro虚拟列表,不妨换种思路封装,看看效果如何?

2,121 阅读8分钟

前言

github: github.com/tingyuxuan2…
npm包:www.npmjs.com/package/tar…
Taro物料市场:taro-ext.jd.com/plugin/view…
目前该组件已经用于生产环境,且运行稳定。

背景

最近组内小程序项目从Taro1迁移到了Taro3,紧跟凹凸实验室的步伐😄,开发体验确实比版本1好了很多,完全支持React语法,没有了那么多鸡肋的限制,项目的可配置程度也大大放开,充分给予了开发者自由发挥的空间;
但是由于Taro3是运行时架构,是以牺牲页面部分性能为代价的,这也间接导致了我们的列表页异常卡顿,由于我们的列表页是一次性请求所有数据,然后进行渲染,所以页面节点初始化渲染的时候会渲染很多节点,再加上一些筛选项,不用说用户了,卡顿的已经让我们自己也忍受不了。。。

原因分析

  1. 页面节点过多,渲染时间变长,阻碍了用户快速操作的需求
  2. 列表setState数据量太大,造成逻辑层与渲染层的通讯时间变长
  3. 修改state,例如点击列表筛选项,列表数据需要重新大量渲染,造成页面卡顿

解决方案

方案一:后端分页

我们第一时间想到让接口分页,这样的话初始化渲染的时候就不会渲染大量节点,然后监听下拉到底时机,再依次渲染数据;
但是该方案第一时间被毙掉,原因:

  1. 列表页接口不只有小程序再用,app客户端也在共用同一套接口,如果想让接口变更,那么app客户端也跟着去修改逻辑(列表页的逻辑也挺复杂的),因为我们去尝试给客户端增加需求量,不太厚道
  2. 就算说服了客户端和服务端一起去修改,我们页面的初始化速度是提升了,但是随着页面上拉,数据加载越来越多,当加载到一定数量之后,再操作页面的筛选项,依然会导致操作卡顿

总结:因此想让页面初始化以及数据全部加载完成之后不卡顿,除非减少setState的数据量以及减少页面总的渲染节点数量,因此只能采用虚拟列表

方案二:官方虚拟列表(3.2.1版本)

官方文档docs.taro.zone/docs/virtua…

原理:只渲染当前可视区域内的数据节点,监听页面可视区域,不在可视区域的节点不再渲染,这样一来就大大减少了页面节点渲染数量

使用效果:团队第一时间尝试了虚拟列表,但是效果并不是非常理想,主要问题有以下几点:
1. 由于我们的列表内容不是所有的Item都是等高的,所以虚拟列表每次渲染的时候都会去动态计算每个Item的高度,造成列表高度变换抖动
2. 上拉加载过程中偶尔会出现无限上滑加载的问题,造成页面紊乱
3. 滑动速度太快会导致页面很长一段时间的白屏,体验不佳

总结:已知问题需要官方团队去解决,但是要等,而且Item不等高,需要频繁动态计算Item高度的问题并不好解决,目前市面上也没有什么特别好的方案,因此该方案也被搁浅了。。。

方案分析

  1. 减少页面节点数量:只能采用虚拟列表,只渲染当前可视区域内的节点;
  2. 减少setState的数据量:能不能不每次都去全量setState;
  3. 动态计算Item高度:每次都重新计算每个Item高度,计算量太大,也会阻碍页面渲染;
    基于以上问题,我们团队最终出品了更佳(没有最佳,只有更佳)虚拟列表方案,且听小二娓娓道来。。。

终极(更佳)方案

效果概览

动图预览:

虚拟列表1.gif

主要看一下虚拟列表节点组成

image.png

前期思考🤔

  1. 继续采用监听可视区域,只渲染可视区域内的节点;
  2. 由于Item不等高问题,需要动态计算每个Item的高度,效果不佳,我们放弃;因为我们只渲染当前可视区域内的数据,那么我们能不能以每一屏的数据为一个维度(界限),当一屏数据渲染完成之后,我们记录一下该屏幕节点所占的整体高度,当该屏幕的节点再次进入可视区域,我们将记录下的高度重新赋予这一屏幕,这样是不是就减少了大量计算的工作?🤔
  3. 那么为了减少setState的数据量,不再可视区域内的那些屏幕的数据,我们可否用该屏幕的高度(一个简单的对象数据结构)去占位?🤔 好像思路都能说的过去,那到底可不可行呢,下面我们来一探究竟吧😄

Coding

格式化数据

首先我们需要外部传入列表数据list,然后我们要在组件内部加工一下,按照一屏一屏渲染的思路,我们暂且把list改为二维数组,一个维度就是一屏的数据;

export default class VirtialList extends Component {
    constructor(props) {
        super(props)
        this.state = {
          twoList: [], // 二维数组
        }
    }
    componentDidMount() {
        // 接收外部传入的列表数据
        const { list } = this.props
        // 将list格式化为二维数组
        this.formatList(list)
    }
    initList = [] // 承载初始化的二维数组,该数组初始化完成之后就不会再变了,除非外部list变化
    /**
     * 将列表格式化为二维
     * @param	list 	列表
     */
    formatList(list) {
        // 用户可自定义二维数组每一个维度的数据量
        const { segmentNum } = this.props
        
        let arr = []
        const _list = [] // 二维数组副本
        list.forEach((item, index) => {
          arr.push(item)
          if ((index + 1) % segmentNum === 0) {
            // 够一个维度的量就装进_list
            _list.push(arr)
            arr = []
          }
        })
        // 将分段不足segmentNum的剩余数据装入_list
        const restList = list.slice(_list.length * segmentNum)
        if (restList?.length) {
          _list.push(restList)
        }
        this.initList = _list
        this.setState({
          twoList: _list.slice(0, 1), // 第一次渲染,只取第一个维度的数据
        })
    }
    render() {
        const {
          twoList,
        } = this.state
        // 渲染回调
        const { onRender } = this.props
        return (
            <ScrollView>
                <View className="zt-main-list">
                  {
                    twoList?.map((item, pageIndex) => {
                      return (
                        // 每一个屏幕都用一个节点包裹着
                        <View key={pageIndex} className={`wrap_${pageIndex}`}>
                            {
                              item.map((el, index) => {
                                return onRender?.(el, (pageIndex * segmentNum + index), pageIndex)
                              })
                            }
                        </View>
                      )
                    })
                  }
                </View>
            </ScrollView>
        )
    }
}

设置屏幕高度

我们已将数据格式化为二维数组了,初始化渲染的时候只会渲染数组的第一维度,那么在该维度节点渲染完成之后,我们需要记录下该维度节点所占屏幕的一个高度

state = {
    wholePageIndex: 0, // 每一屏为一个单位,屏幕索引
}
formatList(list) {
    // ...
    this.setState({
        twoList: _list.slice(0, 1),
    }, () => {
        // 注意:放在下一个事件循环去获取节点,更有保障
        Taro.nextTick(() => {
            this.setHeight()
        })
    })
}
pageHeightArr = [] // 用来装每一屏的高度
setHeight():void {
    const { wholePageIndex } = this.state
    const query = Taro.createSelectorQuery()
    query.select(`.wrap_${wholePageIndex}`).boundingClientRect()
    query.exec((res) => {
      this.pageHeightArr.push(res?.[0]?.height)
    })
}

上拉加载

利用ScrollView的onScrollToLower属性,监听列表上拉至底部,加载下一个维度的数据,塞入二维数组列表

<ScrollView
    scrollY
    onScrollToLower={this.renderNext}
    lowerThreshold={250}
>
//...
</ScrollView>

renderNext = () => {
    // 每次加载下一屏幕的数据,修改屏幕索引
    const page_index = this.state.wholePageIndex + 1

    this.setState({
      wholePageIndex: page_index,
    }, () => {
      const { wholePageIndex, twoList } = this.state
      // 找到当前屏幕的对应的数据,塞入二维数组
      twoList[wholePageIndex] = this.initList[wholePageIndex]
      this.setState({
        twoList: [...twoList],
      }, () => {
        Taro.nextTick(() => {
          this.setHeight()
        })
      })
    })
}

监听可视区域

利用observer对象的监听方法observe,监听当前可视区域,渲染对应维度的数据,那么不在可视区域内的数据要怎么处理呢?
这也是该组件最重要的一环,当不在可视区域内的数据,因为我们之前已经记录了该维度节点渲染之后的一个高度,那么我们就利用一个节点赋予对应的高度,进行占位!

setHeight() {
    //...
    this.observe()
}
observe = () => {
    const { wholePageIndex } = this.state
    // 外界用户传入的组件高度
    const { scrollViewProps } = this.props
    // 以传入的scrollView的高度为相交区域的参考边界,若没传,则默认使用屏幕高度
    const scrollHeight = scrollViewProps?.style?.height || this.windowHeight
    // 设定监听的范围,我们这里默认监听上下两个屏幕的高度
    const observer = Taro.createIntersectionObserver(this.currentPage.page).relativeToViewport({
      top: 2 * scrollHeight,
      bottom: 2 * scrollHeight,
    })
    observer.observe(`.wrap_${wholePageIndex}`, (res) => {
      const { twoList } = this.state
      if (res?.intersectionRatio <= 0) {
        // 当没有与当前视口有相交区域,则将该屏的数据置为该屏的高度占位
        twoList[wholePageIndex] = { height: this.pageHeightArr[wholePageIndex] }
        this.setState({
          twoList: [...twoList],
        })
      } else if (!twoList[wholePageIndex]?.length) {
        // 如果有相交区域,则将对应的维度的数据塞入二维数组
        twoList[wholePageIndex] = this.initList[wholePageIndex]
        this.setState({
          twoList: [...twoList],
        })
      }
    })
}
render() {
    return (
        <ScrollView>
            <View className="zt-main-list">
              {
                  twoList?.map((item, pageIndex) => {
                      return (
                        <View key={pageIndex} className={`wrap_${pageIndex}`}>
                          {
                            item?.length > 0 ? (
                              <Block>
                                {
                                  item.map((el, index) => {
                                    return onRender?.(el, (pageIndex * segmentNum + index), pageIndex)
                                  })
                                }
                              </Block>
                            ) : (
                              <View style={{'height': `${item?.height}px`}}></View>
                            )
                          }
                        </View>
                      )
                   })
                }
            </View>
        </ScrollView>
    )
}

性能提升

接下来是智行小程序机票列表页优化前跟优化后的几组数据对比:

列表页渲染时长

主要指的是从进入该页面到页面节点全部渲染完成的时间间隔,单位毫秒,(包括接口请求时间):

image.png

筛选项响应时间

主要指的是从点击页面下方筛选按钮时间开始算起,到底部浮层漏出的时间间隔,单位毫秒:

image.png

性能提升总结

可以看出在使用虚拟列表对页面进行优化之后,页面总的渲染性能会有一个质的提升,页面渲染速度提升了将近23%,按钮点击响应速度提升了将近50%!😄
目前我们只是针对航班列表使用了虚拟列表进行优化,页面中还有一个比较损耗性能的点是上方的日历列表,后期我们将把日历列表也改成虚拟列表,相信性能提升会更进一步!

总结

组件的实现比较简单,关键点就在于:

  1. 将列表数据格式化为二维数组
  2. 不在可视区域内的数据用{height: xx px}填充,减少了列表数据setState的量
  3. 动态计算每一个屏幕的高度并记录,减少计算量

最后

如果这篇文章对你现在的开发有一些帮助,或者说给你带来了一些更好的思考,欢迎一起来讨论。