前言
在日常代码开发过程中,总会遇到大数据量的问题,当我们需要加载显示几千上万的数据的时候,如果我们是一次性渲染,那肯定就会出现严重的卡顿现象,这对用户体验是非常差的,也会让我们的项目,可用性大大降低,为此我们可以使用虚拟列表这个解决方案,只显示我们可视区域内可展示的数据量,这样就大大降低了页面卡顿的概率。
定高虚拟列表
定高虚拟列表就是虚拟列表的每一行的高度都是固定的,所以做起来也比较方便,一个可视的容器,里面包括这一个用于撑开让可视区域出现滚动条的元素,加列表元素,通过滚动的距离除于每一行的高,得到开始坐标 startIndex, 通过开始坐标加上 可是容器的高度除于每一行的高得到结束下表 endIndex, 然后可视区域展示的数据 就通过startIndex 与 endIndex 去截取,词不达意,直接上代码
效果
代码
<template>
<!-- 虚拟列表可视区域 -->
<div class="virtual-list" :style="{ height }" @scroll="onScroll">
<!-- 撑起出现滚动条的元素 -->
<div class="total-height" :style="{ height: `${totalHeight}px` }"></div>
<!-- 虚拟列表内容区域 -->
<div class="virtual-body" :style="{ transform: `translateY(${scrollY}px` }">
<div
class="virtual-item"
v-for="item in showList"
:key="item.id"
:style="{ height: `${itemHeight}px` }"
>
{{ item.name }}
</div>
</div>
</div>
</template>
<script setup>
import { ref, computed, watch } from "vue";
const props = defineProps({
// 列表所有数据
data: {
type: Array,
default: () => [],
},
// 每一项的高度
itemHeight: {
type: Number,
default: 50,
},
// 可视区域的高度
height: {
type: Number,
default: 300,
},
});
// 滚动距离/虚拟列表体移动距离
const scrollY = ref(0);
// 要展示在可视区域的数据
const showList = computed(() => {
// 计算可视区域起始索引
const startIndex = Math.ceil(scrollY.value / props.itemHeight);
// 计算可视区域结束索引
const endIndex = Math.ceil((scrollY.value + props.height) / props.itemHeight);
// 截取可视区域数据
return props.data.slice(startIndex, endIndex);
});
// 列表所有数据的总高度
const totalHeight = computed(() => props.data.length * props.itemHeight);
const onScroll = (e) => {
// 获取滚动距离
scrollY.value = e.target.scrollTop;
};
watch(
() => props.data,
() => {
// 数据更新后,重置滚动距离
scrollY.value = 0;
}
);
</script>
<style scoped>
.virtual-list {
overflow-y: auto;
width: 100%;
position: relative;
border: dashed 1px orange;
}
.total-height {
width: 100%;
position: absolute;
}
.virtual-body {
position: relative;
width: 100%;
}
.virtual-item {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
border-bottom: dashed 1px orange;
}
</style>
由于定高虚拟列表比较简单就直接上代码了,代码中也有相应的注释
使用的地方
<template>
<div class="container">
<div>
<h1>定高虚拟列表</h1>
<FixedHighVirtualListVue :height="600" :itemHeight="50" :data="data" />
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from "vue";
import FixedHighVirtualListVue from "./components/fixed-high-virtual-list/fixed-high-virtual-list.vue";
const data = ref([]);
onMounted(() => {
for (let index = 0; index < 10000; index++) {
data.value.push({
index,
name: `name-${index}-${"hello world".repeat(6)}`,
});
}
});
</script>
<style scoped>
.container {
display: flex;
div {
width: 500px;
margin-left: 10px;
}
}
</style>
不定高虚拟列表
不定高虚拟列表,就是每一行的高度不确定的,要等可视区域的数据渲染完毕之后,才知道每一项的高度,所以这块会有一个预设的步骤,就是还未在可视区域显示过的元素的高同意预设一个值,等元素渲染之后,再根据实际渲染的值,更新我们的预设值,从而再计算出startInex 与 endIndex, 进而得到要渲染的数据。
效果
可以看到每一行的高度都是不太一样的,具体怎么实现,可以继续往下看
html 结构
<template>
<!-- 虚拟列表可视区域 -->
<div
class="virtual-list"
:style="{ height: `${height}px` }"
@scroll="onScroll"
>
<!-- 撑起出现滚动条的元素 -->
<div
class="total-height"
:style="{ height: `${state.totalHeight}px` }"
></div>
<!-- 虚拟列表内容区域 -->
<div
class="virtual-body"
:style="{ transform: `translateY(${state.scrollY}px` }"
>
<!-- item 具有 index 及 name属性 与 VirtualRowVue 的props一致可以v-bind直接绑定 item-->
<VirtualRowVue
v-for="item in state.showList"
v-bind="item"
@changeSize="onChangeSize"
/>
</div>
</div>
</template>
实现过程
第一 监听传进来的列表数据
watch(props.data, () => {
// 渲染预估搞定的虚拟列表
refreshShowList();
});
当我们刚开始渲染列表的时候,就会调用refreshShwoList 更新我们展示的内容
第二 刷新列表
// 刷新可视区域的元素
const refreshShowList = () => {
// 刷新总高度
refreshTotalHeight();
// 获取开始结束索引
const [startIndex, endIndex] = getRangeIndex();
// 遍历赋值可视区域数据
state.value.showList = props.data.slice(startIndex, endIndex + 1);
};
这里主要是,计算出,撑开滚动条的元素的高度,也就是所有元素的高度总和,然后计算出开始与结束下表用于截取可视区域的数据
第三 刷新总高度
// 计算总高度
const refreshTotalHeight = () => {
// 获取已记录的最后一个元素
const { mapObj, lastIndex } = displayedData;
const lastItem = mapObj[lastIndex] || {
offset: 0,
height: 0,
};
// 计算已记录的总高度
const displayedTotalHeight = lastItem.offset + lastItem.height;
// 计算未记录的总高度
const unDisplayedTotalHeight =
(props.data.length - lastIndex - 1) * props.estimatedHeight;
// 得到总高度
state.value.totalHeight = displayedTotalHeight + unDisplayedTotalHeight;
};
这里主要就是计算已经出现过在可视区域的元素的高度和(每个元素的高度都是实际的高度)与未出现过在可视区域的元素的高度和(每个元素的高度是预设的)mapObj 就是 用来记录出现过在可视区域的元素的偏移量 与 自身高度的对象
第四 计算开始与结束下标
// 获取开始结束索引
const getRangeIndex = () => {
// 获取可视区域开始索引
const startIndex = getStartIndex();
// 获取可视区域结束索引
const endIndex = getEndIndex(startIndex);
return [Math.max(startIndex, 0), Math.min(endIndex, props.data.length - 1)];
};
第五 计算开始下标
// 获取可视区域开始索引
const getStartIndex = () => {
// 找到偏移量大于等于滚动距离的第一个元素
let startIndex = 0;
for (let i = 0; i < props.data.length; i++) {
// 获取每一项的信息, 如果没有就使用预估高度
const item = getItemInfo(i);
if (item.offset >= state.value.scrollY) {
startIndex = i;
break;
}
}
return startIndex;
};
当我们计算出偏移量大于等于滚动距离的时候,这时候得到的就是可视区域开始元素的下标了
第五步 计算结束下标
// 获取可视区域结束索引
const getEndIndex = (startIndex) => {
// 获取开始下标的项
const startItem = getItemInfo(startIndex);
// 计算最后的下标的偏移量
const endOffset = startItem.offset + props.height;
// 遍历计算结束下标
let endIndex = startIndex;
let offset = startItem.offset;
while (offset < endOffset && endIndex < props.data.length) {
const item = getItemInfo(++endIndex);
offset += item.height;
}
return endIndex;
};
结束下标,就得通过开始下标的偏移量 + 可视区域的高度去计算了,当找到元素的偏移量(offset)大于等于他俩(开始下标的偏移量 、可视区域的高度)的和的时候,那这个下标就是结束下标了
第六 获取每一个元素的信息
// 获取每一项的信息, 如果没有就使用预估高度
const getItemInfo = (index) => {
const { mapObj, lastIndex } = displayedData;
// 如果是往下滚就是需要计算新的开始偏移量
if (index > lastIndex) {
// 第一项的时候 mapObj[lastIndex] 是 undefined 需要给个默认值
const lastItem = mapObj[lastIndex] || {
offset: 0,
height: 0,
};
// 计算新的开始偏移量没有记录的项统计使用预估高度
let offset = lastItem.offset + lastItem.height;
for (let i = lastIndex + 1; i <= index; i++) {
mapObj[i] = {
offset,
height: props.estimatedHeight,
};
offset += props.estimatedHeight;
}
// 更新最后一个索引
displayedData.lastIndex = index;
}
return mapObj[index];
};
mapObj 就是保存在可视区域出现过的元素的对象, 如果元素在可视区域出现过,就直接通过mapObj[index] 返回,如果没有出现过,代表是向下滚动的,这种元素就得保存一下,高度时估计高度,同时保存出现在可视区域的最后一个元素的下标,当这些预设高度的元素渲染那完毕之后,都会触发一下 onChangeSize 事件 用于更新maoObj 中高度及偏移量还不是实际值的数据
第七 onChangeSize函数
// 子元素大小改变
const onChangeSize = ({ index, height }) => {
// 更新子元素高度
const item = getItemInfo(index);
item.height = height;
// 更新子元素偏移量
const { mapObj, lastIndex } = displayedData;
let offset = item.offset + item.height;
for (let i = index + 1; i <= lastIndex; i++) {
const curItem = getItemInfo(i);
curItem.offset = offset;
offset += curItem.height;
}
};
第八 子组件
<template>
<!-- 虚拟列表可视区域 -->
<div class="virtual-row" ref="virtualRowRef">{{ name }}</div>
</template>
<script setup>
import { onMounted, onUnmounted, ref } from "vue";
const props = defineProps({
// 每一行展示的内容
name: {
type: String,
default: "",
},
// 数据在总列表的下标
index: {
type: Number,
default: 0,
},
});
const emits = defineEmits(["changeSize"]);
const virtualRowRef = ref(null);
let observe = null;
onMounted(() => {
// 监视元素变化
observe = new ResizeObserver(() => {
emits("changeSize", {
index: props.index,
height: virtualRowRef.value.offsetHeight,
});
});
// 监视行元素
observe.observe(document.querySelector(".virtual-row"));
});
onUnmounted(() => {
// 销毁监听
observe?.disconnect?.();
});
</script>
<style>
.virtual-item {
width: 100%;
display: flex;
align-items: center;
justify-content: center;
border-bottom: dashed 1px blueviolet;
}
</style>
子组件相对简单,主要就是监听元素是否在可视区域,是的话就出触发 父组件的 onChangeSize 事件,把元素标识 及元素高度传过去,进而更新mapObj中的数据,渲染出正确的内容
以上便是所哟内容,作为自己的学习笔记记录,如果恰好能帮到你,那再好不过了,代码地址在这里