背景
小程序在很多场景下面会遇到长列表的交互,当一个页面渲染过多的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结构。
虚拟列表最重要的几个概念:
实现虚拟列表的核心就是监听scroll事件,通过滚动距离offset和滚动的元素的尺寸之和totalSize动态调整「可视区域」数据渲染的顶部距离和前后截取索引值,实现步骤如下:
1.监听scroll事件的scrollTop/scrollLeft,计算「可视区域」起始项的索引值startIndex和结束项索引值endIndex;
2.通过startIndex和endIndex截取长列表的「可视区域」的数据项,更新到列表中;
3.计算可滚动区域的高度和item的偏移量,并应用在可滚动区域和item上。
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: '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;
},复制代码
|
在调用的时候,通过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('#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);
},
});复制代码
|
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>复制代码
|
参考资料
在写这个微信小程序的virtual-list组件过程中,主要参考了一些优秀的开源虚拟列表实现方案:
react-tiny-virtual-list
react-virtualized
react-window
总结
通过上述解释已经初步实现了在微信小程序环境中实现了虚拟列表,并且对虚拟列表的原理有了更加深入的了解。但是对于瀑布流布局,列表项尺寸不可预测等场景依然无法适用。在快速滚动过程中,依然会出现来不及渲染而白屏,这个问题可以通过增加「可视区域」外预渲染的item条数overscanCount来得到一定的缓解。
版权声明:本文内容由网络用户投稿,版权归原作者所有,本站不拥有其著作权,亦不承担相应法律责任。如果您发现本站中有涉嫌抄袭或描述失实的内容,请联系我们jiasou666@gmail.com 处理,核实后本网站将在24小时内删除侵权内容。
暂时没有评论,来抢沙发吧~