为什么使用虚拟列表
虚拟列表这种需求太常见了,可能每个项目组都做过这种事。。基本上只要数据稍微多一点(几千、上万的数据量),并且每一项有些复杂的dom结构,常规的列表就会出现明显的滚动卡顿,这时候就要用到“虚拟列表”,也可以叫“懒加载”,基本的做法就是只渲染可见范围内的列表项,一般也会在可见区域的上下加一些缓冲区,避免正常滚动的时候出现白屏。
虚拟列表原理
虚拟列表实际上就是使用少量的DOM节点显示长列表,即只创建并且显示我们视野中看到item节点,滚动过程中通过算法运算把视野中的节点更新成对应的节点。
功能实现思路
1、创建高度为200px的DOM容器
设置一个拥有10W条数据的列表,如果按原来方式就需要创建10W个dom节点,按每个item高度为40px计算,实际上每次出现在视野中的item为5个,剩余的9W多条元素实际上没有多大用处,并且加重了浏览器渲染的压力,但是如果我只创建视图显示的5条数据,则节省了9W多个节点的性能开销。
首先需要获取装载容器高度,需要在组件渲染之后,才能测量容器的真实高度。可以通过一个 ref 来绑定容器元素,在 nextTick 方法中获取容器高度
<script setup>
import {ref,nextTick,} from "vue";
const wrapper = ref(null);
let wrapperHeight = ref(0);
nextTick(() => {
wrapperHeight.value = wrapper.value.clientHeight;
});
</script>
<template>
<div class="wrapper" ref="wrapper" >
...
</div>
</template>
获取了容器高度之后,计算视窗内应该显示的 DOM 数量
//真实DOM数 = Math.ceil(容器高度 / item高度)
let showItemNum = Math.ceil(wrapperHeight.value / 40)
效果如下:
10W的列表数据,只显示视野中的5条数据,符合预期。但是容器的滚动条(200/4刚好整除,连滚动条都没了)不是跟10W条数据应有的样子,因此需要想办法按10W数据的样子撑开容器。
2、撑开容器
为了让容器看起来跟真的拥有10W条数据一样,我们需要根据item条数和他的高度计算出对应的高度并且撑开容器。
<script setup>
...
//真实容器高度 = item总数 * 每条item的高度
let containerHeight = ref(arr.value.length * 40);
</script>
<template>
<div class="wrapper" ref="wrapper" >
<div class="wrapper-scroll" :style="{ height: containerHeight + 'px' }" >
...
</div>
</div>
</template>
效果如下:
这就得到10W条数据的列表了。
3、滚动列表实现
我们只需要根据在列表滚动到某位置的时候,去计算出当前的视窗中列表的索引,再根据索引对数据进行切片,从而将计算出来的数据片渲染到视图中。
运用computed方法,对数据进行切片处理
const showItem = computed(() => {
//为了让列表效果更好,我们将渲染的真实 DOM 数量多增加 3 个
let showItemNum = Math.ceil(wrapperHeight.value / 40) +3;
return [...10W列表数据.slice(当前视野Item的索引, 视窗内应该显示的 DOM 数量)];
});
滚动计算当前状态的索引
const wrapperScroll = (e) => {
当前状态的索引 = Math.floor(当前滚动高度 / 每条item的高度);
//拿到了正确的数据,还需要计算出正确的位置
数据片偏移位置 = 当前滚动高度
}};
具体源码如下:
<script setup>
import { ref, computed, nextTick } from "vue";
//设置10W条模拟数据
const count = ref(100000);let arr = ref([]);
for (let index = 0; index < count.value; index++) {
arr.value.push(index);
}
//容器真实高度
let containerHeight = ref(arr.value.length * 40);
//当前状态的索引
let startKey = ref(0);
//视窗内应该显示的 DOM 数量
let showItemNum = ref(0);
//容器dom节点
const wrapper = ref(null);
//容器高度
let wrapperHeight = ref(0);
nextTick(() => {
//获取容器高度
wrapperHeight.value = wrapper.value.clientHeight;
//运算出应该显示的 DOM 数量
showItemNum.value = Math.ceil(wrapperHeight.value / 40);
});
//片段容器偏移量
let scrollTopWrapper = ref(0);
//滚动事件
const wrapperScroll = (e) => {
//计算当前状态的索引
let tempNum = Math.floor(e.target.scrollTop / 40);
//当前状态的索引发生变化才触发视图层刷新
if (tempNum !== startKey.value) {
startKey.value = tempNum
scrollTopWrapper.value = e.target.scrollTop;
}
};
//对数据进行切片处理方法
const showItem = computed(() => {
return [...arr.value.slice(startKey.value, showItemNum.value + startKey.value + 3)];
});
</script>
<template>
<div class="wrapper" ref="wrapper" @scroll="wrapperScroll($event)">
<div class="wrapper-scroll" :style="{ height: containerHeight + 'px' }" style="position: relative;" >
<div :style="{ transform: `translateY(${scrollTopWrapper}px)` }" style="position: absolute; width: 100%;" >
<div v-for="(item, key) in showItem" :key="key" style="height:40px;line-height:40px" >
{{item}}
</div>
</div>
</div>
</div>
</template>
<style>
.wrapper {
position: relative;
width: 200px;
height: 200px;
overflow: auto;
border: 1px solid #ccc;
}
</style>
具体效果如下: