虚拟列表解析与实现
我们知道,页面中如果渲染了大量的列表节点,页面渲染会变的很卡,特别是高频操作的时候,很容易变的比较卡顿。
行业内有一个很好的解决方案解决海量的列表渲染:虚拟列表,也叫虚拟滚动
工作原理
虚拟滚动的基本原理大概是:只渲染用户屏幕可以看到的所有节点,如果用户当前没看到的,则不渲染,然后监听滚动的事件,当用户滚动了,需要重新计算当前滚动条的状态下需要渲染的节点。
实现
有了原理,我们就可以写出这样的代码
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue';
const LINE_HEIGHT = 30;
const containerRef = ref<HTMLElement | null>(null);
const renderCount = ref(0);
// 数据类型是 [1, 2, 3, 4, 6, ...]
const props = defineProps<{
list: Array<number>
}>()
// 渲染的范围
const range = reactive({
startIndex: 0,
endIndex: 0
})
// 渲染的列表
const renderList = computed(() => {
return props.list.slice(range.startIndex, range.endIndex);
})
onMounted(() => {
// 挂载后根据容器高度渲染需要显示的内容。
const clientHeight = (containerRef.value as HTMLElement).clientHeight
renderCount.value = Math.ceil(clientHeight / LINE_HEIGHT)
range.startIndex = 0;
range.endIndex = renderCount.value
})
</script>
<template>
<div class="container" ref="containerRef">
<div class="list">
<div v-for="item in renderList" :key="item">{{ item }}</div>
</div>
</div>
</template>
<style>
.container {
height: 600px;
width: 280px;
overflow: auto;
border: 1px solid #e3e3e3;
line-height: 30px;
}
</style>
渲染出来的效果是这样:
入参的列表的长度是 2000,这里只渲染了页面容器大小的数量(20 个)。
那现在问题来了,现在没滚动条,无法滚动怎么办?
这里一般有两种解决方法,一种是在列表的上面和下面补充一个元素,然后设置高度撑开下面元素的高度,这样滚动条就有了。还有一种是设置这个列表的 padding,用 padding 撑开元素。
那 padding 的高度我们怎么知道呢,对于定高的列表,通常可以直接根据定高 * 未渲染的数量得出。至于不确定高度的、变高的元素我们后面再讨论。
<script setup lang="ts">
...
const padding = computed(() => {
return {
top: range.startIndex * LINE_HEIGHT,
bottom: (props.list.length - range.endIndex) * LINE_HEIGHT
}
})
</script>
<template>
<div class="container" ref="containerRef" >
<div class="list" :style="`padding-top: <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>p</mi><mi>a</mi><mi>d</mi><mi>d</mi><mi>i</mi><mi>n</mi><mi>g</mi><mi mathvariant="normal">.</mi><mi>t</mi><mi>o</mi><mi>p</mi></mrow><mi>p</mi><mi>x</mi><mo separator="true">;</mo><mi>p</mi><mi>a</mi><mi>d</mi><mi>d</mi><mi>i</mi><mi>n</mi><mi>g</mi><mo>−</mo><mi>b</mi><mi>o</mi><mi>t</mi><mi>t</mi><mi>o</mi><mi>m</mi><mo>:</mo></mrow><annotation encoding="application/x-tex">{padding.top}px; padding-bottom: </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="mord mathnormal">a</span><span class="mord mathnormal">dd</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord">.</span><span class="mord mathnormal">t</span><span class="mord mathnormal">o</span><span class="mord mathnormal">p</span></span><span class="mord mathnormal">p</span><span class="mord mathnormal">x</span><span class="mpunct">;</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">p</span><span class="mord mathnormal">a</span><span class="mord mathnormal">dd</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">b</span><span class="mord mathnormal">o</span><span class="mord mathnormal">tt</span><span class="mord mathnormal">o</span><span class="mord mathnormal">m</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span></span></span></span>{padding.bottom}px`">
<div v-for="item in renderList" :key="item">{{ item }}</div>
</div>
</div>
</template>
现在效果:
现在确实有滚动条了,那现在显示的内容应该根据滚动条变化做更新了:
...
const onScroll = (e: Event) => {
const top = (e.target as HTMLElement).scrollTop
const topItems = Math.floor(top / LINE_HEIGHT)
range.startIndex = topItems;
range.endIndex = topItems + renderCount.value;
}
<template>
<div class="container" ref="containerRef" @scroll="onScroll">
<div class="list" :style="`padding-top: <span class="katex"><span class="katex-mathml"><math xmlns="http://www.w3.org/1998/Math/MathML"><semantics><mrow><mrow><mi>p</mi><mi>a</mi><mi>d</mi><mi>d</mi><mi>i</mi><mi>n</mi><mi>g</mi><mi mathvariant="normal">.</mi><mi>t</mi><mi>o</mi><mi>p</mi></mrow><mi>p</mi><mi>x</mi><mo separator="true">;</mo><mi>p</mi><mi>a</mi><mi>d</mi><mi>d</mi><mi>i</mi><mi>n</mi><mi>g</mi><mo>−</mo><mi>b</mi><mi>o</mi><mi>t</mi><mi>t</mi><mi>o</mi><mi>m</mi><mo>:</mo></mrow><annotation encoding="application/x-tex">{padding.top}px; padding-bottom: </annotation></semantics></math></span><span class="katex-html" aria-hidden="true"><span class="base"><span class="strut" style="height:0.8889em;vertical-align:-0.1944em;"></span><span class="mord"><span class="mord mathnormal">p</span><span class="mord mathnormal">a</span><span class="mord mathnormal">dd</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mord">.</span><span class="mord mathnormal">t</span><span class="mord mathnormal">o</span><span class="mord mathnormal">p</span></span><span class="mord mathnormal">p</span><span class="mord mathnormal">x</span><span class="mpunct">;</span><span class="mspace" style="margin-right:0.1667em;"></span><span class="mord mathnormal">p</span><span class="mord mathnormal">a</span><span class="mord mathnormal">dd</span><span class="mord mathnormal">in</span><span class="mord mathnormal" style="margin-right:0.03588em;">g</span><span class="mspace" style="margin-right:0.2222em;"></span><span class="mbin">−</span><span class="mspace" style="margin-right:0.2222em;"></span></span><span class="base"><span class="strut" style="height:0.6944em;"></span><span class="mord mathnormal">b</span><span class="mord mathnormal">o</span><span class="mord mathnormal">tt</span><span class="mord mathnormal">o</span><span class="mord mathnormal">m</span><span class="mspace" style="margin-right:0.2778em;"></span><span class="mrel">:</span></span></span></span>{padding.bottom}px`">
<div v-for="item in renderList" :key="item">{{ item }}</div>
</div>
</div>
</template>
这里只需要更新需要渲染的范围就行了,padding 是 computed 属性,所以当 range 的值变化了,padding 会自动重新计算。
到现在为止,一个简易的虚拟滚动就实现了。
其他
当然,实际在应用的使用,虚拟列表还是有很多要处理的。
容器是慢慢增加的
容器一开始高度是 0,慢慢参数逐渐增加元素,容器存在一个 max-height, 处理方法是监听参数的长度,当长度小于的所有元素高度加起来的值,那直接全部渲染,并且 padding 不设置值,如果大于阈值,则切换为虚拟滚动列表(修改的部分代码):
const padding = computed(() => {
if (props.list.length <= renderCount) {
return {
top: 0,
bottom: 0,
};
}
return {
top: range.startIndex * LINE_HEIGHT,
bottom: (props.list.length - range.endIndex) * LINE_HEIGHT,
};
});
onMounted(() => {
if (props.list.length <= renderCount) {
range.startIndex = 0;
range.endIndex = props.list.length;
} else {
range.startIndex = 0;
range.endIndex = renderCount;
}
});
watch(
() => props.list.length,
() => {
// update endIndex
if (props.list.length <= renderCount) {
range.startIndex = 0;
range.endIndex = props.list.length;
} else if (range.endIndex - range.startIndex < renderCount) {
range.endIndex = range.endIndex + renderCount;
}
}
);
元素高度是未知的
对于高度如果是未知的,那可以设置一个预估值,这个预估值不要和实际相差太大即可,然后新建一个 map 去存储列表每个元素的高度。 计算 padding 的时候,不能直接根据下标去计算,要根据实际高度去计算,比如后面有 5 个元素,那要从 map 取出这 5 个元素的高度(如果缺省则用预估值)叠加计算。
那什么时候去获取元素高度呢,可以在 onUpdated 的钩子函数上做存储操作,我原来在业务中是直接在这个钩子上处理的。这里应该还可以用 MutationObserver 去做监听的,也能处理元素变高的情况,性能应该更好,具体没实现过。
总结
因为在工作中实现过一个虚拟列表,对里面具体原理有比较深刻的理解,知道原理了,基本实现出来难度不大,主要是业务是很复杂的,有各种情况和需求,需要做很多处理。