Appearance
虚拟列表
前言
- 移动端列表数据量比较大的时,滚动页面由于DOM太多,会造成页面卡顿,影响用户体验
实现思路(列表高度固定)
- 1、页面布局的时候,将列表拆分成三个区域、视口区域、滚动条,渲染列表区域
- 2、首先计算视口区域的高度(列表展示的个数每一项的高度),滚动条的高度(列表总个数每一项的高度)
- 3、计算数据截取的范围,起始索引的位置=(视口滚动的距离/每一项的高度),结束的索引的位置=起始索引+列表展示的个数
- 4、计算渲染列表区域的偏移量,起始索引的位置*每一项的高度
补充(列表高度不固定)
- 1、当列表的高度不固定时,首先会定义一个列表用来缓存当前元素的高度,top、bottom距离
- 2、页面在更新的时候,得到列表真实的DOM,更新缓存的元素的高度,top、bottom距离,最后重新计算滚动条的高度(最后一个元素的bottom距离)
- 3、视口区域在滚动的时候,通过滚动的距离,利用二分查找去缓存列表中查找算出起始索引的位置,结束的索引的位置=起始索引+列表展示的个数
- 4、最后计算渲染列表区域的偏移量,结束起始索引元素的top距离
html
<script setup lang="ts">
import { computed, nextTick, onMounted, onUpdated, ref } from "vue";
interface IDatas {
id: number | string;
[propname: string]: any;
}
type TypePosition = {
index: number;
height: number;
top: number;
bottom: number;
}
const props = defineProps<{
datas: IDatas[]; // 列表元素
size: number; // 一个列表元素高度
remain: number; // 展示几个列表元素
variable?: boolean; // 列表元素高度不固定
}>();
const startIndex = ref<number>(0);
const endIndex = ref<number>(props.remain);
const offset = ref<number>(0);
const viewport = ref<HTMLElement>();
const scrollBar = ref<HTMLElement>();
const scrollElements = ref<HTMLElement[]>([]);
let positions: TypePosition[] = []
// 缓存当前元素的高度,top、bottom
// DOM渲染后,更新实际的高度,top、bottom
const cacheList = () => {
positions = props.datas.map((_, index) => ({
index,
height: props.size,
top: index * props.size,
bottom: (index + 1) * props.size
}))
}
const init = () => {
if (props.variable) {
cacheList()
}
}
init()
const getStartIndex = (value: number): number => {
let start = 0;
let end = positions.length;
let temp = 0;
while (start < end) {
let middleIndex = parseInt(String((start + end) / 2));
let middleValue = positions[middleIndex].bottom;
if (value == middleValue) {
return middleIndex + 1;
} else if (middleValue < value) {
start = middleIndex + 1;
} else if (middleValue > value) {
if (temp == 0 || temp > middleIndex) {
temp = middleIndex;
}
end = middleIndex - 1;
}
}
return temp;
}
const handleScroll = (time: number) => {
let timer: any;
return () => {
clearTimeout(timer);
timer = setTimeout(() => {
const scrollTop = viewport.value?.scrollTop || 0;
if (props.variable) {
startIndex.value = getStartIndex(scrollTop);
endIndex.value = startIndex.value + props.remain;
// 超出预留区域才需要偏移
offset.value = positions[startIndex.value - preIndex.value].top;
} else {
startIndex.value = Math.floor(scrollTop / props.size);
endIndex.value = startIndex.value + props.remain;
// 超出预留区域才需要偏移
offset.value = startIndex.value * props.size - preIndex.value * props.size;
}
}, time);
};
};
const debounce = handleScroll(0);
onMounted(() => {
nextTick(() => {
if (viewport.value && scrollBar.value && scrollBar.value) {
viewport.value.style.height = `${props.size * props.remain}px`; // 视图高度
scrollBar.value.style.height = `${props.size * props.datas.length}px`; // 滚动条的高度
}
});
});
onUpdated(() => {
nextTick(() => {
scrollElements.value.forEach((element) => {
const { height } = element.getBoundingClientRect()
const index = Number(element.getAttribute('vid'))
let oldHeight = positions[index].height;
let gapHeight = oldHeight - height;
if (gapHeight) {
positions[index].height = height;
positions[index].bottom = positions[index].bottom - gapHeight;
for (let i = index + 1; i < positions.length; i++) {
positions[i].top = positions[i - 1].bottom;
positions[i].bottom = positions[i].bottom - gapHeight;
}
}
})
// 动态计算滚动条高度
if (scrollBar.value) {
scrollBar.value.style.height = positions[positions.length - 1].bottom + 'px';
}
})
})
// 预渲染区域防止白屏
const preIndex = computed(() => {
return Math.min(startIndex.value, props.remain);
});
// 预渲染区域
const nextIndex = computed(() => {
return Math.min(props.datas.length - endIndex.value, props.remain);
});
// 格式化数据,用于元素不定高时,查找更新真实的DOM信息
const formatData = computed(() => {
return props.datas.map((item, index) => ({ ...item, index }))
});
// 实例渲染的列表
const visibleList = computed(() => {
return formatData.value.slice(
startIndex.value - preIndex.value,
endIndex.value + nextIndex.value
);
});
</script>
<template>
<div class="viewport" ref="viewport" @scroll="debounce">
<div class="scroll-bar" ref="scrollBar"></div>
<div class="scroll-list" :style="{ transform: `translate3d(0,${offset}px,0)` }">
<div v-for="_ in visibleList" :key="_.id" :vid="_.id" ref="scrollElements">
<slot :_="(_ as IDatas)"></slot>
</div>
</div>
</div>
</template>
<style scoped>
.viewport {
overflow-y: scroll;
position: relative;
}
.scroll-list {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
</style>