虚拟列表
面试题:一次性给你 10000 条数据,前端怎么渲染到页面上?
长列表常见的 3 种处理方式:
- 懒加载
- 时间分片
- 虚拟列表
懒加载
懒加载的原理在于:只有视口内的内容会被加载,其他内容在用户滚动到视口时才会被加载。这可以显著减少初次加载的时间,提高页面响应速度。这样的好处在于:
- 节省带宽
- 提升用户体验
懒加载的缺点:懒加载只能应对数据不是太多的情况。如果列表项有几万甚至几十万项,最终会有对应数量的 DOM 存在于页面上,严重降低页面性能。
时间分片
时间分片的本质是通过 requestAnimationFrame,由浏览器来决定回调函数的执行时机。大量的数据会被分多次渲染,每次渲染对应一个片段。在每个片段中处理定量的数据后,会将主线程还给浏览器,从而实现快速呈现页面内容给用户。
时间分片的缺点:
- 效率低
- 不直观
- 性能差
总结:无论是懒加载还是时间分片,最终都是将完整数量的列表项渲染出来,这在面对列表项非常非常多的时候,页面性能是比较低的。
虚拟列表
原理:设置一个可视区域,然后用户在滚动列表的时候,本质上是动态修改可视区域里面的内容。
例如,一开始渲染前面 5 个项目

之后用户进行滚动,就会动态的修改可视区域里面的内容,如下图所示:

也就是说,始终渲染的只有可视区的那 5 个项目,这样能够极大的保障页面性能。
实现:实现定高的虚拟列表,这里所指的定高是说列表项的每一项高度相同
涉及到的信息:
- 可视区域起始数据索引(startIndex)
- 可视区域结束数据索引(endIndex)
- 可视区域的数据
- 整个列表中的偏移位置 startOffset
如下图所示:

接下来整个虚拟列表的设计如下:
<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,因为回头需要设置 list 向下位移的距离
- 假定可视区域高度固定,称之为 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)
当发生滚动后,由于渲染区域相对于可视区域发生了偏移,因此我们需要计算出这个偏移量,然后使用 transform 重新偏移回可视区域。
偏移量的计算:startOffset = scrollTop - (scrollTop % itemSize)
思考 🤔:为什么要减去 scrollTop % itemSize ?
答案:之所以要减去 scrollTop % itemSize,是为了将 scrollTop 调整到一个与 itemSize 对齐的位置,避免显示不完整的列表项。

实战
实战演练:实现定高的虚拟列表
定高的虚拟列表存在一些问题,或者说可以优化的地方:
- 动态高度
- 白屏现象
- 滚动事件触发频率过高
VituralList.vue
<template>
<div class="infinite-list-container" ref="listRef" @scroll="onScroll">
<!-- 该元素高度为总列表的高度,目的是为了形成滚动 -->
<div
class="infinite-list-phantom"
:style="{ height: listHeight + 'px' }"
></div>
<!-- 该元素为可视区域,里面就是一个一个列表项 -->
<div class="infinite-list" :style="{ transform: getTranslateY }">
<div
class="infinite-list-item"
v-for="item in visibleData"
:key="item.id"
:style="{
height: `${itemSize}px`,
lineHeight: `${itemSize}px`,
}"
>
{{ item.value }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
const props = defineProps({
data: {
type: Array,
default: () => [],
},
itemSize: {
type: Number,
default: 100,
},
});
// 起始/结束索引
const startIndex = ref(0);
const endIndex = ref(0);
// 视口的高度/其实就是infinite-list-container可视高度
const screenHeight = ref(0);
const listRef = ref(null);
// 列表的总高度
const listHeight = computed(() => {
return props.data.length * props.itemSize;
});
// 可显示的列表项数
const visibleCount = computed(() => {
return Math.ceil(screenHeight.value / props.itemSize);
});
// 真正显示的列表项
const visibleData = computed(() => {
//这里 取最小值是为了防止超出数组长度,因为endIndex是起始+可渲染数
//如果此时渲染到倒数第二个在页面的第一条,那么就会出现endIndex超出数组长度的情况
return props.data.slice(
startIndex.value,
Math.min(endIndex.value, props.listData.length)
);
});
// 滚动事件
const startOffset = ref(0);
const getTranslateY = computed(() => {
return `translateY(${startOffset.value}px)`;
});
const onScroll = () => {
// 更新偏移量和起始结束索引
let scrollTop = listRef.value.scrollTop;
startIndex.value = Math.floor(scrollTop / props.itemSize);
endIndex.value = startIndex.value + visibleCount.value;
//取余是为了将scrollTop调整到对应的itemSize对齐的位置,避免显示不完整的itemSize
startOffset.value = scrollTop - (scrollTop % props.itemSize);
};
onMounted(() => {
screenHeight.value = listRef.value.clientHeight;
startIndex.value = 0;
endIndex.value = startIndex.value + visibleCount.value;
});
</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>
App.vue
<template>
<VitureList :data="data" :itemSize="100" />
</template>
<script setup>
import { ref } from "vue";
import VitureList from "./components/VitureList.vue";
// 创建一个长列表的数据
let d = [];
for (let i = 0; i < 10000; i++) {
d.push({
id: i + 1,
value: `第${i + 1}条数据`,
});
}
const data = ref(d);
</script>
<style>
html {
height: 100%;
}
body {
height: 100%;
margin: 0;
}
#app {
height: 100%;
}
</style>
-EOF-