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

不固定的高度就会导致:
- 列表总高度:listHeight = listData.length * itemSize
- 偏移量的计算:startOffset = scrollTop - (scrollTop % itemSize)
- 数据的起始索引 startIndex = Math.floor(scrollTop / itemSize)
这些属性的计算不能再通过上面的方式来计算。因此我们会遇到这样的一些问题:
- 如何获取真实高度?
- 相关属性该如何计算?
- 列表渲染的项目有何改变?
解决思路:
如何获取真实高度?
- 如果能获得列表项高度数组,真实高度问题就很好解决。但在实际渲染之前是很难拿到每一项的真实高度的,所以我们采用预估一个高度渲染出真实 DOM,再根据 DOM 的实际情况去更新真实高度。
- 创建一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在渲染后根据 DOM 实际情况更新缓存列表。
相关的属性该如何计算?
- 显然以前的计算方式都无法使用了,因为那都是针对固定值设计的。
- 于是我们需要 根据缓存列表重写计算属性、滚动回调函数,例如列表总高度的计算可以使用缓存列表最后一项的定位字段的值。
列表渲染的项目有何改变?
- 因为用于渲染页面元素的数据是根据 开始/结束索引 在 数据列表 中筛选出来的,所以只要保证索引的正确计算,那么渲染方式是无需变化的。
- 对于开始索引,我们将原先的计算公式改为:在 缓存列表 中搜索第一个底部定位大于 列表垂直偏移量 的项并返回它的索引
- 对于结束索引,它是根据开始索引生成的,无需修改。
白屏问题
在第一版的实现中,我们仅渲染可视区域的元素。此时如果用户滚动过快,会出现白屏闪烁的现象。
之所以会有这个现象,是因为先加载出来的是白屏(没有渲染内容),然后迅速会被替换为表格内容,从而出现闪烁的现象。
并且这种现象在越低性能的浏览器上面表现得越明显。
解决思路:
为了让页面的滚动更加平滑,我们可以在原先列表结构的基础上加上缓冲区,也就是整个渲染区域由 可视区 + 缓冲区 共同组成,这样就给滚动回调和页面渲染留出了更多的时间。

这样设计后,缓冲区的数据会进入到可视区域,然后我们要做的就是更新缓冲区的数据。
代码片段:
const aboveCount = computed(() => {
// 缓冲区列表项个数的计算,其实就是可视区显示个数 * 缓冲比例
// 但是考虑到可能存在当前虚拟列表处于最顶端,所以需要和 startIndex 做一个比较,取最小值
return Math.min(startIndex.value, props.bufferScale * visibleCount.value);
});
const belowCount = computed(() => {
return Math.min(
props.listData.length - endIndex.value,
props.bufferScale * visibleCount.value
);
});
假设我们有如下场景:
- 总共有 100 项数据(props.listData.length = 100)
- 当前可视区域显示 10 项(visibleCount.value = 10)
- bufferScale 设置为 1
- 当前 startIndex.value = 20(表示当前可视区域从第 21 项开始显示)
- 当前 endIndex.value = 29(表示当前可视区域显示到第 30 项)
计算 aboveCount:
const aboveCount = Math.min(20, 1 * 10);
// 计算结果为 Math.min(20, 10) = 10
计算 belowCount
const belowCount = Math.min(100 - 30, 1 * 10);
// 计算结果为 Math.min(70, 10) = 10
因此最终上下的缓冲区的缓冲列表项目均为 10.
另外关于整个列表的渲染,之前是根据索引来计算的,现在就需要额外加入上下缓冲区大小重新计算,如下所示:
const visibleData = computed(() => {
let startIdx = startIndex.value - aboveCount.value;
let endIdx = endIndex.value + belowCount.value;
return props.listData.slice(startIdx, endIdx);
});
最后,因为多出了缓冲区域,所以偏移量也要根据缓冲区来重新进行计算,如下所示:
const setStartOffset = () => {
let startOffset;
// 检查当前可视区域的第一个可见项索引是否大于等于1(即当前显示的内容不在列表最开始的地方)
if (startIndex.value >= 1) {
// 计算当前可视区域第一项的顶部位置与考虑上方缓冲区后的有效偏移量
// positions[startIndex.value].top 是当前可视区域第一项的顶端位置
// positions[startIndex.value - aboveCount.value].top 是考虑上方缓冲区后,开始位置的顶端位置
// 如果上方缓冲区存在,则减去它的顶端位置;否则使用 0 作为初始偏移量
let size =
positions[startIndex.value].top -
(positions[startIndex.value - aboveCount.value]
? positions[startIndex.value - aboveCount.value].top
: 0);
// 计算 startOffset:用当前可视区域第一个项的前一项的底部位置,减去上面计算出的 size,
// 这个 size 表示的是在考虑缓冲区后需要额外平移的偏移量
startOffset = positions[startIndex.value - 1].bottom - size;
} else {
// 如果当前的 startIndex 为 0,表示列表显示从最开始的地方开始,没有偏移量
startOffset = 0;
}
// 设置内容容器的 transform 属性,使整个内容平移 startOffset 像素,以确保正确的项对齐在视口中
content.value.style.transform = `translate3d(0,${startOffset}px,0)`;
};
至于这个 startOffset 具体是怎么计算的,如下图所示:

