Appearance
虚拟列表
导航目录
前言
虚拟列表是一种优化长列表性能的技术,主要解决以下问题:
- 页面卡顿:当列表数据量较大时,过多的 DOM 元素会导致页面滚动卡顿
- 内存占用:大量 DOM 元素会增加浏览器内存占用
- 渲染性能:减少 DOM 操作次数,提高渲染效率
- 用户体验:保持流畅的滚动体验,即使在数据量大的情况下
实现思路(列表高度固定)
核心步骤
- 页面布局:将列表拆分为三个区域:视口区域、滚动条和渲染列表区域
- 高度计算:
- 视口区域高度 = 列表展示个数 × 每一项高度
- 滚动条高度 = 列表总个数 × 每一项高度
- 数据截取:
- 起始索引 = 视口滚动距离 / 每一项高度
- 结束索引 = 起始索引 + 列表展示个数
- 偏移计算:渲染列表区域的偏移量 = 起始索引 × 每一项高度
补充(列表高度不固定)
核心步骤
- 缓存机制:定义列表缓存当前元素的高度、top 距离和bottom 距离
- 高度更新:页面更新时,获取真实 DOM 高度,更新缓存数据,并重新计算滚动条高度(最后一个元素的 bottom 距离)
- 索引计算:视口滚动时,通过二分查找在缓存列表中计算起始索引,结束索引 = 起始索引 + 列表展示个数
- 偏移计算:渲染列表区域的偏移量 = 起始索引元素的 top 距离
核心实现代码
vue
<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) {
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>优势与应用场景
优势
- 性能优化:只渲染可视区域内的元素,大大减少 DOM 数量,提高页面性能
- 内存节省:减少 DOM 元素的创建和管理,降低内存占用
- 流畅滚动:避免因 DOM 过多导致的滚动卡顿,提供流畅的用户体验
- 灵活性:支持固定高度和动态高度的列表
- 可扩展性:通过插槽机制,支持自定义列表项内容
应用场景
- 长列表展示:如聊天记录、商品列表、新闻列表等
- 大数据量表格:需要展示大量数据的表格场景
- 无限滚动:结合分页加载,实现无限滚动效果
- 移动端应用:移动端设备性能有限,虚拟列表能显著提升体验
- 时间线:如日志、活动记录等时间线展示
注意事项
- 性能权衡:对于数据量较小的列表,虚拟列表可能带来不必要的复杂性
- 初始化成本:首次渲染时需要计算初始状态,可能有轻微的性能开销
- 滚动精度:在动态高度列表中,滚动位置的计算可能存在轻微误差
- 浏览器兼容性:依赖现代浏览器的 DOM API,如
getBoundingClientRect() - 样式处理:需要确保列表项的样式不会影响虚拟列表的布局计算