什么是虚拟列表?
虚拟列表(Virtual List)本质就是仅将需要显示在视窗中的列表节点挂载到 DOM,以达到「减少一次性加载节点数量」和「减少滚动容器内总挂载节点数量」的目的。
通过「单个元素高度」计算当前列表全部加载时的高度作为「滚动容器」的「可滚动高度」,按该「可滚动高度」撑开「滚动容器」。并根据「当前滚动高度」,在「可视区域」内按需加载列表元素。
为什么需要虚拟列表?
在进行前端业务开发时,很容易遇到需要加载巨大列表的场景。比如微博的信息流、微信的朋友圈和直播平台的聊天框等,这些列表通常具有两个显著的特点:
- 不能分页;
- 只要用户愿意就可以无限地滚动下去。
在这种场景下,如果直接加载一个数量级很大的列表,会造成页面假死,使用传统的上拉分页加载模式或者 window.requestAnimationFrame空闲加载模式可以在一定程度上缓解这种情况,但是在加载到一定量级的页面时,会因为页面同时存在大量的 DOM 元素而出现过渡占用内存、页面卡顿等性能问题,带来糟糕的用户体验。因此必须对这种业务场景做相应的加载优化,只加载需要显示的元素是这种情况的唯一解,「虚拟列表」的概念应运而生。
相关概念
- 单个元素高度:列表内每个独立元素的高度,它可以是固定的或者是动态的。
- 滚动容器:意指挂载列表元素的 DOM 对象,它可以是自定义的元素或者
window
对象(默认)。 - 可滚动高度:滚动容器可滚动的纵向高度。当滚动容器的高度(宽度),小于它的子元素所占的总高度(宽度)且该滚动容器的
overflow
不为hidden
时,此时滚动容器的scrollHeight
为可滚动高度。 - 可视区域:滚动容器的视觉可见区域。如果容器元素是
window
对象,可视区域就是浏览器的视口大小(即视觉视口);如果容器元素是某个 ul 元素,其高度是 300,右侧有纵向滚动条可以滚动,那么视觉可见的区域就是可视区域,也即是该滚动容器的offsetHeight
。 - 当前滚动高度:与平常的滚动高度概念一致。虽然虚拟列表仅加载需要显示在可视区域内的元素,但是为了维持与常规列表一致的滚动体验,必须通过监听当前滚动高度来动态更新需要显示的元素。
实现逻辑
实现「虚拟列表」可以简单理解为就是在列表发生滚动时,改变「可视区域」内的渲染元素。大概的文字逻辑步骤如下:
- 根据单个元素高度计算出滚动容器的可滚动高度,并撑开滚动容器;
- 根据可视区域计算总挂载元素数量;
- 根据可视区域和总挂载元素数量计算头挂载元素(初始为 0)和尾挂载元素;
- 当发生滚动时,根据滚动差值和滚动方向,重新计算头挂载元素和尾挂载元素。
固定高度的虚拟列表
<div class="infinite-list-container">
<div class="infinite-list-phantom"></div>
<div class="infinite-list">
<!-- item-1 -->
<!-- item-2 -->
<!-- ...... -->
<!-- item-n -->
</div>
</div>
infinite-list-container
为可视区域
的容器infinite-list-phantom
为容器内的占位,高度为总列表高度,用于形成滚动条infinite-list
为列表项的渲染区域
接着,监听infinite-list-container
的scroll
事件,获取滚动位置scrollTop
- 假定
可视区域
高度固定,称之为screenHeight
- 假定
列表每项
高度固定,称之为itemSize
- 假定
列表数据
称之为listData
- 假定
当前滚动位置
称之为scrollTop
则可推算出:
- 列表总高度
listHeight
= listData.length * itemSize - 可显示的列表项数
visibleCount
= Math.ceil(screenHeight / itemSize) - 数据的起始索引
startIndex
= Math.floor(scrollTop / itemSize) - 数据的结束索引
endIndex
= startIndex + visibleCount - 列表显示数据为
visibleData
= listData.slice(startIndex,endIndex)
当滚动后,由于渲染区域
相对于可视区域
已经发生了偏移,此时我需要获取一个偏移量startOffset
,通过样式控制将渲染区域
偏移至可视区域
中。
- 偏移量
startOffset
= scrollTop - (scrollTop % itemSize);

