微信小程序中 virtual-list 实现方法的详尽阐释

why 732 2024-07-08

在微信小程序中实现virtual-list的方法详解

背景

小程序在很多场景下面会遇到长列表的交互,当一个页面渲染过多的wxml节点的时候,会造成小程序页面的卡顿和白屏。原因主要有以下几点:

1.列表数据量大,初始化setData和初始化渲染列表wxml耗时都比较长;

2.渲染的wxml节点比较多,每次setData更新视图都需要创建新的虚拟树,和旧树的diff操作耗时比较高;

3.渲染的wxml节点比较多,page能够容纳的wxml是有限的,占用的内存高。

微信小程序本身的scroll-view没有针对长列表做优化,官方组件recycle-view就是一个类似virtual-list的长列表组件。现在我们要剖析虚拟列表的原理,从零实现一个小程序的virtual-list。

实现原理

首先我们要了解什么是virtual-list,这是一种初始化只加载「可视区域」及其附近dom元素,并且在滚动过程中通过复用dom元素只渲染「可视区域」及其附近dom元素的滚动列表前端优化技术。相比传统的列表方式可以到达极高的初次渲染性能,并且在滚动过程中只维持超轻量的dom结构。

虚拟列表最重要的几个概念:

  • 可滚动区域:比如列表容器的高度是600,内部元素的高度之和超过了容器高度,这一块区域就可以滚动,就是「可滚动区域」;

  • 可视区域:比如列表容器的高度是600,右侧有纵向滚动条可以滚动,视觉可见的内部区域就是「可视区域」。

实现虚拟列表的核心就是监听scroll事件,通过滚动距离offset和滚动的元素的尺寸之和totalSize动态调整「可视区域」数据渲染的顶部距离和前后截取索引值,实现步骤如下:

1.监听scroll事件的scrollTop/scrollLeft,计算「可视区域」起始项的索引值startIndex和结束项索引值endIndex;

2.通过startIndex和endIndex截取长列表的「可视区域」的数据项,更新到列表中;

3.计算可滚动区域的高度和item的偏移量,并应用在可滚动区域和item上。

image.png

1.列表项的宽/高和滚动偏移量

在虚拟列表中,依赖每一个列表项的宽/高来计算「可滚动区域」,而且可能是需要自定义的,定义itemSizeGetter函数来计算列表项宽/高。

1

2

3

4

itemSizeGetter(itemSize) {      return (index: number) => {        if (isFunction(itemSize)) {          return itemSize(index);

        }        return isArray(itemSize) ? itemSize[index] : itemSize;

      };

    }复制代码

滚动过程中,不会计算没有出现过的列表项的itemSize,这个时候会使用一个预估的列表项estimatedItemSize,目的就是在计算「可滚动区域」高度的时候,没有测量过的itemSize用estimatedItemSize代替。

1

2

3

4

5

6

7

8

9

10

11

getSizeAndPositionOfLastMeasuredItem() {    return this.lastMeasuredIndex >= 0

      ? this.itemSizeAndPositionData[this.lastMeasuredIndex]

      : { offset: 0, size: 0 };

  }

 

getTotalSize(): number {    const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();    return (

      lastMeasuredSizeAndPosition.offset +

      lastMeasuredSizeAndPosition.size +

      (this.itemCount - this.lastMeasuredIndex - 1) * this.estimatedItemSize

    );

  }复制代码

这里看到了是直接通过缓存命中最近一个计算过的列表项的itemSize和offset,这是因为在获取每一个列表项的两个参数时候,都对其做了缓存。

1

2

3

4

5

6

7

8

9

10

getSizeAndPositionForIndex(index: number) {    if (index > this.lastMeasuredIndex) {      const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();      let offset =

       lastMeasuredSizeAndPosition.offset + lastMeasuredSizeAndPosition.size;      for (let i = this.lastMeasuredIndex + 1; i <= index; i++) {        const size = this.itemSizeGetter(i);        this.itemSizeAndPositionData[i] = {

         offset,

         size,

       };

 

       offset += size;

     }      this.lastMeasuredIndex = index;

   }    return this.itemSizeAndPositionData[index];

}复制代码

2.根据偏移量搜索索引值

在滚动过程中,需要通过滚动偏移量offset计算出展示在「可视区域」首项数据的索引值,一般情况下可以从0开始计算每一列表项的itemSize,累加到一旦超过offset,就可以得到这个索引值。但是在数据量太大和频繁触发的滚动事件中,会有较大的性能损耗。好在列表项的滚动距离是完全升序排列的,所以可以对已经缓存的数据做二分查找,把时间复杂度降低到 O(lgN) 。

js代码如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

24

25

26

27

28

findNearestItem(offset: number) {

   offset = Math.max(0, offset);    const lastMeasuredSizeAndPosition = this.getSizeAndPositionOfLastMeasuredItem();    const lastMeasuredIndex = Math.max(0, this.lastMeasuredIndex);    if (lastMeasuredSizeAndPosition.offset >= offset) {      return this.binarySearch({        high: lastMeasuredIndex,        low: 0,

       offset,

     });

   } else {      return this.exponentialSearch({        index: lastMeasuredIndex,

       offset,

     });

   }

 }

 

private binarySearch({

   low,

   high,

   offset,

 }: {    low: number;

   high: number;

   offset: number;

 }) {    let middle = 0;    let currentOffset = 0;    while (low <= high) {

     middle = low + Math.floor((high - low) / 2);

     currentOffset = this.getSizeAndPositionForIndex(middle).offset;      if (currentOffset === offset) {        return middle;

     } else if (currentOffset < offset) {

       low = middle + 1;

     } else if (currentOffset > offset) {

       high = middle - 1;

     }

   }    if (low > 0) {      return low - 1;

   }    return 0;

 }复制代码

对于搜索没有缓存计算结果的查找,先使用指数查找缩小查找范围,再使用二分查找。

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

private exponentialSearch({

    index,

    offset,

  }: {    index: number;

    offset: number;

  }) {    let interval = 1;    while (

      index < this.itemCount &&      this.getSizeAndPositionForIndex(index).offset < offset

    ) {

      index += interval;

      interval *= 2;

    }    return this.binarySearch({      high: Math.min(index, this.itemCount - 1),      low: Math.floor(index / 2),

      offset,

    });

  }

}复制代码

3.计算startIndex、endIndex

我们知道了「可视区域」尺寸containerSize,滚动偏移量offset,在加上预渲染的条数overscanCount进行调整,就可以计算出「可视区域」起始项的索引值startIndex和结束项索引值endIndex,实现步骤如下:

1.找到距离offset最近的索引值,这个值就是起始项的索引值startIndex;

2.通过startIndex获取此项的offset和size,再对offset进行调整;

3.offset加上containerSize得到结束项的maxOffset,从startIndex开始累加,直到越过maxOffset,得到结束项索引值endIndex。

js代码如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

getVisibleRange({

    containerSize,

    offset,

    overscanCount,

  }: {    containerSize: number;

    offset: number;

    overscanCount: number;

  }): { start?: number; stop?: number } {    const maxOffset = offset + containerSize;    let start = this.findNearestItem(offset);    const datum = this.getSizeAndPositionForIndex(start);

    offset = datum.offset + datum.size;    let stop = start;    while (offset < maxOffset && stop < this.itemCount - 1) {

      stop++;

      offset += this.getSizeAndPositionForIndex(stop).size;

    }    if (overscanCount) {

      start = Math.max(0, start - overscanCount);

      stop = Math.min(stop + overscanCount, this.itemCount - 1);

    }    return {

      start,

      stop,

    };

}复制代码

3.监听scroll事件,实现虚拟列表滚动

现在可以通过监听scroll事件,动态更新startIndex、endIndex、totalSize、offset,就可以实现虚拟列表滚动。

js代码如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

