虚拟列表
虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染,也就是设置缓冲区,从而达到极高的渲染性能。如下图所示,未滚动的时候上缓冲区是没有的,虚拟列表有三种情况:
- 定高虚拟列表,每一项的高度固定,可以很容易计算
- 不定高虚拟列表,每一项的高度是不固定的,但是我们知道整个列表的高度数组
- 动态高度虚拟列表,每一项的高度在渲染时确认
对于所有虚拟列表而言,我们的实现考虑的都是如何计算出可视区域的节点索引范围,即[startIndex, endIndex],然后加上缓冲区的大小,将此范围内的节点渲染到容器中,我们最根本的目的是计算起始索引startIndex和终止索引endIndex。
简单理解
- 第一步:初始状态,屏幕的可见区域高度为500px,列表高度为50px,可以在屏幕最多只能看到10个列表项。首次渲染时,只需加载10条即可。
- 第二步:滚动事件触发。滚动发生,滚动条距顶部位置150px,可知在可见区域内的列表项为第4项至第13项。
总结:首屏加载时,只加载可视区域内需要的列表项。当滚动发生时,动态计算获得可视区域内的列表项,并将非可视区域内存在的列表项删除。
列表的高度固定
- 计算当前可视区域起始数据索引---startIndex
- 计算当前可视区域结束数据索引---endIndex
- 计算当前可视区域的结束,渲染到页面中
- 计算startIndex对应的数据在整个列表中的偏移位置startOffset并设置到列表上
<div class="infinite-list-container"> // 可视区域的容器
<div class="infinite-list-phantom"></div> // 容器内的占位,高度为总列表高度,用于形成滚动条
<div class="infinite-list"> // 列表项的渲染区域
<!-- item1-->
<!-- item2-->
<!-- ... -->
<!-- itemn-->
</div>
</div>
接着,监听infinite-list-container的scroll事件,获取滚动位置scrollTop
-
假定
可视区域高度固定,称之为screenHeight -
假定
列表每项高度固定,称之为itemSize -
假定
列表数据称之为listData -
假定
当前滚动位置称之为scrollTop则可推算出: -
列表总高度
listHeight= listData.length * itemSize -
可显示的列表项数
visibleCount= Math.ceil(screenHeight / itemSize) -
数据的起始索引
startIndex= Math.floor(scrollTop / itemSize) -
数据的结束索引
endIndex= startIndex + visibleCount -
列表显示数据为
visibleData= listData.slice(startIndex,endIndex)
当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。
偏移量startOffset = scrollTop - (scrollTop % itemSize);
代码如下:
<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>
<script>
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);
}
}
};
</script>
<style scoped>
.infinite-list-container {
height: 100%;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
列表高度不固定
动态高度虚拟列表
原理:先设置一个默认的高度,并记录到缓存数据结构中,我们去监听节点的高度变换,在发生变化后重新计算缓存数据,然后更新列表。
定义默认高度
定义默认高度,在后面渲染时监听节点变化会更新缓存数据。
// 获取每一项的元数据
const getItemMetaData = (index: number) => {
const {measuredDataMap, LastMeasuredItemIndex} = measuredData;
// 如果index大于当前记录的最大值,挨个计算到index去,用top+height一个一个计算
if (index > LastMeasuredItemIndex) {
let topOffset =
LastMeasuredItemIndex >= 0
? measuredDataMap[LastMeasuredItemIndex].topOffset +
measuredDataMap[LastMeasuredItemIndex].height : 0;
for (let i = LastMeasuredItemIndex + 1; i <= index; i++) {
// 这里用的都是默认高度,因为后面渲染时会更新缓存数据
measuredDataMap[i] = {height: 50, topOffset};
topOffset += 50;
}
measuredData.LastMeasuredItemIndex = index;
}
return measuredDataMap[index];
};
动态获取任意高度的项目
项目的高度都是通过getOneChildItem在渲染的时候动态获取到的。
// 获取任意高度的item
const getRandomHeightItem = (() => {
let items: ReactNode[] | null = null;
return () => {
if (items) return items;
items = [];
const itemCount = 1000;
for (let i = 0; i < itemCount; i++) {
const height = 30 + Math.floor(Math.random() * 30);
const style = {
height,
width: '100%',
};
items.push(<ChildItem key={i} childIndex={i} itemStyle={style} />);
}
return items;
};
})();
// 动态获取子集
const DynamicChildItem = (options: DynamicChildItemProps) => {
const {itemStyle, getChildItem, onSizeChange, childIndex} = options;
const childRef = useRef(null);
const resizeObserverRef = useRef<ResizeObserver | null>(null);
useEffect(() => {
const domNode = childRef.current;
if (domNode) {
if (!resizeObserverRef.current) {
resizeObserverRef.current = new ResizeObserver(() => {
onSizeChange(childIndex, domNode);
});
}
resizeObserverRef.current.observe(domNode);
}
return () => {
if (resizeObserverRef.current && domNode) {
resizeObserverRef.current.unobserve(domNode);
}
};
}, [childIndex, onSizeChange]);
return (
<div ref={childRef} style={itemStyle}>
{getChildItem(childIndex)}
</div>
);
};
const getOneChildItem = (index: number) => getRandomHeightItem()[index];
调用
// 根据当前显示范围,插入节点,节点通过getOneChildItem动态获取
const getCurShowChild = (scrollTop: number) => {
const items = [];
const {bufferStartIndex, bufferEndIndex} = getChildShowRange(scrollTop);
for (let i = bufferStartIndex; i <= bufferEndIndex; i++) {
const item = getItemMetaData(i);
const itemStyle: CSSProperties = {
position: 'absolute',
height: item.height,
width: '100%',
top: item.topOffset,
};
items.push(
<DynamicChildItem
key={`${i}${item.topOffset}`}
childIndex={i}
getChildItem={getOneChildItem}
onSizeChange={sizeChangeHandle}
itemStyle={itemStyle}
/>,
);
}
return items;
};
监听节点宽高变化
通过监听节点的尺寸变化去更新缓存数据,然后触发虚拟列表重新渲染,实现动态高度渲染
useEffect(() => {
const domNode = childRef.current;
if (domNode) {
if (!resizeObserverRef.current) {
resizeObserverRef.current = new ResizeObserver(() => {
onSizeChange(childIndex, domNode);
});
}
resizeObserverRef.current.observe(domNode);
}
return () => {
if (resizeObserverRef.current && domNode) {
resizeObserverRef.current.unobserve(domNode);
}
};
}, [childIndex, onSizeChange]);
// 监听节点尺寸变化,更新measuredDataMap,触发重新渲染
const sizeChangeHandle = (index: number, domNode: HTMLDivElement) => {
const height = (domNode.children[0] as HTMLDivElement).offsetHeight;
const {measuredDataMap, LastMeasuredItemIndex} = measuredData;
measuredDataMap[index].height = height;
let offset = 0;
// 重新计算偏移值
for (let i = 0; i <= LastMeasuredItemIndex; i++) {
measuredDataMap[i].topOffset = offset;
offset += measuredDataMap[i].height;
}
domNode.style.height = height + 'px';
// 触发列表的一次更新
setNeedUpdate(true);
};