在(zài)微信小程序中實現virtual-list - 新聞資訊 - 雲南小程序開發|雲南軟件開發|雲南網站建設-昆明融晨信息技術有限公司

159-8711-8523

雲南網建設/小程序開發/軟件開發

知識

不(bù)管是(shì)網站,軟件還是(shì)小程序,都要(yào / yāo)直接或間接能爲(wéi / wèi)您産生價值,我們在(zài)追求其視覺表現的(de)同時(shí),更側重于(yú)功能的(de)便捷,營銷的(de)便利,運營的(de)高效,讓網站成爲(wéi / wèi)營銷工具,讓軟件能切實提升企業内部管理水平和(hé / huò)效率。優秀的(de)程序爲(wéi / wèi)後期升級提供便捷的(de)支持!

您當前位置>首頁 » 新聞資訊 » 小程序相關 >

在(zài)微信小程序中實現virtual-list

發表時(shí)間:2021-1-5

發布人(rén):融晨科技

浏覽次數:68

背景

小程序在(zài)很多場景下面會遇到(dào)長列表的(de)交互,當一個(gè)頁面渲染過多的(de)wxml節點的(de)時(shí)候,會造成小程序頁面的(de)卡頓和(hé / huò)白屏。原因主要(yào / yāo)有以(yǐ)下幾點:

1.列表數據量大(dà),初始化setData和(hé / huò)初始化渲染列表wxml耗時(shí)都比較長;

2.渲染的(de)wxml節點比較多,每次setData更新視圖都需要(yào / yāo)創建新的(de)虛拟樹,和(hé / huò)舊樹的(de)diff操作耗時(shí)比較高;

3.渲染的(de)wxml節點比較多,page能夠容納的(de)wxml是(shì)有限的(de),占用的(de)内存高。

微信小程序本身的(de)scroll-view沒有針對長列表做優化,官方組件recycle-view就(jiù)是(shì)一個(gè)類似virtual-list的(de)長列表組件。現在(zài)我們要(yào / yāo)剖析虛拟列表的(de)原理,從零實現一個(gè)小程序的(de)virtual-list。

實現原理

首先我們要(yào / yāo)了(le/liǎo)解什麽是(shì)virtual-list,這(zhè)是(shì)一種初始化隻加載「可視區域」及其附近dom元素,并且在(zài)滾動過程中通過複用dom元素隻渲染「可視區域」及其附近dom元素的(de)滾動列表前端優化技術。相比傳統的(de)列表方式可以(yǐ)到(dào)達極高的(de)初次渲染性能,并且在(zài)滾動過程中隻維持超輕量的(de)dom結構。

虛拟列表最重要(yào / yāo)的(de)幾個(gè)概念:

  • 可滾動區域:比如列表容器的(de)高度是(shì)600,内部元素的(de)高度之(zhī)和(hé / huò)超過了(le/liǎo)容器高度,這(zhè)一塊區域就(jiù)可以(yǐ)滾動,就(jiù)是(shì)「可滾動區域」;

  • 可視區域:比如列表容器的(de)高度是(shì)600,右側有縱向滾動條可以(yǐ)滾動,視覺可見的(de)内部區域就(jiù)是(shì)「可視區域」。

實現虛拟列表的(de)核心就(jiù)是(shì)監聽scroll事件,通過滾動距離offset和(hé / huò)滾動的(de)元素的(de)尺寸之(zhī)和(hé / huò)totalSize動态調整「可視區域」數據渲染的(de)頂部距離和(hé / huò)前後截取索引值,實現步驟如下:

1.監聽scroll事件的(de)scrollTop/scrollLeft,計算「可視區域」起始項的(de)索引值startIndex和(hé / huò)結束項索引值endIndex;

2.通過startIndex和(hé / huò)endIndex截取長列表的(de)「可視區域」的(de)數據項,更新到(dào)列表中;

3.計算可滾動區域的(de)高度和(hé / huò)item的(de)偏移量,并應用在(zài)可滾動區域和(hé / huò)item上(shàng)。

1.列表項的(de)寬/高和(hé / huò)滾動偏移量

在(zài)虛拟列表中,依賴每一個(gè)列表項的(de)寬/高來(lái)計算「可滾動區域」,而(ér)且可能是(shì)需要(yào / yāo)自定義的(de),定義itemSizeGetter函數來(lái)計算列表項寬/高。

itemSizeGetter(itemSize) {
      return (index: number) => {
        if (isFunction(itemSize)) {
          return itemSize(index);
        }
        return isArray(itemSize) ? itemSize[index] : itemSize;
      };
    }
複制代碼

