0%

实现虚拟列表组件

当遇到不能用分页方式来加载列表数据的业务情况,可以使用长列表,但是同时加载大量数据时性能消耗大,因此可以使用虚拟列表,只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,从而达到较高的渲染性能

实现:

虚拟列表的实现,就是在首屏加载时,只加载可视区域内需要的列表项,当滚动发生时,动态通过计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除

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">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</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)
    },
    //偏移量对应的style
    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
//因为用bottom 所有查找到的数据索引为下一条
if(midValue === value){
return midIndex + 1
}
else if(midValue < value) {
start = midIndex+1
}
else if(midValue > value) {
//tempIndex存放最靠值为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)
//startIndex变更,更新endIndex,ListHeight
updateState({myvisibleCount,startIndex})
})
}

在每一次页面重新挂载后监听滚动事件,卸载后移除回调函数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
//useLayoutEffect相当于componentDidMount,在dom更新后,页面渲染前执行,避免出现闪烁现象
useLayoutEffect(()=>{
const el = getEl()
el.addEventListener('scroll',onScroll,false)
//火狐浏览器是DOMMouseScroll
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
//在componentDidUpdate阶段更新缓存,useEffect渲染到页面后异步执行
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)
//如果上一个数据条目滚动到一半,则要获取的是上一个数据条目的bottom值即为startOffset
return positions[startIndex-1].bottom -size
}else{
return 0
}
}

完整源码:https://github.com/Coloey/july-design/tree/master/src/vitual-list

参考: