background

Applets encounter long list interactions under many scenarios, and when a page renders too many WXML nodes, the applets page freezes and the screen goes blank. The main reasons are as follows:

1. Due to the large amount of list data, it takes a long time to initialize setData and WXML of the rendering list;

2. There are a lot of WXML nodes rendered, and every time setData updates the view, new virtual trees need to be created, and the diff operation with the old tree is time-consuming;

3. There are a lot of WXML nodes rendered, and the WXML page can accommodate is limited, occupying high memory.

The Scrollview of wechat mini program itself is not optimized for long lists. The recycle-view is a long list component similar to virtual-list. Now we want to analyze the principle of virtual list, from zero to achieve a small program virtual-list.

Realize the principle of

First we need to understand what virtual-list is, which is a scrolling list front-end optimization technique that initializes loading only the “visible region” and its nearby DOM elements, and renders only the “visible region” and its nearby DOM elements by reusing DOM elements during scrolling. You can achieve extremely high initial rendering performance compared to the traditional tabular approach, and only maintain an extremely lightweight DOM structure during scrolling.

The most important concepts of virtual lists are:

  • Scrollable area: for example, if the height of the list container is 600, the sum of the height of the elements in the list exceeds the height of the container, this area can be scrollable.

  • Viewable area: If the height of the list container is 600 and there is a vertical scroll bar to scroll to the right, the visually visible inner area is the “viewable area”.

The core of implementing virtual list is to monitor scroll event, dynamically adjust the top distance of “visible area” data rendering and intercept index values before and after by the sum of scrolling distance offset and scrolling element size totalSize. The implementation steps are as follows:

1. Listen to the scrollTop/scrollLeft of the Scroll event and calculate the startIndex and endIndex of the start and end entries of the visible area.

2. Use startIndex and endIndex to intercept data items in the viewable area of the long list and update them to the list.

3. Calculate the height of the scrollable area and the offset of item and apply it to the scrollable area and item.

1. The width/height and scroll offset of the list item

In a virtual list, the scrollable area depends on the width/height of each list item, and may need to be customized. Define the itemSizeGetter function to calculate the width/height of the list item.

itemSizeGetter(itemSize) {
      return (index: number) = > {
        if (isFunction(itemSize)) {
          return itemSize(index);
        }
        return isArray(itemSize) ? itemSize[index] : itemSize;
      };
    }
Copy the code

EstimatedItemSize is used instead of calculating the itemSize of items that do not appear before. In this case, estimatedItemSize is used to calculate the height of the scrollable area. EstimatedItemSize is used to replace items that have not been measured before.

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
    );
  }
Copy the code

ItemSize and offset of the last calculated list item are hit directly from the cache. This is because the two parameters of each list item are cached.

 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];
 }
Copy the code

2. Search for the index based on the offset

In the scrolling process, the index value of the first item displayed in the “visible area” is calculated by scrolling offset. Normally, the index value can be calculated from 0 to itemSize of each item. Once the value exceeds offset, the index value can be calculated. However, in the case of too much data and frequently triggered rolling events, there will be a large performance loss. Fortunately, the scrolling distance of the list items is fully in ascending order, so binary lookups can be performed on the cached data, reducing the time complexity to O(lgN).

The js code is as follows:

  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;
  }
Copy the code

For searches without cached results, exponential searches are first used to narrow the search, followed by binary searches.

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, }); }}Copy the code

3. Calculate startIndex and endIndex

ContainerSize: containerSize: containerSize: offset: containerSize: offset: overscanCount: startIndex: endIndex: endIndex: endIndex

1. Find the index value closest to offset, which is the startIndex of the start item.

2. Run startIndex to obtain the offset and size of the item and adjust the offset.

3. Add containerSize to containerSize to obtain the maxOffset of the end item. The value of endIndex is added from startIndex until it exceeds maxOffset.

The js code is as follows:

 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,
    };
}

Copy the code

3. Listen for the Scroll event to realize the virtual list scrolling

Now you can dynamically update startIndex, endIndex, totalSize, offset by listening for scroll events to implement virtual list scrolling.

The js code is as follows:

  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;
  },
Copy the code

When called, startIndex, stopIndex, styleItems are called by the Render event to intercept the data of the “visible area” of the long list, and apply the itemSize and offset of the list item to the list in the way of absolute positioning

The code is as follows:

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); }});Copy the code
<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>
Copy the code

The resources

In the process of writing the virtual list component of this wechat small program, we mainly refer to some excellent open source virtual list implementation schemes:

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

conclusion

Through the above explanation, the virtual list has been initially realized in the micro channel small program environment, and the principle of the virtual list has a deeper understanding. However, for waterfall flow layout, the list item size is unpredictable and other scenarios are not applicable. In the process of fast scrolling, there will still be a blank screen due to lack of rendering time. This problem can be alleviated by increasing overscanCount, the number of pre-rendered items outside the “viewable area”.