setStartOffset 方法重写完毕后,整个白屏闪烁问题也就完美解决了。
滚动事件触发频率过高
上一版实现中,我们绑定的是 scroll 滚动事件,虽然效果实现了,但是 scroll 事件的触发频率非常高,每次用户一滚动就会触发,而每次触发都会执行 scroll 回调方法。
解决思路:
可以使用 IntersectionObserver 来替换监听 scroll 事件。
相比 scroll,IntersectionObserver 可以设置多个阈值来检测元素进入视口的不同程度,只在必要时才进行计算,没有性能上的浪费。并且监听回调也是异步触发的。
代码
<template>
<!-- 外层容器 -->
<div ref="list" class="infinite-list-container">
<!-- 该元素高度为总列表的高度,目的是为了形成滚动 -->
<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,
},
// 缓冲区域相对于可视区域的比例
bufferScale: {
type: Number,
default: 2,
},
});
// 引用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 aboveCount = computed(() => {
// 缓冲区列表项个数的计算,其实就是可视区显示个数 * 缓冲比例
// 但是考虑到可能存在当前虚拟列表处于最顶端,所以需要和 startIndex 做一个比较,取最小值
return Math.min(startIndex.value, props.bufferScale * visibleCount.value);
});
const belowCount = computed(() => {
return Math.min(
props.listData.length - endIndex.value,
props.bufferScale * visibleCount.value
);
});
// 列表显示数据
const visibleData = computed(() => {
// return props.listData.slice(startIndex.value, Math.min(endIndex.value, props.listData.length))
// 现在计算列表要渲染多少项,就需要加入缓冲区进行计算
let startIdx = startIndex.value - aboveCount.value;
let endIdx = endIndex.value + belowCount.value;
return props.listData.slice(
startIdx,
Math.min(endIdx, 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()
// }
const observer = ref(null);
// 创建 IntersectionObserver 实例
const createObserver = () => {
observer.value = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
// 说明当前这个列表项进入到了可视区域
let scrollTop = list.value.scrollTop;
startIndex.value = getStartIndex(scrollTop);
endIndex.value = startIndex.value + visibleCount.value;
setStartOffset();
}
});
},
{
root: list.value, // 设置观察的根元素为列表容器
rootMargin: "0px", // 设置根元素的边距
threshold: 0.1, // 设置阈值
}
);
};
// 观察列表项
const observerItems = () => {
// items就是一个一个列表项的引用
items.value.forEach((item) => {
observer.value.observe(item);
});
};
onMounted(() => {
// 获取可视区域高度
screenHeight.value = list.value.clientHeight;
startIndex.value = 0;
endIndex.value = startIndex.value + visibleCount.value;
// 在组件挂载的时候,初始化列表项的位置信息
initPostions();
// 在组件挂载的时候,创建 IntersectionObserver 实例
createObserver();
});
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)`
// 因为现在多了缓冲区,所以关于偏移量的计算也发生了变化
let startOffset; // 存储偏移量
if (startIndex.value >= 1) {
// 如果进入该分支,说明当前列表项不是第一个列表项,需要计算 startOffset
// 计算当前可视区域第一项的顶部位置与考虑上方缓冲区后的有效偏移量
// positions[startIndex.value].top 是当前可视区域第一项的顶端位置
// positions[startIndex.value - aboveCount.value].top 是考虑上方缓冲区后,开始位置的顶端位置
// 如果上方缓冲区存在,则减去它的顶端位置;否则使用 0 作为初始偏移量
let size =
positions[startIndex.value].top -
(positions[startIndex.value - aboveCount.value]
? positions[startIndex.value - aboveCount.value].top
: 0);
// 计算 startOffset:用当前可视区域第一个项的前一项的底部位置,减去上面计算出的 size,
// 这个 size 表示的是在考虑缓冲区后需要额外平移的偏移量
startOffset = positions[startIndex.value - 1].bottom - size;
} else {
// 如果进入该分支,说明当前列表项是第一个列表项,那么 startOffset 就是 0
startOffset = 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();
// 4. 观察列表项
observerItems();
});
});
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-