代码实现
<template>
<section class="infinite-list-container" ref="instance" @scroll="handleScroll">
<div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
<div class="infinite-list" :style="{ transform: getTransform }">
<div ref="items" class="infinite-list-item" v-for="(item, index) in visibleData" :key="index" :style="{ height: ITEMSIZE + 'px', lineHeight: ITEMSIZE + 'px' }">{{ item }}</div>
</div>
</section>
</template>
<script lang="ts" setup>
import { start } from 'repl';
import { defineComponent, onMounted, ref, computed, getCurrentInstance, ComponentPublicInstance, VNode } from 'vue';
const ITEMSIZE = 30;
const instance = ref<HTMLElement>();
const listData = Array.from({ length: 1000000 }).map(() => Math.floor(Math.random() * 10000));
const visibleData = ref<number[]>([]);
// 列表总高度
const listHeight = listData.length * ITEMSIZE;
// 可视区高度
const screenHeight = ref(0);
// 可显示的列表数
const visibleCount = computed(() => {
return Math.ceil(screenHeight.value / ITEMSIZE);
});
// 偏移量对应的style
const getTransform = computed(() => {
return `translate3d(0, ${startOffset.value}px, 0)`;
});
const startOffset = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);
const handleScroll = () => {
let scrollTop = instance.value!.scrollTop;
startIndex.value = Math.floor(scrollTop / ITEMSIZE);
endIndex.value = startIndex.value + visibleCount.value;
visibleData.value = listData.slice(startIndex.value, endIndex.value);
startOffset.value = scrollTop - (scrollTop % ITEMSIZE);
};
onMounted(() => {
screenHeight.value = instance.value!.clientHeight;
endIndex.value = startIndex.value + visibleCount.value;
handleScroll()
});
</script>
<style scoped>
.infinite-list-container {
position: relative;
height: 400px;
overflow-x: hidden;
overflow-y: auto;
border: 1px solid skyblue;
}
.infinite-list-phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
}
</style>
动态高度的虚拟列表
在虚拟列表中应用动态高度的解决方案一般有如下三种:
1.对组件属性
itemSize
进行扩展,支持传递类型为数字
、数组
、函数
- 可以是一个固定值,如 100,此时列表项是固高的
- 可以是一个包含所有列表项高度的数据,如 [50, 20, 100, 80, ...]
- 可以是一个根据列表项索引返回其高度的函数:(index: number): number
这种方式虽然有比较好的灵活度,但仅适用于可以预先知道或可以通过计算得知列表项高度的情况,依然无法解决列表项高度由内容撑开的情况。
2.将列表项
渲染到屏幕外
,对其高度进行测量并缓存,然后再将其渲染至可视区域内。
由于预先渲染至屏幕外,再渲染至屏幕内,这导致渲染成本增加一倍,这对于数百万用户在低端移动设备上使用的产品来说是不切实际的。
3.以
预估高度
先行渲染,然后获取真实高度并缓存。
选择这个方案实现,可以避免前两种方案的不足。
面向未来
在前文中我们使用监听scroll事件
的方式来触发可视区域中数据的更新,当滚动发生后,scroll事件会频繁触发,很多时候会造成重复计算
的问题,从性能上来说无疑存在浪费的情况。
可以使用IntersectionObserver替换监听scroll事件,IntersectionObserver
可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新,并且IntersectionObserver
的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低。
遗留问题
我们虽然实现了根据列表项动态高度下的虚拟列表,但如果列表项中包含图片,并且列表高度由图片撑开,由于图片会发送网络请求,此时无法保证我们在获取列表项真实高度时图片是否已经加载完成,从而造成计算不准确的情况。
这种情况下,如果我们能监听列表项的大小变化就能获取其真正的高度了。我们可以使用ResizeObserver来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。