getItemStyle(index) {      const style = this.styleCache[index];      if (style) {        return style;

    }      const { scrollDirection } = this.data;      const {

      size,

      offset,

    } = this.sizeAndPositionManager.getSizeAndPositionForIndex(index);      const cumputedStyle = styleToCssString({        position: &#39;absolute&#39;,        top: 0,        left: 0,        width: &#39;100%&#39;,

      [positionProp[scrollDirection]]: offset,

      [sizeProp[scrollDirection]]: size,

    });      this.styleCache[index] = cumputedStyle;      return cumputedStyle;

},

 

observeScroll(offset: number) {      const { scrollDirection, overscanCount, visibleRange } = this.data;      const { start, stop } = this.sizeAndPositionManager.getVisibleRange({        containerSize: this.data[sizeProp[scrollDirection]] || 0,

      offset,

      overscanCount,

    });      const totalSize = this.sizeAndPositionManager.getTotalSize();      if (totalSize !== this.data.totalSize) {        this.setData({ totalSize });

    }      if (visibleRange.start !== start || visibleRange.stop !== stop) {        const styleItems: string[] = [];        if (isNumber(start) && isNumber(stop)) {          let index = start - 1;          while (++index <= stop) {

          styleItems.push(this.getItemStyle(index));

        }

      }        this.triggerEvent(&#39;render&#39;, {          startIndex: start,          stopIndex: stop,

        styleItems,

      });

    }      this.data.offset = offset;      this.data.visibleRange.start = start;      this.data.visibleRange.stop = stop;

},复制代码

在调用的时候,通过render事件回调出来的startIndex, stopIndex,styleItems,截取长列表「可视区域」的数据,在把列表项目的itemSize和offset通过绝对定位的方式应用在列表上

代码如下:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

let list = Array.from({ length: 10000 }).map((_, index) => index);

 

Page({  data: {    itemSize: index => 50 * ((index % 3) + 1),    styleItems: null,    itemCount: list.length,    list: [],

  },

  onReady() {    this.virtualListRef =      this.virtualListRef || this.selectComponent(&#39;#virtual-list&#39;);

  },

 

  slice(e) {    const { startIndex, stopIndex, styleItems } = e.detail;    this.setData({      list: list.slice(startIndex, stopIndex + 1),

      styleItems,

    });

  },

 

  loadMore() {

    setTimeout(() => {      const appendList = Array.from({ length: 10 }).map(        (_, index) => list.length + index,

      );

      list = list.concat(appendList);      this.setData({        itemCount: list.length,        list: this.data.list.concat(appendList),

      });

    }, 500);

  },

});复制代码

1

2

3

4

5

6

7

<view class="container">

  <virtual-list scrollToIndex="{{ 16 }}" lowerThreshold="{{50}}" height="{{ 600 }}" overscanCount="{{10}}" item-count="{{ itemCount }}" itemSize="{{ itemSize }}" estimatedItemSize="{{100}}" bind:render="slice" bind:scrolltolower="loadMore">

    <view wx:if="{{styleItems}}">

      <view wx:for="{{ list }}" wx:key="index" style="{{ styleItems[index] }};line-height:50px;border-bottom:1rpx solid #ccc;padding-left:30rpx">{{ item + 1 }}</view>

    </view>

  </virtual-list>

  {{itemCount}}</view>复制代码

image.png

参考资料

在写这个微信小程序的virtual-list组件过程中,主要参考了一些优秀的开源虚拟列表实现方案:

  • react-tiny-virtual-list

  • react-virtualized

  • react-window

总结

通过上述解释已经初步实现了在微信小程序环境中实现了虚拟列表,并且对虚拟列表的原理有了更加深入的了解。但是对于瀑布流布局,列表项尺寸不可预测等场景依然无法适用。在快速滚动过程中,依然会出现来不及渲染而白屏,这个问题可以通过增加「可视区域」外预渲染的item条数overscanCount来得到一定的缓解。


版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。

上一篇:微信小程序 Taro 自动埋点的全面剖析与深入了解
下一篇:解码会员小程序的开发秘籍
相关文章

 发表评论

暂时没有评论,来抢沙发吧~