什么是虚拟滚动列表
虚拟滚动列表是一种优化长列表渲染性能的技术,通过只渲染可视区域内的列表项,减少DOM的渲染数量,从而提高页面滚动的流畅性。
核心原理
在滚动时,只渲染可视区域内的列表项,而不是一次性渲染所有列表项。通过计算可视区域的起始索引和结束索引,动态渲染该范围内的列表项。当用户滚动时,根据滚动位置动态更新可视区域内的列表项,从而实现虚拟滚动的效果。
图例:
实现思路
为了实现虚拟滚动列表,需要设计三个盒子(可视区盒子、列表容器、列表项容器)
- 可视区盒子,控制列表展示的区域,超出滚动;
- 列表容器,包裹所有列表项的盒子,高度为真实列表的高度;
- 列表项容器,每个列表项外的盒子,用于动态定位展示列表项;
解释:监听1中盒子的滚动事件,在滚动的时候通过事件对象中的scrollTop动态计算对应展示区列表项的开始索引和结束索引,通过列表项的高度计算出对应列表项的偏移量,更新展示区域的列表项;
代码实现
这里需要计算所有列表项外包裹的盒子的高度以及每一项列表偏移量,但是由于我们要渲染的列表项每一项的高度都是不等长的,所以我们只有在对应列表项DOM渲染完成之后才知道每一项的真实高度,然后我们需要维护一个所有列表项对应的高度和偏移量的map数据,用于更新列表,每次在真实的列表项挂在之后动态去更新这个map数据,再以此为模板动态更新包裹的盒子的高度;
list-item容器代码
<!-- list-item.vue -->
<template>
<div :style="style" ref="domRef">
<slot name="slot-scope" :data="data"></slot>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import ResizeObserver from 'resize-observer-polyfill';
// ResizeObserver兼容低版本浏览器
if (window.ResizeObserver === undefined) {
window.ResizeObserver = ResizeObserver;
}
const emit = defineEmits(['onSizeChange']);
const props = defineProps({
style: {
type: Object,
default: () => { }
},
data: {
type: Object,
default: () => { }
},
index: {
type: Number,
default: 0
}
})
const domRef = ref(null);
const resizeObserver = null;
onMounted(() => {
const domNode = domRef.value.children[0];
emit("onSizeChange", props.index, domNode);
const resizeObserver = new ResizeObserver(() => {
emit("onSizeChange", props.index, domNode);
});
resizeObserver.observe(domNode);
})
onUnmounted(() => {
if (resizeObserver) {
resizeObserver?.unobserve(domRef.value.children[0]);
}
})
</script>
此处使用ResizeObserver
这个api来监听列表项dom尺寸的改变,用于更新我们维护的列表项map,但是这个api在低版本浏览器会有兼容性问题,导致白屏报错,可安装resize-observer-polyfill兼容低版本浏览器;
list容器代码
<template>
<div
class="virtual-wrap"
:class="{ hideScrollBar: isHideScrollBar }"
ref="virtualWrap"
:style="{
width: width,
height: height,
}"
@scroll="scrollHandle"
>
<div class="virtual-content" :style="{height: totalEstimatedHeight +'px'}">
<list-item v-for="(item,index) in showItemList" :key="item.dataIndex+index" :index="item.dataIndex" :data="item.data" :style="item.style"
@onSizeChange="sizeChangeHandle">
<template #slot-scope="slotProps">
<slot name="slot-scope" :slotProps="slotProps"></slot>
</template>
</list-item>
</div>
</div>
</template>
<script setup>
import ListItem from './list-item.vue';
import { ref, onMounted,watch, nextTick } from 'vue'
const props = defineProps({
isHideScrollBar: {
type: Boolean,
default: false
},
height: {
default: 100,
type: Number
},
width: {
default: 100,
type: Number
},
itemEstimatedSize: {
default: 50,
type: Number
},
itemCount: {
default: 0,
type: Number
},
data: {
default: ()=>[],
type: Array
},
buffCount:{
default: 4,
type: Number
}
})
const virtualWrap = ref(null);
const showItemList = ref([]);
const totalEstimatedHeight=ref(0)
const scrollOffset = ref(0)
watch(props.data,()=>{
getCurrentChildren()
})
const sizeChangeHandle = (index, domNode) => {
const height = domNode.offsetHeight;
const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
const itemMetaData = measuredDataMap[index];
itemMetaData.size = height;
let offset = 0;
for (let i = 0; i <= lastMeasuredItemIndex; i++) {
const itemData = measuredDataMap[i];
itemData.offset = offset;
offset += itemData.size;
}
}
// 元数据
const measuredData = {
measuredDataMap: {},
lastMeasuredItemIndex: -1,
};
const getCurrentChildren = () => {
//重新计算高度
estimatedHeight(props.itemEstimatedSize,props.itemCount)
const [startIndex, endIndex] = getRangeToRender(props, scrollOffset.value)
const items = [];
for (let i = startIndex; i <= endIndex; i++) {
const item = getItemMetaData(i);
const itemStyle = {
position: 'absolute',
height: item.size + 'px',
width: '100%',
top: item.offset + 'px',
};
items.push({
style: itemStyle,
data: props.data[i],
dataIndex:i
});
}
showItemList.value = items;
}
const getRangeToRender = (props, scrollOffset) => {
const { itemCount } = props;
const startIndex = getStartIndex(props, scrollOffset);
const endIndex = getEndIndex(props, startIndex + props.buffCount);
return [
Math.max(0, startIndex -1 - props.buffCount),
Math.min(itemCount - 1, endIndex ),
];
};
const getStartIndex = (props, scrollOffset) => {
const { itemCount } = props;
let index = 0;
while (true) {
const currentOffset = getItemMetaData(index).offset;
if (currentOffset >= scrollOffset) return index;
if (index >= itemCount) return itemCount;
index++
}
}
const getItemMetaData = (index) => {
const { itemEstimatedSize = 50 } = props;
const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
// 如果当前索引比已记录的索引要大,说明要计算当前索引的项的size和offset
if (index > lastMeasuredItemIndex) {
let offset = 0;
// 计算当前能计算出来的最大offset值
if (lastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
offset += lastMeasuredItem.offset + lastMeasuredItem.size;
}
// 计算直到index为止,所有未计算过的项
for (let i = lastMeasuredItemIndex + 1; i <= index; i++) {
const currentItemSize = itemEstimatedSize;
measuredDataMap[i] = { size: Number(currentItemSize), offset };
offset += currentItemSize;
}
// 更新已计算的项的索引值
// measuredData.lastMeasuredItemIndex = index;
}
return measuredDataMap[index];
};
const getEndIndex = (props, startIndex) => {
const { height, itemCount } = props;
// 获取可视区内开始的项
const startItem = getItemMetaData(startIndex);
// 可视区内最大的offset值
const maxOffset = Number(startItem.offset) + Number(height);
// 开始项的下一项的offset,之后不断累加此offset,知道等于或超过最大offset,就是找到结束索引了
let offset = Number(startItem.offset) + startItem.size;
// 结束索引
let endIndex = startIndex;
// 累加offset
while (offset <= maxOffset && endIndex < (itemCount - 1)) {
endIndex++;
const currentItem = getItemMetaData(endIndex);
offset += currentItem.size;
}
// 更新已计算的项的索引值
measuredData.lastMeasuredItemIndex = endIndex;
return endIndex;
};
const estimatedHeight = (defaultEstimatedItemSize = 50, itemCount) => {
let measuredHeight = 0;
const { measuredDataMap, lastMeasuredItemIndex } = measuredData;
// 计算已经获取过真实高度的项的高度之和
if (lastMeasuredItemIndex >= 0) {
const lastMeasuredItem = measuredDataMap[lastMeasuredItemIndex];
measuredHeight = lastMeasuredItem.offset + lastMeasuredItem.size;
}
// 未计算过真实高度的项数
const unMeasuredItemsCount = itemCount - measuredData.lastMeasuredItemIndex - 1;
// 预测总高度
totalEstimatedHeight.value = measuredHeight + unMeasuredItemsCount * defaultEstimatedItemSize;
}
//列表滚动处理
const scrollHandle = (event) => {
const { scrollTop } = event.currentTarget;
scrollOffset.value = scrollTop;
getCurrentChildren();
}
onMounted(() => {
nextTick(() => {
getCurrentChildren();
})
})
</script>
<style>
.hideScrollBar::-webkit-scrollbar {
width: 0;
}
.virtual-wrap {
position: relative;
overflow: auto;
}
.virtual-content {
position: relative;
overflow: auto;
}
</style>
list组件参数
-
props
:定义组件的属性,包括是否隐藏滚动条、容器高度、宽度、每项预估高度、总项数、数据列表和缓冲区大小。 -
ref
:定义了一些响应式变量,如virtualWrap
、showItemList
、totalEstimatedHeight
和scrollOffset
。 -
watch
:监听props.data
的变化,当数据变化时重新计算可视区域内的项。 -
sizeChangeHandle
:处理列表项大小变化的事件,更新元数据。 -
measuredData
:存储已测量项的元数据,包括大小和偏移量。 -
getCurrentChildren
:根据滚动位置计算当前需要渲染的项,并更新showItemList
。 -
getRangeToRender
:计算需要渲染的项的起始和结束索引。 -
getStartIndex
:根据滚动位置计算可视区域开始的项的索引。 -
getItemMetaData
:获取指定索引项的元数据,如果未计算过则进行计算。 -
getEndIndex
:根据起始索引和缓冲区大小计算可视区域结束的项的索引。 -
estimatedHeight
:计算总高度,包括已测量项和未测量项。 -
scrollHandle
:处理滚动事件,更新滚动位置并重新计算可视区域内的项。 -
onMounted
:组件挂载后,初始化可视区域内的项。
应用实现
<template>
<div style="width: 500px; height: 300px">
<List
:data="dataList"
:itemCount="dataList.length"
:height="500"
:width="300"
:isHideScrollBar="false"
@onSizeChange="handleSizeChange"
>
<template #slot-scope="{ slotProps }">
<!-- 这里定义每个 item 的样式和内容 -->
<div class="item-content">
{{ slotProps.data }}
</div>
</template>
</List>
</div>
</template>
<script setup>
import List from '@/components/List.vue'
import { ref } from 'vue'
const dataList = ref([...Array(100).keys()].map((i) => `Item ${i}`))
const handleSizeChange = (index, domNode) => {
// 可以在这里处理列表项尺寸变化的逻辑
console.log(`Item ${index} size changed`, domNode)
}
</script>
<style>
.item-content {
padding: 10px;
border-bottom: 1px solid #ddd;
background-color: #f9f9f9;
}
</style>