本文正在参加「金石计划」
前言
最近由于列表需要渲染一万+数据,导致渲染起来很卡,本来是准备直接加一个分页或者下拉滚动的,但是和产品batter了好久,始终不通过这个方案,所以就决定使用虚拟列表滚动进行渲染。关于这个虚拟滚动,我也是之前听说,但没有具体了解过,正好趁着这次机会好好体验下,希望该文章能对你有帮助。
虚拟滚动原理
了解虚拟滚动之前,需要先知道几个概念性的知识:可视区域,列表区域,非可视区域。
- 可视区域:当前固定高度所能看见的区域,决定列表每次展示的数量。
- 列表区域:列表数据区域,决定总的高度。
- 非可视区域:列表数据区域减去可视区域后不可见的区域。
虚拟滚动实现原理,其实就是根据当前可视区域的高度,去计算显示的数量,只渲染可视区域的数据,对不可视区域的数据不渲染,从而节省性能。每次滚动时,根据滚动的距离去计算当前可视区域应该展示的数据。
举个例子:可以想象一下这个场景,有一扇门,门上有一个小孔,你每次看到门后的东西是取决于这个孔所在的位置,例如孔在头部时,你可能只能看到天花板,当这个孔慢慢向下时,你可能就会依次看到窗户,床,地板。虚拟滚动就是每次只渲染你能看到的区域,你看不到的区域它就不渲染。
准备
在真正开始使用虚拟滚动之前,我们可以先了解一下el-scrollbar
滚动条,这个滚动条操作其实和虚拟滚动差不多,都需要外部可视区域盒子固定高度overflow-y:auto
,里面的列表区域盒子有自己的高度,el-scrollbar
可以作为我们了解虚拟滚动的开胃菜。
el-scrollbar
其实就是自己写了一个盒子,然后隐藏掉浏览器默认的滚动条,通过定位到固定位置。滚动页面的时候,计算当前滚动的距离和可视固定高度的比例,就等于虚拟滚动条占可视区域的百分比。
这里贴一下el-scrollbar
的部分关键代码:
# 这里定义水平滚动条和垂直滚动条组件,传入移动的距离百分比和滚动条宽度(高度)。
const wrap = (
<div
ref="wrap"
style={ style }
onScroll={ this.handleScroll }
class={ [this.wrapClass, 'el-scrollbar__wrap', gutter ? '' : 'el-scrollbar__wrap--hidden-default'] }>
{ [view] }
</div>
);
if (!this.native) {
nodes = ([
wrap,
<Bar
move={ this.moveX }
size={ this.sizeWidth }></Bar>,
<Bar
vertical
move={ this.moveY }
size={ this.sizeHeight }></Bar>
]);
}
return h('div', { class: 'el-scrollbar' }, nodes);
这里的 handleScroll
方法就是盒子滚动时,获取当前滚动的距离和可视固定高度的比例,然后更新Bar
组件的位置。
handleScroll() {
const wrap = this.wrap;
this.moveY = ((wrap.scrollTop * 100) / wrap.clientHeight);
this.moveX = ((wrap.scrollLeft * 100) / wrap.clientWidth);
}
这种是鼠标滚轮的情况下,如果是按住滚动条滚动的话,就需要另外的三个方法。
没错,又是咱哥三,mousemove,mousedown,mouseup。
原理和拖拽一样,鼠标按下时计算当前位置,然后move
的时候通过当前位置减去初始位置,计算移动的距离,然后再计算scrollTop
的位置。
startDrag(e) {
e.stopImmediatePropagation();
this.cursorDown = true;
on(document, 'mousemove', this.mouseMoveDocumentHandler);
on(document, 'mouseup', this.mouseUpDocumentHandler);
document.onselectstart = () => false;
},
clickTrackHandler(e) {
const offset = Math.abs(e.target.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]);
const thumbHalf = (this.$refs.thumb[this.bar.offset] / 2);
const thumbPositionPercentage = ((offset - thumbHalf) * 100 / this.$el[this.bar.offset]);
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},
mouseMoveDocumentHandler(e) {
if (this.cursorDown === false) return;
const prevPage = this[this.bar.axis];
if (!prevPage) return;
const offset = ((this.$el.getBoundingClientRect()[this.bar.direction] - e[this.bar.client]) * -1);
const thumbClickPosition = (this.$refs.thumb[this.bar.offset] - prevPage);
const thumbPositionPercentage = ((offset - thumbClickPosition) * 100 / this.$el[this.bar.offset]);
this.wrap[this.bar.scroll] = (thumbPositionPercentage * this.wrap[this.bar.scrollSize] / 100);
},
实现一个简单的虚拟滚动
通过上面的案例,应该已经对虚拟滚动的流程有了一个大概的了解,接下来咱们来实现一个简单的虚拟滚动。
假设:固定可视区域高度为500px
,列表每一项都等高50px
,有一万 条数据,列表总高度为500000px
,列表可视区域显示数量为:500/50=10
,所以列表初始显示值为下标 0~10。
先定义template
结构,和el-scrollbar
一样,虚拟滚动需要外面可视区域盒子固定,并设置overflow-y:auto
,里面列表区域盒子根据高度和数量生成。
所以大概template
代码如下:
<div ref="wrapperRef" style="height:500px" @scroll="onScroll">
<div class="inner" ref="innerRef" style="height:500000px">
<div class="list" ref="virtualListRef" :style="{ willChange: "transform",transform: `translateY(${state.scrollOffset}px)`}">
<div v-for="(item, index) in clientData" :key="index + state.start">
{{item}}
</div>
</div>
</div>
</div>
定义初始方法:
const state = reactive<any>({
start: 0,
end: 10,
scrollOffset: 0,
});
//当前可视的数据。
const clientData = computed(() => {
return props.data.slice(state.start, state.end);
});
接下来主要处理scroll事件,根据当前滚动的scrollTop去计算当前的startIndex和endIndex,注意,当页面滚动后,有一个偏移量,因为列表已经向下了,所以数据区域位置也需要同步向下,才能和数据保持一致。
const onScroll = (e: any) => {
const { scrollTop } = e.target;
if (state.scrollOffset === scrollTop) return;
const startIndex = Math.floor(scrollTop / 50);
const endIndex = startIndex+10;
// 偏移量
const offset = scrollTop - (scrollTop % 50);
Object.assign(state, {
start: startIndex,
end: endIndex,
scrollOffset: offset
});
};
实现效果:
进一步优化
虽然上面实现了简单的虚拟滚动,但是如果列表滚动过快,数据还没渲染,可能会导致空白的情况,效果不太好,关于这点,我们可以和无缝滚动一样,在当前可视区域前后各插入一屏,这样即可优化滚动效果。
定义字段cacheCount
,假设为5
,对上面onScroll
进行小改造:
const onScroll = (e: any) => {
const { scrollTop } = e.target;
if (state.scrollOffset === scrollTop) return;
const startIndex = Math.floor(scrollTop / 50);
const endIndex = startIndex + 10 + cacheCount)
if (startIndex > cacheCount) {
startIndex = startIndex - cacheCount;
}
// 偏移量
const offset = scrollTop - (scrollTop % 50);
Object.assign(state, {
start: startIndex,
end: endIndex,
scrollOffset: offset
});
};
复杂的虚拟滚动
上面我们已经实现了一个简单的虚拟滚动,看上去是不是很简单?但是实际场景中可能列表项不是等高的,那这样处理就比等高的复杂了。
主要实现思路是:定义预估高度字段:itemHeight
,假设预估列表高度为50px
,这里我们要用到onUpdate
钩子,当页面渲染完成后,获取当前列表的每一项的高度,然后存储在cacheData
里面,滚动的时候通过cacheData
快速找到当前的startIndex
。
假设:固定可视区域高度为500px
,列表预估高度为50px
,有一万 条数据,列表预估总高度为500000px
,列表可视区域显示数量为:500/50=10
,所以列表初始显示值为下标 0~10。
每次可视区域值变化的时候,初始化一下cacheData
的值,将当前index
的预估高度,top
和bottom
存储起来,方便后续读取。
watchEffect(() => {
clientData.value.forEach((_, index) => {
const currentIndex = state.start + index;
if (Object.hasOwn(state.cacheData, currentIndex)) return;
state.cacheData[currentIndex] = {
top: currentIndex * 50,
height: 50,
bottom: (currentIndex + 1) * 50,
index: currentIndex
};
});
});
然后onUpdate
的时候,更新index
对应的实际高度,和位置值:
onUpdated(() => {
const childrenList = virtualListRef.value.children || [];
[...childrenList].forEach((node: any, index: number) => {
const height = node.getBoundingClientRect().height;
const currentIndex = state.start + index;
if (state.cacheData[currentIndex].height === height) return;
state.cacheData[currentIndex].height = height;
state.cacheData[currentIndex].top = getCurrentTop(currentIndex);
state.cacheData[currentIndex].bottom = state.cacheData[currentIndex].top + state.cacheData[currentIndex].height;
});
});
改动onScroll
方法:
const onScroll = (e: any) => {
const { scrollTop } = e.target;
if (state.scrollOffset === scrollTop) return;
let startIndex = getStartIndex(scrollTop)
const endIndex = startIndex + 10 + cacheCount)
if (startIndex > cacheCount) {
startIndex = startIndex - cacheCount;
}
// 偏移量
const offset = getCurrentTop(startIndex);
Object.assign(state, {
start: startIndex,
end: endIndex,
scrollOffset: offset
});
};
这里添加了两个核心方法:getStartIndex和getCurrentTop,第一个方法是通过scrollTop
我们上面生成的cacheData
去获取index
,另一个是通过index
去获取scrollTop
。
// 二分法去查找对应的index
const getStartIndex = (scrollTop = 0): number => {
let low = 0;
let high = state.cacheData.length - 1;
while (low <= high) {
const middle = low + Math.floor((high - low) / 2);
const middleTopValue = getCurrentTop(middle);
const middleBottomValue = getCurrentTop(middle + 1);
if (middleTopValue <= scrollTop && scrollTop <= middleBottomValue) {
return middle;
} else if (middleBottomValue < scrollTop) {
low = middle + 1;
} else if (middleBottomValue > scrollTop) {
high = middle - 1;
}
}
return Math.min(10000 - 10, Math.floor(scrollTop / 50));
};
获取当前top的值,这里有一个小逻辑,如果cacheData
里面存在当前index,则直接返回,如果当前的index,cacheData
里面没有,则= cacheData最后一位的高度+(index-cacheData.length) × 预估高度。
const getCurrentTop = (index: number) => {
const lastIndex = state.cacheData.length - 1;
if (Object.hasOwn(state.cacheData, index)) {
return state.cacheData[index].top;
} else if (Object.hasOwn(state.cacheData, index - 1)) {
return state.cacheData[index - 1].bottom;
} else if (index > lastIndex) {
return state.cacheData[lastIndex].bottom + Math.max(0, index - state.cacheData[lastIndex].index) * props.itemHeight;
} else {
return index * props.itemHeight;
}
};
列表区域总高度也需要根据实际高度变化:
const getTotalHeight = computed(() => {
return getCurrentTop(props.data.length));
});
实现效果:
IntersectionObserver方法
前面我们使用的传统方法,根据scrollTop
去计算可视区域的元素,但是滚动后,scrollTop
会触发多次,可能会造成性能上的浪费。所以我们还可以通过 intersectionObserver去实现虚拟滚动。
基础用法
IntersectionObserver 又称交叉观察器,主要作用是用来观察元素是否出现在可视窗口上。
主要用法:
const io = new IntersectionObserver(callback, options)
io.observe(DOM)
参数
- callback:回调函数,当观察的显示或消失,会触发该回调方法,我们可以用该方法来进行判断元素是否显示或隐藏。该回调函数接收一个数组参数 entries,我们主要使用其中的 isIntersecting参数,通过该参数可以判断该观察的元素是否出现在可视区域,其他参数有兴趣可以了解下。
- options:配置可选项,root(监听对象的root元素),rootMargin(到root的偏移量) 和 threshold(阈值,监听对象交叉比例超过阈值后会触发callback)。
- io.observe(DOM):开始观察一个元素。
- io.unobserve(DOM):停止观察对应元素。
- io.takeRecords 返回当前观察的元素数组。
- io.disconnect: 停止观察所有元素。
小案例
通过这个方法,可以用来做图片懒加载的小案例和滚动加载更多小案例。
图片懒加载
const imgList=this.$ref.imgRef;
const io = new IntersectionObserver((entries) =>{
entries.forEach(item => {
if (item.isIntersecting) {
item.target.src = item.target.dataset.src
io.unobserve(item.target)
}
})
})
//开始监听
imgList.forEach(img => io.observe(img))
滚动加载更多
<template>
<div v-for="item in list" :key="item.id">
{{item}}
<div>
<div ref="loadMoreRef">正在加载更多...</div>
</template>
<script setup>
const loadMoreRef=ref(null)
onMounted(()=>{
const io = new IntersectionObserver((entries) =>{
if (entries[0].isIntersecting) {
pageNum++;
loadMore()
}
})
io.observe(unref(loadMoreRef))
})
</script>
实现虚拟滚动
先从简单的等高列表滚动开始,假设条件和上面一致,同样是固定高度50px,可视区域10条数据。
实现思路: 需要默认加载20个元素,因为如果只有10个,初始化就触发了底部的观察,给第一个元素和最后一个元素打一个标记,然后监听观察这两个元素,分别对应向下和向上滚动,然后如果底部元素出现了,那就说明是向下滚动,如果头部元素出现了,那就说明是向上滚动。
<div ref="wrapperRef" style="height:500px" @scroll="onScroll">
<div class="inner" ref="innerRef" style="height:500000px">
<div class="list" ref="virtualListRef" >
<div v-for="(item, index) in clientData" :key="index + state.start" :id="index === clientData.length - 1 ? '_bottom' : index === 0 ? '_top' : ''">
{{item}}
</div>
</div>
</div>
</div>
定义初始方法:
const state = reactive<any>({
start: 0,
end: 20,
scrollOffset: 0,
});
const observerInstance = ref();
//当前可视的数据。
const clientData = computed(() => {
return props.data.slice(state.start, state.end);
});
//主要通过监听end的变化,去更换当前观察的元素。
watch(
() => state.end,
() => {
clearObserver();
initObserver();
},
{ immediate: true }
);
主要核心方式是clearObserver 和 initObserver。
const clearObserver = () => {
//停止观察。
nextTick(() => {
unref(observerInstance)?.unobserve(document.getElementById("_top"));
unref(observerInstance)?.unobserve(document.getElementById("_bottom"));
});
};
const initObserver = () => {
//开始观察
observerInstance.value = new IntersectionObserver(observerCallback, { threshold: 0.1 });
nextTick(() => {
unref(observerInstance).observe(document.getElementById("_top"));
unref(observerInstance).observe(document.getElementById("_bottom"));
});
};
观察处理函数observerCallback:
const observerCallback = (entries: any[]) => {
entries.forEach((entry: any) => {
if (entry.isIntersecting && entry.target.id === "_bottom") {
//向下滚动
state.end = state.end + 10
//设置两倍,类似于无缝滚动。
state.start = state.end - 20
state.scrollOffset = state.start * 50
}
if (entry.isIntersecting && entry.target.id === "_top") {
//向上滚动
state.end = state.end === 20 ? 20 : state.end - 10 > 20 ? state.end - 10 : 20;
state.start = state.start === 0 ? 0 : state.start - 10 > 0 ? state.start - 10 : 0;
}
state.scrollOffset = state.start * 50
});
};
实现效果:
动态高度(新)
上面动态高度我们是用 onUpdate 钩子去实现高度更新的,但其实还可以使用 ResizeObserverapi去实现这个功能,ResizeObserver的作用就是目标元素大小的变化,可以用来取代resize方法。
有兴趣可以去了解下该api,这里就不做过多描述了,在虚拟滚动中,也可以通过该api去更新高度,可以自己动手实战下,这里就不做过多讲解了。
最后
到这里虚拟列表滚动就完成了,关于虚拟滚动我封装成了一个组件,代码放在vue3-baisc-admin里面了,有兴趣可以前去下载了解下,如果对你有帮助,可以点个赞,有问题可以评论区留言。
其他
vue3-baisc-admin 是一款开源开箱即用的中后台管理系统。基于 Vue3、Vite、Element-Plus、TypeScript、Pinia 等主流技术开发,内置许多开箱即用的组件,能快速构建中后台管理系统,目前决定完全开源。