背景小程序在很多场景下面会遇到长列表的交互,当一个页面渲染过多的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上。
1.列表项的宽/高和滚动偏移量在虚拟列表中,依赖每一个列表项的宽/高来计算「可滚动区域」,而且可能是需要自定义的,定义itemsizegetter函数来计算列表项宽/高。
itemsizegetter(itemsize) { return (index: number) => { if (isfunction(itemsize)) { return itemsize(index); } return isarray(itemsize) ? itemsize[index] : itemsize; }; }复制代码
滚动过程中,不会计算没有出现过的列表项的itemsize,这个时候会使用一个预估的列表项estimateditemsize,目的就是在计算「可滚动区域」高度的时候,没有测量过的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 ); }复制代码
这里看到了是直接通过缓存命中最近一个计算过的列表项的itemsize和offset,这是因为在获取每一个列表项的两个参数时候,都对其做了缓存。
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代码如下:
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; }复制代码
对于搜索没有缓存计算结果的查找,先使用指数查找缩小查找范围,再使用二分查找。
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代码如下:
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代码如下:
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通过绝对定位的方式应用在列表上
代码如下:
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>复制代码
参考资料在写这个微信小程序的virtual-list组件过程中,主要参考了一些优秀的开源虚拟列表实现方案:
react-tiny-virtual-listreact-virtualizedreact-window总结通过上述解释已经初步实现了在微信小程序环境中实现了虚拟列表,并且对虚拟列表的原理有了更加深入的了解。但是对于瀑布流布局,列表项尺寸不可预测等场景依然无法适用。在快速滚动过程中,依然会出现来不及渲染而白屏,这个问题可以通过增加「可视区域」外预渲染的item条数overscancount来得到一定的缓解。
想了解更多编程学习,敬请关注php培训栏目!
以上就是在微信小程序中实现virtual-list的方法详解的详细内容。
