虚拟列表优化
遗留问题:
- 动态高度
- 白屏问题
- 滚动事件触发频率过高
动态高度
在实际应用中,列表项目里面可能包含一些可变内容,导致列表项高度并不相同。例如新浪微博:

不固定的高度就会导致:
- 列表总高度:listHeight = listData.length * itemSize
- 偏移量的计算:startOffset = scrollTop - (scrollTop % itemSize)
- 数据的起始索引 startIndex = Math.floor(scrollTop / itemSize)
这些属性的计算不能再通过上面的方式来计算。因此我们会遇到这样的一些问题:
- 如何获取真实高度?
- 相关属性该如何计算?
- 列表渲染的项目有何改变?
解决思路:
如何获取真实高度?
- 如果能获得列表项高度数组,真实高度问题就很好解决。但在实际渲染之前是很难拿到每一项的真实高度的,所以我们采用预估一个高度渲染出真实 DOM,再根据 DOM 的实际情况去更新真实高度。
- 创建一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在渲染后根据 DOM 实际情况更新缓存列表。
相关的属性该如何计算?
- 显然以前的计算方式都无法使用了,因为那都是针对固定值设计的。
- 于是我们需要 根据缓存列表重写计算属性、滚动回调函数,例如列表总高度的计算可以使用缓存列表最后一项的定位字段的值。
列表渲染的项目有何改变?
- 因为用于渲染页面元素的数据是根据 开始/结束索引 在 数据列表 中筛选出来的,所以只要保证索引的正确计算,那么渲染方式是无需变化的。
- 对于开始索引,我们将原先的计算公式改为:在 缓存列表 中搜索第一个底部定位大于 列表垂直偏移量 的项并返回它的索引
- 对于结束索引,它是根据开始索引生成的,无需修改。
代码
vue
<template>
<!-- 外层容器 -->
<div ref="list" class="infinite-list-container" @scroll="scrollHandler">
<!-- 该元素高度为总列表的高度,目的是为了形成滚动 -->
<div ref="listHeight" class="infinite-list-phantom"></div>
<!-- 该元素为可视区域,里面就是一个一个列表项 -->
<div ref="content" class="infinite-list">
<div
class="infinite-list-item"
ref="items"
v-for="item in visibleData"
:key="item.id"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, watch, onUpdated, nextTick } from "vue";
const props = defineProps({
listData: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 150,
},
// 预估高度
estimatedItemSize: {
type: Number,
required: true,
},
});
// 引用container元素
const list = ref(null);
// 可视区域高度
const screenHeight = ref(0);
// 开始索引
const startIndex = ref(0);
// 结束索引
const endIndex = ref(0);
// 初始的偏移量
// const startOffset = ref(0)
// 用于创建列表项元素的引用
const items = ref([]);
// 用于引用phantom元素
const listHeight = ref(null);
// 用于引用list元素
const content = ref(null);
// 缓存列表,用于存储列表项的位置信息
let positions = [];
// 用于初始化每个列表项的位置信息
const initPostions = () => {
positions = props.listData.map((_, index) => ({
index, // 列表项的下标
height: props.estimatedItemSize, // 列表项的高度,这里采用预估的高度
top: index * props.estimatedItemSize, // 列表项的顶部位置,根据下标和预估高度计算
bottom: (index + 1) * props.estimatedItemSize, // 列表项的底部位置,也是根据下标和预估高度计算
}));
};
// 列表总高度
// 现在因为列表项不定高,所以不能再采用这样的计算方式
// const listHeight = computed(() => props.listData.length * props.itemSize)
// 可显示的列表项数
const visibleCount = computed(() =>
Math.ceil(screenHeight.value / props.itemSize)
);
// 列表显示数据
const visibleData = computed(() =>
props.listData.slice(
startIndex.value,
Math.min(endIndex.value, props.listData.length)
)
);
// 向下位移的距离
// const getTransform = computed(() => `translate3d(0, ${startOffset.value}px, 0)`)
// const getStartIndex = (scrollTop) => {
// // 找到第一个底部位置大于滚动高度的列表项
// let item = positions.find((i) => i && i.bottom > scrollTop)
// return item.index
// }
// 关于查找 startIndex 的方法,可以使用二分查找法来进行优化
const binarySearch = (list, value) => {
let start = 0;
let end = list.length - 1;
let tempIndex = null;
while (start <= end) {
let midIndex = parseInt((start + end) / 2);
let midValue = list[midIndex].bottom;
if (midValue === value) {
return midIndex + 1;
} else if (midValue < value) {
start = midIndex + 1;
} else if (midValue > value) {
if (tempIndex === null || tempIndex > midIndex) {
tempIndex = midIndex;
}
end = end - 1;
}
}
return tempIndex;
};
const getStartIndex = (scrollTop) => {
return binarySearch(positions, scrollTop);
};
// 滚动对应的处理函数
const scrollHandler = () => {
// 这里要做的事情主要就是更新各项数据
let scrollTop = list.value.scrollTop;
// startIndex.value = Math.floor(scrollTop / props.itemSize)
startIndex.value = getStartIndex(scrollTop);
endIndex.value = startIndex.value + visibleCount.value;
// startOffset.value = scrollTop - (scrollTop % props.itemSize)
setStartOffset();
};
onMounted(() => {
// 获取可视区域高度
screenHeight.value = list.value.clientHeight;
startIndex.value = 0;
endIndex.value = startIndex.value + visibleCount.value;
// 在组件挂载的时候,初始化列表项的位置信息
initPostions();
});
const updateItemsSize = () => {
items.value.forEach((node, index) => {
// 获取列表项实际的高度
let height = node.getBoundingClientRect().height;
// 计算预估高度和真实高度的差值
let oldHeight = positions[index].height; // 拿到该项的预估高度
let dValue = oldHeight - height;
if (dValue) {
// 如果存在差值,那么就需要更新位置信息
positions[index].bottom -= dValue;
positions[index].height = height;
// 接下来需要更新后续所有列表项的位置
for (let i = index + 1; i < positions.length; i++) {
positions[i].top = positions[i - 1].bottom;
positions[i].bottom -= dValue;
}
}
});
};
// 更新偏移量
const setStartOffset = () => {
let startOffset =
startIndex.value >= 1 ? positions[startIndex.value - 1].bottom : 0;
content.value.style.transform = `translate3d(0, ${startOffset}px, 0)`;
};
onUpdated(() => {
// 这里之所以使用 nextTick,是为了确保DOM更新完毕后再去获取列表项的位置信息
nextTick(() => {
if (!items.value || !items.value.length) return;
// 1. 更新列表项的高度
updateItemsSize();
// 2. 更新虚拟列表的高度
listHeight.value.style.height =
positions[positions.length - 1].bottom + "px";
// 3. 更新列表的偏移量
setStartOffset();
});
});
watch(() => props.listData, initPostions);
</script>
<style scoped>
.infinite-list-container {
height: 100%;
overflow: auto;
position: relative;
-webkit-overflow-scrolling: touch;
}
.infinite-list-phantom {
position: absolute;
left: 0;
top: 0;
right: 0;
z-index: -1;
}
.infinite-list {
left: 0;
right: 0;
top: 0;
position: absolute;
text-align: center;
}
.infinite-list-item {
padding: 10px;
color: #555;
box-sizing: border-box;
border-bottom: 1px solid #999;
}
</style>
-EOF-