滾動過程中,不(bù)會計算沒有出(chū)現過的(de)列表項的(de)itemSize,這(zhè)個(gè)時(shí)候會使用一個(gè)預估的(de)列表項estimatedItemSize,目的(de)就(jiù)是(shì)在(zài)計算「可滾動區域」高度的(de)時(shí)候,沒有測量過的(de)itemSize用estimatedItemSize代替。

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
    );
  }
複制代碼

這(zhè)裏看到(dào)了(le/liǎo)是(shì)直接通過緩存命中最近一個(gè)計算過的(de)列表項的(de)itemSize和(hé / huò)offset,這(zhè)是(shì)因爲(wéi / wèi)在(zài)獲取每一個(gè)列表項的(de)兩個(gè)參數時(shí)候,都對其做了(le/liǎo)緩存。

 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.根據偏移量搜索索引值

在(zài)滾動過程中,需要(yào / yāo)通過滾動偏移量offset計算出(chū)展示在(zài)「可視區域」首項數據的(de)索引值,一般情況下可以(yǐ)從0開始計算每一列表項的(de)itemSize,累加到(dào)一旦超過offset,就(jiù)可以(yǐ)得到(dào)這(zhè)個(gè)索引值。但是(shì)在(zài)數據量太大(dà)和(hé / huò)頻繁觸發的(de)滾動事件中,會有較大(dà)的(de)性能損耗。好在(zài)列表項的(de)滾動距離是(shì)完全升序排列的(de),所以(yǐ)可以(yǐ)對已經緩存的(de)數據做二分查找,把時(shí)間複雜度降低到(dào) O(lgN) 。

js代碼如下:

  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;
  }
複制代碼

對于(yú)搜索沒有緩存計算結果的(de)查找,先使用指數查找縮小查找範圍,再使用二分查找。

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

我們知道(dào)了(le/liǎo)「可視區域」尺寸containerSize,滾動偏移量offset,在(zài)加上(shàng)預渲染的(de)條數overscanCount進行調整,就(jiù)可以(yǐ)計算出(chū)「可視區域」起始項的(de)索引值startIndex和(hé / huò)結束項索引值endIndex,實現步驟如下:

1.找到(dào)距離offset最近的(de)索引值,這(zhè)個(gè)值就(jiù)是(shì)起始項的(de)索引值startIndex;

2.通過startIndex獲取此項的(de)offset和(hé / huò)size,再對offset進行調整;

3.offset加上(shàng)containerSize得到(dào)結束項的(de)maxOffset,從startIndex開始累加,直到(dào)越過maxOffset,得到(dào)結束項索引值endIndex。

js代碼如下:

 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事件,實現虛拟列表滾動

現在(zài)可以(yǐ)通過監聽scroll事件,動态更新startIndex、endIndex、totalSize、offset,就(jiù)可以(yǐ)實現虛拟列表滾動。

js代碼如下:

  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: 'absolute',
        top: 0,
        left: 0,
        width: '100%',
        [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('render', {
          startIndex: start,
          stopIndex: stop,
          styleItems,
        });
      }

      this.data.offset = offset;
      this.data.visibleRange.start = start;
      this.data.visibleRange.stop = stop;
  },
複制代碼

在(zài)調用的(de)時(shí)候,通過render事件回調出(chū)來(lái)的(de)startIndex, stopIndex,styleItems,截取長列表「可視區域」的(de)數據,在(zài)把列表項目的(de)itemSize和(hé / huò)offset通過絕對定位的(de)方式應用在(zài)列表上(shàng)

代碼如下:

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('#virtual-list');
  },

  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);
  },
});
複制代碼
<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>
複制代碼


參考資料

在(zài)寫這(zhè)個(gè)微信小程序的(de)virtual-list組件過程中,主要(yào / yāo)參考了(le/liǎo)一些優秀的(de)開源虛拟列表實現方案:

  • react-tiny-virtual-list
  • react-virtualized
  • react-window

總結

通過上(shàng)述解釋已經初步實現了(le/liǎo)在(zài)微信小程序環境中實現了(le/liǎo)虛拟列表,并且對虛拟列表的(de)原理有了(le/liǎo)更加深入的(de)了(le/liǎo)解。但是(shì)對于(yú)瀑布流布局,列表項尺寸不(bù)可預測等場景依然無法适用。在(zài)快速滾動過程中,依然會出(chū)現來(lái)不(bù)及渲染而(ér)白屏,這(zhè)個(gè)問題可以(yǐ)通過增加「可視區域」外預渲染的(de)item條數overscanCount來(lái)得到(dào)一定的(de)緩解。


作者:用戶8824932366654
來(lái)源:掘金
著作權歸作者所有。商業轉載請聯系作者獲得授權,非商業轉載請注明出(chū)處。

相關案例查看更多