列表首屏毫秒级加载与自动滚动定位方案
场景
<template>
<div ref="commentsRef">
<div
v-for="comment in displayComments"
:key="comment.id"
:data-cell-id="comment.id"
class="card"
>
{{ comment.data }}
</div>
</div>
</template>
<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));
</script>
说明:需要一个场景,用户首屏需要渲染200条数据,且后续如果comments列表发生变动时,页面上能够保持当前显示的条目不进行滚动。
分批加载
如果直接全量数据加载,200个节点肯定不能在1帧内渲染完毕,这样会出现较长时间的白屏。这时候可以使用时间分片的思路,在每个小时间段内渲染少量节点,提高首屏的渲染效率。
<template>
<div>
<div
v-for="comment in displayComments.slice(0, loadCount)"
:key="comment.id"
>
{{ comment.data }}
</div>
</div>
</template>
<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const commentsRef = ref();
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));
// 首屏动态加载的数量
const loadCount = ref<number>(0);
// 如果少于20条,直接全量加载;否则每隔4ms渲染40条数据
watch(displayComments, () => {
clearInterval(timer);
loadCount.value = 20;
if (loadCount.value < nv.length) {
timer = setInterval(() => {
loadCount.value = Math.min(loadCount.value + 40, nv.length);
if (loadCount.value >= nv.length) {
clearInterval(timer);
}
}, 4);
}
})
</script>
滚动定位
实现在列表数据更新时自动定位到用户当前可视区域,尽可能保证可视区域的内容不因数据更新而发生剧烈抖动。
获取当前可视内容
通过浏览器提供的 IntersectionObserver API 监听列表中的元素,获取元素滚动时在可视区域内的元素,记录他们的id。
const visibleCardIds: Set<string> = new Set();
const intersection: IntersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((e) => {
const id = e.target.getAttribute('data-cell-id');
if (!id) return;
if (e.isIntersecting && e.intersectionRatio > 0.91) {
visibleCardIds.add(id);
} else {
visibleCardIds.delete(id);
}
});
},
{
threshold: [0, 0.9, 1],
},
);
const addIntersectionObserve = () => {
visibleCardIds.clear();
intersection?.disconnect();
nextTick(() => {
const nodes = document.body.querySelectorAll('.card') || [];
for (const node of nodes) {
intersection?.observe(node);
}
});
};
滚动处理
从上一次拿到的可视区域元素中拿到在更新后的数据中仍存在的、最靠近顶部的节点,记录它的位置,并把容器节点滚动到该节点的 scrollTop 高度。
const getNearestVisibleId = () => {
return displayComment.value.find(c => visibleCardIds.has(c.id));
}
const silenceScroll = () => {
const id = getNearestVisibleId();
nextTick(() => {
commentScrollTop(id);
});
};
const commentScrollTop = (id: string) => {
const n = commentsRef.value?.querySelector(
`[data-cell-id="${id}"]`,
) as HTMLElement;
if (n) {
commentsRef.value?.scrollTo({
top: n.offsetTop,
behavior: 'instant',
});
}
};
结合起来
在数据更新后的下一帧触发滚动即可实现定位效果
<template>
<div>
<div
v-for="comment in displayComments.slice(0, loadCount)"
:key="comment.id"
>
{{ comment.data }}
</div>
</div>
</template>
<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const commentsRef = ref();
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));
// 首屏动态加载的数量
const loadCount = ref<number>(0);
// 如果少于20条,直接全量加载;否则每隔4ms渲染40条数据
watch(displayComments, () => {
clearInterval(timer);
loadCount.value = 20;
if (loadCount.value < nv.length) {
timer = setInterval(() => {
loadCount.value = Math.min(loadCount.value + 40, nv.length);
if (loadCount.value >= nv.length) {
clearInterval(timer);
silenceScroll();
addIntersectionObserve();
}
}, 4);
} else {
silenceScroll();
addIntersectionObserve();
}
})
</script>
定位优化
首屏加载速度上去了,也实现了自动滚动到指定的条目位置。但这时候遇到个新问题,如果列表的数据发生了变动,比如从前面插入了一条新数据,那么这时候可视范围内的数据肯定会发生改变。
如果使用批量重绘,即使本次修改只有一条数据更新,会出现页面先重新渲染,等10+ms后再滚动到指定位置,中间会出现画面的闪动。
这时候可以利用vue VDom算法的一些小Trick。先判断前后数据的差异程度,如果差异数量小于一个阈值(比如小于10),那么可以借用vue的diff算法,尽可能保留原有节点,渲染少量新节点。这时候可以大大提升页面的渲染效率,即使全量渲染也不会有性能问题。
<template>
<div>
<div
v-for="comment in displayComments.slice(0, loadCount)"
:key="comment.id"
>
{{ comment.data }}
</div>
</div>
</template>
<script lang="ts" setup>
// 假设comment有2000条数据,首次只加载200条
const commentsRef = ref();
const comments = ref<{ id: string; data: string }[]>();
const displayComments = computed(() => comments.slice(0, 200));
// 首屏动态加载的数量
const loadCount = ref<number>(0);
// 如果少于20条,直接全量加载;否则每隔4ms渲染40条数据
watch(displayComments, () => {
clearInterval(timer);
const nSet = new Set(nv.map((v) => v.id));
const oSet = new Set(ov.map((v) => v.id));
const n = new Set([...nSet].filter((x) => !oSet.has(x)));
if (n.size <= 10) {
loadCount.value = nv.length;
silenceScroll();
addIntersectionObserve();
} else {
loadCount.value = 20;
if (loadCount.value < nv.length) {
timer = setInterval(() => {
loadCount.value = Math.min(loadCount.value + 40, nv.length);
if (loadCount.value >= nv.length) {
clearInterval(timer);
silenceScroll();
addIntersectionObserve();
}
}, 4);
} else {
silenceScroll();
addIntersectionObserve();
}
}
})
</script>
效果预览
在前面插入一个div节点,保持现有节点不动