虚拟滚动
虚拟滚动
主要解决数据量较大时,滚动加载的问题。
效果
思路
主要是通过 start/end
不断的切割数据,然后渲染到页面中。
页面布局如下图所示:
INFO
滚动条的高度是 应该渲染所有数据的真实高度, 里面的内容高度是固定的,只是通过不断的切割数据去填充页面,dom节点的数量是不变的。
难点就在于确定 start/end
的值。
当 item 的高度已知时
基础
比如我要渲染 8 个item
- 初始化时:
初始值 为
start = 0,end = 8
,那么data
为源数据.slice(0,8)
- 当用户滚动滚动条时:
获取滚动高度,
start = 滚动高度/item高度,end = start + 8
,data
此时为源数据.slice(start,end)
通过不断的滚动,不断的更新start/end
值
用代码简单表示为:
ts
// 单个 item的高度
const size = ref(40)
const start = ref(0)
const end = computed(()=>start + 8);
onMounted(() => {
// 内容区域的高度,渲染 8 个 item
viewport.value!.style.height = 8 * size.value + 'px'
// 设置滚动条的高度,这样才能滚动
scrollBar.value!.style.height = originData.length * size.value + 'px'
})
// 发生滚动时
viewport.value.addEventListener("scroll",function(){
let scrollTop = viewport.value!.scrollTop;
start.value = Math.floor(scrollTop / size.value)
})
// 渲染到页面的数据
const visiableData = computed(() => {
return originData.slice(start.value,end.value)
})
此时虽然可以运行,但是为了保证用户的体验性,我们需要在页面上加一些前景数据和尾部预留数据,增大预留面积,使得滚动时不会有空白数据
优化
定义前景预留数据个数和尾部预留数据个数
ts
// 前景预留数据个数
const prevCount = computed(() => {
return Math.min(start.value, 8)
})
// 尾部预留数据个数
const nextCount = computed(() => {
return Math.min(end.value, originData.length - end.value)
})
那么此时的数据切割就变成了
ts
// 增大预留面积
const visiableData = computed(() => {
// start 值往前移动
// end 值往后移动
let _start = start.value - prevCount.value
let _end = end.value + nextCount.value
return originData.slice(_start, _end)
})
由于数据量变大,要修改 dom 结构,内容区域改为 绝对定位
,通过改变 top
值来控制显示数据
vue
<div class="absolute top-0 w-full" :style="{ top: offset + 'px' }">
</div>
<script>
offset.value = start.value * size.value - prevCount.value * size.value;
</script>
当item 的高度不定时
虽然高度不定,但是要给出一个大致的高度做一个初渲染,等到真实dom渲染后,再动态的改变高度
缓存高度数据
先临时记录 height/top/bottom 数据
ts
const size = ref(40)
let positions = reactive<any[]>([])
onMounted(() => {
positions = originData.map((_, index) => ({
height: size.value,
top: index * size.value,
bottom: (index + 1) * size.value
}))
})
当发生改变时,会触发 onUpdate
方法,此时我们需要真实元素的大小位置,并且更改positions
中的数据
更新位置
在 onUpdated
回调中, 使用 getBoundingClientRect
获取元素的高度,并更改 positions
中的数据
ts
const updatePosition = async () => {
let nodes = itemsRefNode.value
nodes && nodes.length && nodes.forEach((node) => {
// 最新的高度
let { height } = node.getBoundingClientRect()
// 元素的唯一标识 id
let id = + (node.getAttribute("vid") ?? 0) - 0;
// 原来的高度
let oldHeight = positions[id].height;
// 高度差
let diffHeight = oldHeight - height
if (diffHeight) {
// 更新高度
positions[id].height = height
positions[id].bottom = positions[id].bottom - diffHeight
// 后面所有人都需要增加高度
for (let i = id + 1; i < positions.length; i++) {
// 上一个的bottom 就是下一个的 top
positions[i].top = positions[i - 1].bottom
positions[i].bottom = positions[i].bottom - diffHeight
}
}
})
scrollBar.value!.style.height = positions[positions.length - 1].bottom + 'px'
}
源码
vue
<template>
<!-- 视口 -->
<div>
<div class="relative overflow-y-auto rounded-md mx-auto h-auto bg-red-100" ref="viewport" @scroll="handleScroll">
<!-- 滚动条 -->
<div ref="scrollBar"></div>
<!-- 真实位置 -->
<div class="absolute top-0 w-full" :style="{ top: offset + 'px' }">
<div class="bg-blue-400 text-white text-center py-4 mt-2 first:mt-0 rounded" v-for="(item, index) in visiableData"
:index="item.id" :vid="item.id" ref="itemsRefNode">
{{ item.value }}
</div>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUpdated, PropType, reactive, ref } from 'vue';
import Mock from "mockjs";
const start = ref(0)
const end = computed(() => start.value + 8)
const offset = ref(0)
const size = ref(40)
const originData = Array.from({ length: 60 }, (_, i) => ({
id: i,
value: Mock.mock('@cparagraph()')
}))
const virable = ref(false);
const visiableData = computed(() => {
// 增大预留面积
// start 值往前移动
// end 值往后移动
let _start = start.value - prevCount.value
let _end = end.value + nextCount.value
return originData.slice(_start, _end)
})
/**
* @description 前景预留
*/
const prevCount = computed(() => {
return Math.min(start.value, 8)
})
/**
* @description 尾部预留
* @description 如果 传入 的 items 的数量小于 要 预留(remain)的个数,使用 预留个数
*/
const nextCount = computed(() => {
return Math.min(end.value, originData.length - end.value)
})
const itemsRefNode = ref<HTMLDivElement[] | null>(null)
const scrollBar = ref<HTMLDivElement | null>(null)
let positions = reactive<any[]>([])
const updatePosition = async () => {
let nodes = itemsRefNode.value
nodes && nodes.length && nodes.forEach((node) => {
let { height } = node.getBoundingClientRect()
let id = + (node.getAttribute("vid") ?? 0) - 0;
let oldHeight = positions[id].height;
let diffHeight = oldHeight - height
if (diffHeight) {
positions[id].height = height
positions[id].bottom = positions[id].bottom - diffHeight // 顶部增加了
// 后面所有人都需要增加高度
for (let i = id + 1; i < positions.length; i++) {
positions[i].top = positions[i - 1].bottom
positions[i].bottom = positions[i].bottom - diffHeight
}
}
})
scrollBar.value!.style.height = positions[positions.length - 1].bottom + 'px'
}
onUpdated(() => {
if (!virable.value) return
updatePosition()
})
const getIndex = (value: number) => {
let start = 0, end = positions.length - 1, temp: null | number = null;
// 二分法
while (start < end) {
let middleIndex = parseInt(String((start + end) / 2))
let middleValue = positions[middleIndex].bottom
if (middleValue == value) {
return middleIndex
} else if (middleValue < value) {
start = middleIndex + 1
} else {
/**
* @examle [1,2,5,6,10,20,50] value = 40 ,返回 50
*
*/
if (temp == null || temp > middleIndex) {
temp = middleIndex // 找到范围
}
end = middleIndex - 1
}
}
return temp
}
const viewport = ref<HTMLDivElement | null>(null)
const handleScroll = () => {
let scrollTop = viewport.value!.scrollTop;
if (virable.value) {
// 滚动的距离,计算需要从哪个 item 开始
start.value = getIndex(scrollTop) || 0
offset.value = positions[start.value - prevCount.value] ? positions[start.value - prevCount.value].top : 0;
} else {
start.value = Math.floor(scrollTop / size.value)
// 需要把预留出来的偏移量 减去
// 因为滚动的时候 start 提前了,会有一段时间重复数据
offset.value = start.value * size.value - prevCount.value * size.value;
}
}
const cacheList = () => {
// 先暂时记录一个 缓存高度数组列表
positions = originData.map((_, index) => ({
height: size.value,
top: index * size.value,
bottom: (index + 1) * size.value
}))
}
onMounted(() => {
// 视口高度 是 视口的items 个数 * 每一个的高度 大约值
viewport.value!.style.height = 8 * size.value + 'px'
// 设置滚动条的高度,这样才能滚动
scrollBar.value!.style.height = originData.length * size.value + 'px'
if (virable.value) {
cacheList()
}
})
</script>