虚拟滚动
使用背景:
前端开发中,当我们需要展示大量数据时,如果我们把所有的元素都渲染出来的话就会造成以下问题:
1、大量的数据需要庞大的DOM元素来呈现,这会导致浏览器的渲染时间明显增加,影响了页面的加载和交互性能。
2、每个列表项都在内存中占用一定的空间,当数据量庞大时会占用大量的内存,占用的内存过多时可能会导致计算机运行缓慢或出现卡顿甚至是崩溃等问题。
3、影响用户体验,一次性加载庞大数据需要较长的时间,这使得用户在等待数据加载完成时可能会感到不耐烦,从而影响了整体的用户体验。
既然渲染大量的数据会造成那么多问题,那么我们有什么办法去解决这些问题呢?这个肯定是有的,答案就是:列表局部渲染,又被称为虚拟列表
虚拟滚动列表通过只渲染可视区域的列表项,当用户滚动时,动态计算可视区域的起始索引,然后只渲染这部分列表项,避免了一次性加载大量数据从而实现平滑的滚动效果,并且列表项移出可见区域时,虚拟滚动列表会回收对应的DOM元素从而降低内存占用。
虚拟滚动列表的实现原理通常包括以下几个步骤:
- 计算可视区域的高度以及每个列表项的高度。
- 根据滚动条的位置,计算出可视区域的起始索引和结束索引。
- 只渲染起始索引和结束索引之间的列表项。
- 当滚动条位置变化时,重新计算起始索引和结束索引,并更新渲染的列表项。
先从简单的一个开始,假如每一个列表项都是等高的,这个时候计算起始索引和结束索引就会变得简单很多
等高虚拟列表具体实现
首先准备静态容器:
<template>
<div
ref="scrollContainer"
class="scroll-container"
@scroll="onScroll"
:style="{ height: containerHeight + 'px', overflowY: 'auto'}"
>
<div class="content-container" :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="(item, index) in visibleData"
:key="item.id"
:style="getItemStyle(index)"
class="item"
>
{{ item.text }}
</div>
</div>
</div>
</template>
scrollContainer
是展示数据的可视区域,即可滚动的区域content-container
是用来撑起滚动条的区域,它的高度是实际的数据长度乘以每条数据的高度,它的作用只是用来撑起滚动条item
则是具体渲染的数据
容器的样式:
<style>
.scroll-container {
border: 1px solid #ddd;
}
.item {
padding: 16px;
border-bottom: 1px solid #ddd;
font-size: 20px;
}
</style>
然后定义我们需要的数据:
data() {
return {
items: Array.from({ length: 100 }).map((_, index) => ({
id: index,
text: `Item ${index + 1}`
})), // 模拟大量数据
itemHeight: 60, // 每个元素的高度
containerHeight: 500, // 容器高度
buffer: 5, // 缓存区大小
startIndex: 0,
endIndex: 0
}
}
接下来就是为展示容器绑定滚动事件来动态改变要展示的数据,思路就是:
- 根据滚动的高度除以每一条数据的高度得到数据数组的起始索引
- 起始索引加上容器可以展示的条数得到1结束索引
- 根据起始结束索引截取数据
具体代码如下:
updateVisibleItems() {
const scrollTop = this.$refs.scrollContainer.scrollTop //首先获得元素滚动位置
const visibleCount = Math.ceil(this.containerHeight / this.itemHeight) //计算容器最多能展示的数据条数,向上取整
this.startIndex = Math.max(
0,
Math.floor(scrollTop / this.itemHeight) - this.buffer
) //计算第一条数据的索引,要保留缓存区的数目
this.endIndex = Math.min(
this.items.length,
this.startIndex + visibleCount + this.buffer * 2
) //计算结束索引,由于容器前后都要有缓存区的数据,所以要加上两倍的缓存区的数目
console.log(this.startIndex)
console.log(this.endIndex)
}
知道了每一项数据的索引,这时候就要调整列表项相对于父元素的偏移程度
代码如下:
getItemStyle(index) {
return {
position: 'absolute',
top: `${(this.startIndex + index) * this.itemHeight}px`,
width: '100%'
}
}
由于每个列表项都是等高的,可以直接通过列表项索引乘以每一项的高度直接得到
不等高虚拟列表具体实现
在实际应用当中,只通过等高的列表项难以满足需求,因为要展示的数据是各式各样的,它们的高度难以保证,那再已有的等高虚拟列表实现的前提下,怎样修改能实现不等高虚拟列表呢?
原理相同,这两者之间的关键点就在于如何获取要展示的数据的索引以及数据的偏移量
依然是先准备静态容器:
<div
ref="scrollContainer"
class="scroll-container"
@scroll="onScroll"
:style="{ height: containerHeight + 'px', overflowY: 'auto' }"
>
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<div
v-for="(item, index) in visibleData"
:key="item.id"
:style="getItemStyle(index)"
class="item"
>
{{ item.text }}
</div>
</div>
</div>
准备需要的数据,这里的不同点是每个元素还保存了其高度,每一项是不同的:
data() {
return {
items: Array.from({ length: 100 }).map((_, index) => ({
id: index,
text: `Item ${index + 1}`,
height: Math.floor(Math.random() * 50) + 50, // 每个item的高度随机(50px到100px之间)
})), // 模拟不等高数据
containerHeight: 500, // 容器高度
buffer: 5, // 缓存区大小
startIndex: 0,
endIndex: 0,
};
}
接下来就是最重要的计算起始和结束索引:
updateVisibleItems() {
const scrollTop = this.$refs.scrollContainer.scrollTop;
let accumulatedHeight = 0;
let startIdx = 0;
// 计算可见的起始索引和结束索引
while (startIdx < this.items.length && accumulatedHeight + this.items[startIdx].height < scrollTop) {
accumulatedHeight += this.items[startIdx].height;
startIdx++;
}
let endIdx = startIdx;
accumulatedHeight = 0;
while (endIdx < this.items.length && accumulatedHeight + this.items[endIdx].height < this.containerHeight + scrollTop) {
accumulatedHeight += this.items[endIdx].height;
endIdx++;
}
this.startIndex = Math.max(0, startIdx - this.buffer);
this.endIndex = Math.min(this.items.length, endIdx + this.buffer);
console.log('Start Index:', this.startIndex);
console.log('End Index:', this.endIndex);
}
计算起始索引的方法是遍历数据数组的高度,每一项相加,当超过当前获得到的容器滚动长度,就说明到了第一项要展示的数据的索引,即起始索引,获得结束索引的方法也是通过遍历每一项的高度,当腰超过容器展示的最大数据数目的时候就说明到了结束索引,最后相应加上和减去缓存区的长度
计算偏移量
代码实现:
getItemStyle(index) {
let topOffset = 0;
// 计算当前项的 top offset
for (let i = 0; i < index; i++) {
topOffset += this.items[i].height;
}
return {
position: 'absolute',
top: `${topOffset}px`, // 根据每个 item 的实际高度来定位
width: '100%',
};
}
这里的实现就是暴力的遍历每一项算出目标元素的偏移量