当遇到不能用分页方式来加载列表数据的业务情况,可以使用长列表,但是同时加载大量数据时性能消耗大,因此可以使用虚拟列表,只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,从而达到较高的渲染性能
实现:
虚拟列表的实现,就是在首屏加载时,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除
1 2 3 4 5 6 7 8 9 10
| <div class="infinite-list-container"> <div class="infinite-list-phantom"></div> <div class="infinite-list"> </div> </div>
|
infinite-list-container
为可视区域
的容器
infinite-list-phantom
为容器内的占位,高度为总列表高度,用于形成滚动条
infinite-list
为列表项的渲染区域
可以计算出:
列表总高度:listHeight = listData.length*itemSize
可显示列表项数:visibleCount
数据起始索引:startIndex = Math.floor(scrollTop/itemSize)
数据结束索引:endIndex = startIndex + visibleCount
列表显示数据visibleData = listData.slice(startIndex,endIndex)
滚动后渲染区域对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式将渲染区域便宜到可是区域中
偏移量startOffset = this.startIndex * this.itemSize
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| <template> <div ref="list" class="infinite-list-container" @scroll="scrollEvent($event)"> <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div> <div class="infinite-list" :style="{ transform: getTransform }"> <div ref="items" class="infinite-list-item" v-for="item in visibleData" :key="item.id" :style="{ height: itemSize + 'px',lineHeight: itemSize + 'px' }" >{{ item.value }}</div> </div> </div> </template>
|
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63
| export default { name:'VirtualList', props: { listData:{ type:Array, default:()=>[] }, itemSize: { type: Number, default:200 } }, computed:{ listHeight(){ return this.listData.length * this.itemSize; }, visibleCount(){ return Math.ceil(this.screenHeight / this.itemSize) }, getTransform(){ return `translate3d(0,${this.startOffset}px,0)`; }, visibleData(){ return this.listData.slice(this.start, Math.min(this.end,this.listData.length)); } }, mounted() { this.screenHeight = this.$el.clientHeight; this.start = 0; this.end = this.start + this.visibleCount; }, data() { return { screenHeight:0, startOffset:0, start:0, end:null, }; }, methods: { scrollEvent() { let scrollTop = this.$refs.list.scrollTop; this.start = Math.floor(scrollTop / this.itemSize); this.end = this.start + this.visibleCount; this.startOffset = scrollTop - (scrollTop % this.itemSize); } } };
|
列表项动态高度
当列表中包含文本之类的可变内容时,会导致列表项的高度并不相同,在虚拟列表中应用动态高度的解决方案一般有三种:
1.对组件属性itemSize进行扩展,支持传递类型为数字,数组,函数
2.将列表项渲染到屏幕外,对其高度进行测量并缓存,然后再将其渲染到可视区域内
先渲染到屏幕外,再渲染到屏幕内,会导致渲染成本增加一倍,对于数百万用户在低端移动设备上使用的产品来说不切实际
3.以预估高度先行渲染,然后获取真实高度并缓存
使用第三种方法:positions用来存储列表项渲染后每一项高度以及位置信息:
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
| import { number } from "prop-types"
export type PositionType = { index: number, height: number, top: number, bottom: number }
const initPositionCache = ( estimatedItemSize: number =32, length: number = 0, ) => { let index = 0, positions = Array(length) while(index < length) { positions[index] = { index, height: estimatedItemSize, top: index * estimatedItemSize, bottom: (index++ +1) * estimatedItemSize } } return positions } export default initPositionCache
|
列表的总高度等于列表项实际中最后一项的地步距离列表顶部的位置
getListHeight.ts:
1 2 3 4 5 6
| import { PositionType } from "./initPositionCache" export const getListHeight = (positions: Array<PotionType>) => { let index = positions.length -1; return index < 0 ? 0 : positions[index].bottom }
|
更新缓存:在页面更新阶段,通过遍历列表每一项,获得实际内容撑起的高度height,和预估的高度oldHeight,如果两者存在差值,则更新该列表项的bottom和height,以及更新列表项后面每一项的top和bottom
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| export const updateItemSize = ( positions: Array<PositionType>, items: HTMLCollection, ) => { Array.from(items).forEach(item => { let index = Number(item.getAttribute('date-index')) let {height} = item.getBoundingClientRect() let oldHeight = positions[index].height let dValue = oldHeight - height if(dValue) { positions[index].bottom = positions[index].bottom - dValue positions[index].height = height
for(let k = index + 1; k < positions.length; k++) { positions[k].top = positions[k-1].bottom positions[k].bottom = positions[k].bottom - dValue } } })
}
|
滚动后获取列表的开始索引的方法修改为通过缓存获取:
1 2 3 4 5
| getStartIndex(scrollTop = 0){ let item = this.positions.find(i => i && i.bottom > scrollTop); return item.index; }
|
由于缓存数据是有顺序的,所以获取开始索引的方法可以考虑通过二分查找的方式降低检索次数:
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
| import { PositionType } from "./initPositionCache" export const binarySearch = (list: Array<PotionType>,value: number = 0) => { let start: number = 0 let end: number = list.length - 1 let tempIndex = null while(start <= end) { let midIndex = Math.floor((start + end)/2) let midValue = list[midIndex].bottom if(midValue === value){ return midIndex + 1 } else if(midValue < value) { start = midIndex+1 } else if(midValue > value) { if(tempIndex === null || tempIndex > midIndex) { tempIndex = midIndex; } end = midIndex -1 } } return tempIndex }
|
滚动过程中二分查找到可视区域的内容
1 2 3 4 5 6 7 8 9
| const onScroll = () => { requestAnimationFrame(() => { let { scrollTop } = getEl() let startIndex = binarySearch(positions,scrollTop) updateState({myvisibleCount,startIndex}) }) }
|
在每一次页面重新挂载后监听滚动事件,卸载后移除回调函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
| useLayoutEffect(()=>{ const el = getEl() el.addEventListener('scroll',onScroll,false) if(isFF)el.addEventListener('DOMMouseScroll',onScroll,false) else el.addEventListener('wheel',onScroll,false) return () => { if(el){ el.removeEventListener('scroll',onScroll,false) if(isFF)el.removeEventListener('DOMMouseScroll',onScroll,false) else el.removeEventListener('wheel',onScroll,false) } } },[])
|
滚动后将偏移量的获取方式进行变更:
1 2 3 4 5
| if(this.start >= 1){ this.startOffset = this.positions[this.start - 1].bottom }else{ this.startOffset = 0; }
|
在componetDidUpdate阶段更新缓存
1 2 3 4 5 6 7 8 9 10 11
| useEffect(() => { let nodes: HTMLCollection = items.current?.children if(!nodes.length) return updateItemSize(positions,nodes) setListHeight(getListHeight(positions)) setStartOffset(getStartOffset(startIndex,positions,aboveCount)) },[])
|
获取结束索引的函数:
1 2 3 4 5
| export const getEndIndex = (resource:Array<string>,startIndex: number,visibleCount: number) => { let resourceLength = resource.length let endIndex = startIndex + visibleCount return resourceLength > 0 ? Math.min(resourceLength,endIndex) : endIndex; }
|
当滚动过快时,会出现短暂的白屏现象,为了使页面平滑滚动,我们还需要再可见区域的上方和下方渲染额外的项目,在滚动时基于一些缓冲,所以将屏幕分为三个区域:
可视区域上方:above
可视区域:screen
可视区域下方:below
定义bufferScale,用于接收缓冲区数据和可视区数据的比例:
1 2 3 4 5 6
| const bufferScale = 1 const aboveCount = Math.min(startIndex,bufferScale * myvisibleCount) const blowCount = Math.min(resources.length - endIndex,bufferScale * myvisibleCount) let visibleData = resources.slice(startIndex - aboveCount,endIndex + blowCount)
|
获取总偏移量getStartOffset:
1 2 3 4 5 6 7 8 9
| export const getStartOffset = (startIndex: number,positions: Array<PositionType> = [],aboveCount: number) => { if(startIndex >=1 ) { let size = positions[startIndex].top - (positions[startIndex-aboveCount] ? positions[startIndex-aboveCount].top : 0) return positions[startIndex-1].bottom -size }else{ return 0 } }
|
完整源码:https://github.com/Coloey/july-design/tree/master/src/vitual-list
参考: