头像

zjian

Just Code!

帖子照片墙关于

虚拟列表解析与实现

2023-11-11 01:13

我们知道,页面中如果渲染了大量的列表节点,页面渲染会变的很卡,特别是高频操作的时候,很容易变的比较卡顿。

行业内有一个很好的解决方案解决海量的列表渲染:虚拟列表,也叫虚拟滚动

工作原理

虚拟滚动的基本原理大概是:只渲染用户屏幕可以看到的所有节点,如果用户当前没看到的,则不渲染,然后监听滚动的事件,当用户滚动了,需要重新计算当前滚动条的状态下需要渲染的节点。

实现

有了原理,我们就可以写出这样的代码

<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>

渲染出来的效果是这样:

VirtualList

入参的列表的长度是 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>

现在效果:

VirtualList1

现在确实有滚动条了,那现在显示的内容应该根据滚动条变化做更新了:

...
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 去做监听的,也能处理元素变高的情况,性能应该更好,具体没实现过。

总结

因为在工作中实现过一个虚拟列表,对里面具体原理有比较深刻的理解,知道原理了,基本实现出来难度不大,主要是业务是很复杂的,有各种情况和需求,需要做很多处理。