搞懂虚拟列表实现原理与步骤
虚拟列表已经说烂了,此篇文章仅作记录使用,通俗的拆解每一步的逻辑和每一个变量的意义。
一、原理和基本构成
首先,虚拟滚动就是为了解决渲染大量数据到页面上造成的性能问题,一千个dom元素同时渲染到页面上必然出现卡顿,但是一千条或者一万条真的渲染出来了,一般的显示器也是显示不出来的,那我们能不能只渲染可视区域中出现的数据缩小渲染数据量呢,比如我的可视区域只能展示十条,那我把这十条拿出来只渲染这十条不就好了。那这种虚拟列表就应运而生了。
他的结构如下图:
没看懂没关系,它的结构代码如下:
<template>
<!-- 可视区域 -->
<div class="virtua_main">
<!-- 虚拟元素 -->
<div class="occupy_pace"></div>
<!-- 内容区域 -->
<div class="virtua_content">
<p class="virtua_item">
item1
</p>
</div>
</div>
</template>
<style scoped>
.virtua_main {
width: 500px;
height: 500px;
overflow-y: auto;
position: relative;
background: greenyellow;
color: red;
}
.virtua_content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtua_item {
margin: 0;
border: 1px solid #e0e0e0;
box-sizing: border-box;
}
</style>
首先明确,可视区域(virtua_main)的高度是固定的,超出高度出现滚动条,所以需要一个虚拟元素(occupy_pace)撑起高度从而显示滚动条,而且滚动高度要与数据条数相匹配。内容区域(virtua_content)通过绝对定位覆盖在虚拟元素之上。
二、需要思考的几个数字
到目前为止,只有可视区域的高度是固定的,那么内容区域的高度呢,虚拟元素的高度呢;下面列阵:
内容区域的高度 = 要展示的条数 * 每一条内容的高度
虚拟元素的高度 = 需要渲染数组的总长度 * 每一条内容的高度
截取展示内容数据开始的索引 = 滚动条移动的高度 / 每一条内容的高度
截取展示内容数据结束的索引 = 截取展示内容数据开始的索引 + 要展示的条数
三、代码实现基础逻辑
虽然但是那我还是不知道,要展示的条数、每一条内容的高度、滚动条移动的高度、需要渲染数组的总长度从哪里来呢?那还是直接上代码吧🙄🙄🙄
<template>
<!-- 可视区域 -->
<div class="virtua_main" @scroll="onScroll">
<!-- 虚拟元素 -->
<div class="occupy_pace" :style="{ height: virtualHeight }"></div>
<!-- 内容区域 -->
<div
class="virtua_content"
:style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
>
<p class="virtua_item" v-for="item in visibleItems" :key="item.id">
{{ item.label }}
</p>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
Array.from({ length: 1000 }).map((_, index) => ({
id: index,
label: `Item ${index + 1}`,
}))
);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
//开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
//使用floor向下取整数
const index = Math.floor(scrollTop.value / rowHeight.value);
const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
return Math.min(index, maxStartIndex);
});
//截取展示内容数据结束的索引
const endIndex = computed(() => {
//也有边界问题,不能大于数据总长度
return Math.min(startIndex.value + showSize.value, allList.value.length);
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
return allList.value.slice(startIndex.value, endIndex.value);
});
//滚动事件
const onScroll = (event) => {
//将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
scrollTop.value = event.target.scrollTop;
};
</script>
<style scoped>
.virtua_main {
width: 300px;
height: 500px;
overflow-y: auto;
position: relative;
background: #e0e0e0;
color: red;
}
.virtua_content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtua_item {
height: var(--row-height);
margin: 0;
border: 1px solid #000;
box-sizing: border-box;
}
</style>
这样更直观的回答了数据从哪来、如何计算、如何使用、何时更新的问题。但是但是但是如果这样写了之后会发现一个问题,内容数据确实一直在变,但是内容区域怎么上去了,位置不对,如图:
上图出现的原因是因为虽然内容区域已经开启了定位,但是它处于virtua_main的滚动空间之中,所以也会随着滚动,那这样,既然他要向上滚动,那我们就再加一步,手动通过transform:translateY()把他纵向向下移动,那么应该移动的距离怎么计算呢,理一理,比如当前从0到19展示了10条,我们向下滚动到1此时0隐藏了,那他是不是就向上滚动了1 * rowHeight的距离呢,1是当前切割数据的开始索引,由此推断 位移距离 = startIndex * rowHeight==。
在滚动事件增加这么一行
//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
//将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
scrollTop.value = event.target.scrollTop;
//向下位移掩盖空白
virtuaContent.value.style.transform = `translateY(${
startIndex.value * rowHeight.value
}px)`;
};
目前为止,完成了一个基础的虚拟滚动。
四、细节问题
1.空白
大家是否想过,如果我滚动高度挪到了一个不能整除rowHeight的数字怎么办,当然我们使用了Math.floor向下取整,比如我挪动到了624,我设置的rowHeight是50,startIndex = 624 / 50结果是12.48,Math.floor(12.48)得出12,很好数字没有问题,但是我们使用transform进行位移是用12 * 50得600,也就是说我向上滚动了624px但是向下却位移了600px,那尾部自然会出现24px的空白,如图:
两种解决方案
其一,简单粗暴,算位移距离直接用scrollTop:
//通过ref的方式取到内容区域
const virtuaContent = ref(null)
//滚动事件
const onScroll = (event) => {
//将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
scrollTop.value = event.target.scrollTop;
//向下位移掩盖空白
virtuaContent.value.style.transform = `translateY(${
(scrollTop.value / rowHeight.value) * rowHeight.value
}px)`;
};
其二、更简单更粗暴,尾部多切几条数据:
//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
//也有边界问题,不能大于数据总长度
//每次多加载buffer条数据
return Math.min(
startIndex.value + showSize.value + buffer,
allList.value.length
);
});
这两种方式都能用,但使用 scrollTop 会产生半行高度空白,需要 buffer 补齐
2.加载更多
我们做到这里基本能用了,但是都说了大数据量了,只有一千条吗,我有十万条!一百万!但是一个接口返回给你这个请求的时间是不是有点长呢,那么一页一千条拉到底部继续加载是不是极好的呢,如果再加一个等待效果是不是极好的呢。如果马上滑到底部就发送请求到底部时数据已经加载出来了是不是堪称完美,那我们来实现他。
想一想,我们如何来判断要到底部或者即将到底部了呢,有小伙伴说如果scrollTop如果大于等于虚拟元素的总高不就说明到底了吗,no scrollTop可以理解为虚拟元素顶部到可视区域顶部的距离,还差一个可视区域的高度,所以,scrollTop + clientHeight >= virtualHeight才说明到底了,此时走过来一个老伙伴问,那我怎么才能实现即将到底时就发送求情呢,问得好,比如我们想距离底部100px时就发送请求,我们只要再加100就好了呀,又又又来了个小东西问加载效果呢,问的也好,但是没有刚才好,答:只要在内容区最下方加一条内容用一个状态来控制它的显隐就好了呀。完整版如下:
<template>
{{ `startindex:${startIndex}` }} {{
`endIndex:${endIndex}`
}} {{ `scrollTop:${scrollTop}` }}
{{ currentPage }}
<!-- 可视区域 -->
<div class="virtua_main" @scroll="onScroll">
<!-- 虚拟元素 -->
<div class="occupy_pace" :style="{ height: virtualHeight }"></div>
<!-- 内容区域 -->
<div
class="virtua_content"
ref="virtuaContent"
:style="{ height: contentHeight, '--row-height': rowHeight + 'px' }"
>
<p class="virtua_item" v-for="item in visibleItems" :key="item.id">
{{ item.label }}
</p>
<p v-if="loading" class="virtua_item loading-indicator">加载中。。。</p>
</div>
</div>
</template>
<script setup>
import { ref } from "vue";
//模拟一千条数据
const allList = ref(
Array.from({ length: 1000 }).map((_, index) => ({
id: index,
label: `Item ${index + 1}`,
}))
);
//内容区域元素
const virtuaContent = ref(null);
//可视区域显示条数
const showSize = ref(10);
//每行高度
const rowHeight = ref(50);
//当前滚动高度
const scrollTop = ref(0);
//虚拟元素高度
const virtualHeight = computed(() => {
return allList.value.length * rowHeight.value + "px";
});
//内容区高度
const contentHeight = computed(() => {
return showSize.value * rowHeight.value + "px";
});
//截取展示内容数据开始的索引
const startIndex = computed(() => {
//开始的索引要考虑边界问题,不能比0小,同时不能大于数据总长度减显示条数
const index = Math.floor(scrollTop.value / rowHeight.value);
const maxStartIndex = Math.max(0, allList.value.length - showSize.value);
return Math.min(index, maxStartIndex);
});
//定义缓冲数据大小
const buffer = 3;
//截取展示内容数据结束的索引
const endIndex = computed(() => {
//也有边界问题,不能大于数据总长度
//每次多加载buffer条数据
return Math.min(
startIndex.value + showSize.value + buffer,
allList.value.length
);
});
//内容区域展示的数据,从所有数据中截取showSize条
const visibleItems = computed(() => {
return allList.value.slice(startIndex.value, endIndex.value);
});
//记录当前页,模拟状态下没啥用,真实情况下要发送给后端一般还要带一个pageSize
const currentPage = ref(1);
//定义加载状态
const loading = ref(false);
//定义距离下边界多少像素时触发加载更多
const loadMoreThreshold = 100;
//模拟一个加载更多数据函数
const loadMoreData = () => {
loading.value = true;
setTimeout(() => {
//模拟网络请求
allList.value.push(
...Array.from({ length: 1000 }).map((_, index) => ({
id: allList.value.length + index,
label: `Item ${allList.value.length + index + 1}`,
}))
);
currentPage.value++;
loading.value = false;
scrollTop.value -= 10;
}, 200);
};
//滚动事件
const onScroll = (event) => {
const target = event.target;
//将新的滚动位置赋值给scrollTop,驱动更新startIndex,endIndex和visibleItems
scrollTop.value = target.scrollTop;
virtuaContent.value.style.transform = `translateY(${
startIndex.value * rowHeight.value
}px)`;
//判断是否触底
const isBoundary =
target.scrollTop + target.clientHeight + loadMoreThreshold >=
virtualHeight.value.replace("px", "");
if (isBoundary && !loading.value) {
loadMoreData();
}
};
</script>
<style scoped>
.virtua_main {
width: 300px;
height: 500px;
overflow-y: auto;
position: relative;
background: #e0e0e0;
color: red;
}
.virtua_content {
position: absolute;
top: 0;
left: 0;
width: 100%;
}
.virtua_item {
height: var(--row-height);
margin: 0;
border: 1px solid #000;
box-sizing: border-box;
}
</style>
四、结语
这个代码很基础,我们可以把它运用到业务场景中,封装一个组件,或者把计算逻辑抽离出hooks,还有可以改为动态高度的版本,并且滚动事件应该添加防抖。有些地方可以更为精简,但是为了一些好兄弟能看的更明白所以这样写了。 代码中还隐藏了一些bug,老伙子们如果发现了可以给我留言,因为第一次写这么长的文章,文中如果有些逻辑不严谨或者错误、还有可以有优化的地方,也欢迎留言指正一起交流。