前言
在日常工作中难免会遇到大量数据渲染的情况,刷不到底的新闻,无尽图片瀑布流、超级超级长的排行榜等等。对于这种场景,我们不可能一次性加载完所有数据,同时请求如此多的数据,渲染大量的元素,对用户体验和应用性能都不友好。
对于长列表的优化一般都有以下三种:
- 分页加载:实现简单直接,但用户使用需频繁切换页码,体验不是最佳。
- 懒加载:实现难度不大,一定程度上解决首屏压力,但随着长时间加载数据,页面存在大量元素节点(未及时销毁的过期节点),从而影响应用页面性能。
- 虚拟列表:实现难度较大,通过监听计算滚动位置,每次渲染一定量的元素节点,对于用户来说是无感刷新,所以该方案可以满足上述大部分场景。
虚拟列表
其核心思想就是在处理用户滚动时,只改变列表在可视区域的渲染部分,然后使用padding或者translate来让渲染的列表偏移到可视区域中,给用户平滑滚动的感觉。
原理
从上图可以发现,实际上用户每次能看到的其实只有item10 - item14 5个元素。所以列表每次总是只渲染 5 + 4(缓冲区元素)个元素,这就是虚拟列表的基本原理。
虚拟列表由虚拟区、缓冲区和可视区组成。其中虚拟区的元素不渲染,缓冲区是为了解决快速滚动时候存在白屏问题。
固定高度
核心步骤
-
根据容器的高度,计算出所在可视区展示的元素个数,以及初始化列表高度
初始化列表高度 = 列表总数据 x 元素高度
可视区展示的元素个数 = Math.ceil ( 可视区高度 / 元素高度 )
-
初始化数据,更新渲染方法,设置缓冲区域
-
监听滚动事件,根据滚动后的scrollTop计算出新的开始和结束索引
实现原理 因为元素是定高,所以很容易就得出正常渲染时候列表容器高度。然后把该值赋给列表的外层容器(用于模拟正常滚动的一个容器),然后渲染的元素节点通过设置top值(此处用的是top,也可以通过translate实现)去模拟元素在正常列表的位置,从而实现模拟滚动的效果。
元素top: (循环渲染时的index + 可视区开始索引) x 元素高度
ts
import { reactive, watch } from 'vue';
// height 可视区高度 rowHeight 元素高度 bufferSize 缓存个数 allList 数据总列表
// ele 监听滚动的容器的id
// callback 渲染列表数据改变触发回调
export default function useVirtualList({ height, rowHeight, bufferSize, allList }, ele, callback) {
const virtual = reactive({
list: [], // 总列表
total: 0, // 总数量
limit: 0, // 在可视区展示的元素个数
originStartIdx: 0, // 原始开始索引
startIndex: 0, // 开始索引
endIndex: 0, // 结束索引
});
const init = () => {
virtual.list = allList;
virtual.total = virtual.list.length;
virtual.limit = Math.ceil(height / rowHeight);
virtual.originStartIdx = 0;
virtual.startIndex = Math.max(virtual.originStartIdx - bufferSize, 0);
virtual.endIndex = Math.min(virtual.originStartIdx + virtual.limit + bufferSize, virtual.total);
updateDisplayList(virtual.startIndex, virtual.endIndex);
document.getElementById(ele)?.addEventListener('scroll', scrollChange);
};
const scrollChange = e => {
const { scrollTop } = e.target;
const { total, limit, originStartIdx } = virtual;
//计算当前的startIndex
const currentIndex = Math.floor(scrollTop / rowHeight);
if (originStartIdx !== currentIndex) {
virtual.originStartIdx = currentIndex;
virtual.startIndex = Math.max(currentIndex - bufferSize, 0);
virtual.endIndex = Math.min(currentIndex + limit + bufferSize, total);
updateDisplayList(virtual.startIndex, virtual.endIndex);
}
};
const updateDisplayList = (sIdx, eIdx) => {
callback(virtual.list.slice(sIdx, eIdx));
};
init();
watch(
() => allList,
() => {
init();
},
{ deep: true },
);
return virtual;
}
在组件调用hook表现
ts
<template>
<div class="more-log">
<el-dialog
width="70%"
style="height: 700px; overflow-y: scroll"
v-model="visibleRef"
destroy-on-close
:close-on-click-modal="false"
>
<el-timeline
id="scrollContainer"
class="scroll-container"
:style="{
height: listDescribe.height + 'px',
}"
>
<div class="wrapper" :style="{ height: virtual.total * listDescribe.rowHeight + 'px' }">
<el-timeline-item
v-for="(item, index) in displayListRef"
class="item"
:key="item.id"
placement="top"
hollow
hide-timestamp
:style="{
height: listDescribe.rowHeight + 'px',
top:
index * listDescribe.rowHeight + virtual.startIndex * listDescribe.rowHeight + 'px',
}"
>
<log :info="item" />
</el-timeline-item>
</div>
</el-timeline>
</el-dialog>
</div>
</template>
<script lang="ts" setup>
import Log from '@/components/behaviorTrajectory/Log.vue';
import useVirtualList from '@/hooks/useVirtualList';
import { reactive, ref, nextTick } from 'vue';
const visibleRef = ref(false);
const displayListRef = ref();
const listDescribe = reactive({
list: [] as any,
height: 600, // 可视区高度
rowHeight: 400, // 行数据高度
bufferSize: 2, // 缓存个数
});
const virtual: any = useVirtualList(
{
height: listDescribe.height,
rowHeight: listDescribe.rowHeight,
bufferSize: listDescribe.bufferSize,
allList: listDescribe.list,
},
'scrollContainer',
list => {
displayListRef.value = list;
},
);
const init = list => {
visibleRef.value = true;
nextTick(() => {
listDescribe.list.push(...list);
});
};
defineExpose({ init });
</script>
<style lang="less" scoped>
.scroll-container {
overflow: hidden auto;
}
.wrapper {
position: relative;
}
.item {
width: 96%;
left: 0;
right: 0;
position: absolute;
}
</style>
不定高度
实现原理 实现的方法有很多种,这里采用的是利用 IntersectionObserverAPI 和分页,监听列表顶部和底部出现时机进行数据的切换。
前置知识 IntersectionObserverAPI
Intersection Observer API 提供了一种异步检测目标元素与祖先元素或 viewport 相交情况变化的方法。
这是官方描述,其实他的作用就是用来监听一个元素在容器的显示/隐藏。传统的实现方法是,监听到scroll
事件后,调用目标元素(绿色方块)的getBoundingClientRect()
方法,得到它对应于视口左上角的坐标,再判断是否在视口之内。这种方法的缺点是,由于scroll
事件密集发生,计算量很大,容易造成性能问题。
目前有一个新的 IntersectionObserver API,可以自动"观察"元素是否可见,Chrome 51+ 已经支持。由于可见(visible)的本质是,目标元素与视口产生一个交叉区,所以这个 API 叫做"交叉观察器"。
API
js
// 创建实例
const observer = new IntersectionObserver(callback, option);
// 开始观察
observer.observe(document.getElementById('example'));
// 停止观察
observer.unobserve(element);
// 关闭观察器
observer.disconnect();
// 上面代码中,`observe`的参数是一个 DOM 节点对象。如果要观察多个节点,就要多次调用这个方法。
observer.observe(elementA);
observer.observe(elementB);
callback参数
目标元素的可见性变化时,就会调用观察器的回调函数callback
。
callback
一般会触发两次。一次是目标元素刚刚进入视口(开始可见),另一次是完全离开视口(开始不可见)。
ini
var io = new IntersectionObserver(
entries => {
console.log(entries);
}
);
上面代码中,回调函数采用的是箭头函数的写法。callback
函数的参数(entries
)是一个数组,每个成员都是一个IntersectionObserverEntry
对象。举例来说,如果同时有两个被观察的对象的可见性发生变化,entries
数组就会有两个成员。
实现逻辑在列表顶部和底部各添加一个div,然后通过IntersectionObserverAPI监听这两个元素的出现和隐藏,从而判断用户的行为并更改页码,进而改变渲染的列表数据。
列表滚动方向有两个:上和下
- 往下
- 当前显示的数据不是列表最后一页,且当前页码大于2时候,加载下一页数据并删除前面(一个分页大小)的数据
- 为最后一页数据,不做处理
- 往上
- 当前页码为1或2的时候,不做处理
- 当前页码为3时候,展示第一页的数据,页码为1(这里是因为当时开发需求时候,展示第二页数据的同时会展示第一页)
- 当前页码大于3时候,列表数组unshift上一页的数据,最后重置一下scrollTop
代码实践
ts
<template>
<div
ref="scrollWrapperEle"
class="scroll-wrapper"
:style="{ maxHeight: (maxHeight ? maxHeight : 600) + 'px' }"
>
<div class="scroll-header" ref="scrollHeaderEle"></div>
<div ref="scrollContentEle">
<slot :renderList="virtual.curDisplayList"></slot>
</div>
<div class="scroll-loading" ref="scrollLoadingEle" v-show="hasMoreData">
正在努力加载更多数据中...
</div>
<div class="no-more" v-show="virtual.curDisplayList.length && !hasMoreData">全部加载完成~</div>
<div class="no-data" v-if="!virtual.curDisplayList.length && !error">
<el-empty description="暂无更多数据" />
</div>
<div class="err-warp" v-if="error">
<div class="err">
<el-icon :size="180" color="#C0C4CC"><i-ep-FolderDelete /></el-icon>
<p>出错啦~</p>
</div>
</div>
</div>
</template>
<script lang="ts" setup>
import { computed, onMounted, reactive, ref, watch } from 'vue';
const props = defineProps<{
perPage?: number; // 分页大小
maxHeight?: number; // 滚动容器的最大高度
list: any[]; // 总列表
loading: boolean; // 数据是否加载中
error: boolean; // 数据是否有问题(接口报错)
}>();
const scrollWrapperEle = ref();
const scrollContentEle = ref();
const emit = defineEmits(['updateLoading']);
// 交叉观察器
const intersectionObserver = new IntersectionObserver(entries => {
entries.forEach(it => {
// 触发目标为底部加载更多容器
if (it.target.className === 'scroll-loading') {
// 数据加载完毕前不触发
if (it.isIntersecting && !props.loading) {
const { displayList, listPage } = virtual;
const { page, perPage, total } = listPage;
// 当前显示的数据不是列表最后一页
if (page < Math.ceil(total / perPage)) {
virtual.curDisplayList = [
...virtual.curDisplayList,
...displayList.slice(page * perPage, (page + 1) * perPage),
];
if (page > 2) {
// 展示数据为第三页时候开始删除前面的数据
virtual.curDisplayList.splice(0, perPage);
}
listPage.page++;
}
}
} else if (it.target.className === 'scroll-header') {
// 触发目标为顶部空白容器
if (it.isIntersecting && !props.loading) {
const { displayList, listPage } = virtual;
const { page, perPage, total } = listPage;
if (page === 3) {
virtual.curDisplayList.splice(-2 * perPage, 2 * perPage);
listPage.page -= 2;
}
if (page > 3) {
emit('updateLoading', true);
scrollWrapperEle.value.style.overflowY = 'hidden'; // 禁止滚动
virtual.curDisplayList = [
...displayList.slice((page - 4) * perPage, (page - 3) * perPage),
...virtual.curDisplayList,
];
// 当前显示为最后一页数据
if (page >= Math.ceil(total / perPage)) {
// 最后一页数据可能小于分页数
virtual.curDisplayList.splice(-perPage, total - (page - 1) * perPage);
} else {
virtual.curDisplayList.splice(-perPage, perPage);
}
// 需要计算更新后的dom的真实高度 这里用setTimeout0
setTimeout(() => {
let parentNodeType = scrollContentEle.value.children[0].nodeName;
let nodes;
if (parentNodeType === 'UL') {
nodes = scrollContentEle.value.children[0].children;
} else if (parentNodeType === 'DIV') {
nodes = scrollContentEle.value.children;
}
let nodesHeight = 0;
for (let i = 0; i < perPage; i++) {
nodesHeight += nodes[i].offsetHeight;
}
scrollWrapperEle.value.scrollTop = nodesHeight;
setTimeout(() => {
emit('updateLoading', false);
scrollWrapperEle.value.style.overflowY = 'auto';
}, 600);
}, 0);
listPage.page--;
}
}
}
});
});
interface ListPage {
page: number;
perPage: number;
total: number;
}
interface Virtual {
displayList: any[];
curDisplayList: any[];
listPage: ListPage;
}
const virtual: Virtual = reactive({
displayList: [],
curDisplayList: [],
listPage: {
page: 1,
perPage: 3,
total: 0,
},
});
const hasMoreData = computed(() => {
return virtual.listPage.page * virtual.listPage.perPage < virtual.listPage.total;
});
const initPagingInfo = () => {
let { listPage, displayList } = virtual;
listPage.page = 1;
listPage.total = displayList.length;
if (listPage.total <= listPage.perPage) {
virtual.curDisplayList = [...displayList];
} else {
virtual.curDisplayList = [...displayList.slice(0, listPage.perPage)];
}
};
const scrollLoadingEle = ref();
const scrollHeaderEle = ref();
onMounted(() => {
if (props.perPage) {
virtual.listPage.perPage = props.perPage;
}
if (props.list.length) {
virtual.displayList = JSON.parse(JSON.stringify(props.list));
initPagingInfo();
}
// 开始观察
intersectionObserver.observe(scrollLoadingEle.value);
intersectionObserver.observe(scrollHeaderEle.value);
});
watch(
() => props.list,
list => {
virtual.displayList = JSON.parse(JSON.stringify(list));
initPagingInfo();
scrollWrapperEle.value.scrollTop = 0; // 重置滚动高度,避免出现数据源切换导致滚动条位置错误
},
);
</script>
<style lang="less" scoped>
.no-more {
font-size: 16px;
text-align: center;
color: #c0c4cc;
}
.err-warp {
height: 400px;
display: flex;
justify-content: center;
align-items: center;
.err {
text-align: center;
font-size: 24px;
color: #c0c4cc;
}
}
.scroll-wrapper {
width: 100%;
overflow-x: hidden;
overflow-y: auto;
}
.scroll-header {
height: 30px;
}
.scroll-loading {
height: 30px;
text-align: center;
font-size: 24px;
}
</style>
父级调用
js
<dynamic-virtual-list
:list="trajectory.displayList"
:loading="trajectory.loading"
:error="trajectory.err"
v-slot="slotProps"
@updateLoading="bool => (trajectory.loading = bool)"
>
{{ slotProps.renderList }}
</dynamic-virtual-list>
缺点 因为滚动高度会重新计算,而快速拖动滚动条可能会导致页面位置错乱。解决方案有两种,一种是直接隐藏滚动条,只开放鼠标滚动,一种则是在加载数据时候添加一个loading,loading时候隐藏滚动条,数据渲染完重新出现。这里采用的是后者。
总结
- 本篇文章只是实现了最简单、最基础的虚拟列表(它的玩法有很多)。
- 其实
虚拟列表的本质就是固定dom数量
, 只要你能够分批渲染大数据量的list,并且能够保证dom数量固定,那么你实现的就是虚拟列表。