什么是虚拟列表?

虚拟列表(Virtual List)本质就是仅将需要显示在视窗中的列表节点挂载到 DOM,以达到「减少一次性加载节点数量」和「减少滚动容器内总挂载节点数量」的目的。

通过「单个元素高度」计算当前列表全部加载时的高度作为「滚动容器」的「可滚动高度」,按该「可滚动高度」撑开「滚动容器」。并根据「当前滚动高度」,在「可视区域」内按需加载列表元素。

为什么需要虚拟列表?

在进行前端业务开发时,很容易遇到需要加载巨大列表的场景。比如微博的信息流、微信的朋友圈和直播平台的聊天框等,这些列表通常具有两个显著的特点:

  • 不能分页;
  • 只要用户愿意就可以无限地滚动下去。

在这种场景下,如果直接加载一个数量级很大的列表,会造成页面假死,使用传统的上拉分页加载模式或者 window.requestAnimationFrameopen in new window空闲加载模式可以在一定程度上缓解这种情况,但是在加载到一定量级的页面时,会因为页面同时存在大量的 DOM 元素而出现过渡占用内存、页面卡顿等性能问题,带来糟糕的用户体验。因此必须对这种业务场景做相应的加载优化,只加载需要显示的元素是这种情况的唯一解,「虚拟列表」的概念应运而生。

相关概念

  • 单个元素高度:列表内每个独立元素的高度,它可以是固定的或者是动态的。
  • 滚动容器:意指挂载列表元素的 DOM 对象,它可以是自定义的元素或者window对象(默认)。
  • 可滚动高度:滚动容器可滚动的纵向高度。当滚动容器的高度(宽度),小于它的子元素所占的总高度(宽度)且该滚动容器的overflow不为hidden时,此时滚动容器的scrollHeight可滚动高度
  • 可视区域:滚动容器的视觉可见区域。如果容器元素是window对象,可视区域就是浏览器的视口大小(即视觉视口);如果容器元素是某个 ul 元素,其高度是 300,右侧有纵向滚动条可以滚动,那么视觉可见的区域就是可视区域,也即是该滚动容器的offsetHeight
  • 当前滚动高度:与平常的滚动高度概念一致。虽然虚拟列表仅加载需要显示在可视区域内的元素,但是为了维持与常规列表一致的滚动体验,必须通过监听当前滚动高度来动态更新需要显示的元素。

mSZZPV

D972pD

实现逻辑

实现「虚拟列表」可以简单理解为就是在列表发生滚动时,改变「可视区域」内的渲染元素。大概的文字逻辑步骤如下:

  1. 根据单个元素高度计算出滚动容器的可滚动高度,并撑开滚动容器;
  2. 根据可视区域计算总挂载元素数量;
  3. 根据可视区域和总挂载元素数量计算头挂载元素(初始为 0)和尾挂载元素;
  4. 当发生滚动时,根据滚动差值和滚动方向,重新计算头挂载元素和尾挂载元素。

固定高度的虚拟列表

<div class="infinite-list-container">
  <div class="infinite-list-phantom"></div>
  <div class="infinite-list">
    <!-- item-1 -->
    <!-- item-2 -->
    <!-- ...... -->
    <!-- item-n -->
  </div>
</div>
  1. infinite-list-container可视区域的容器
  2. infinite-list-phantom 为容器内的占位,高度为总列表高度,用于形成滚动条
  3. infinite-list 为列表项的渲染区域

接着,监听infinite-list-containerscroll事件,获取滚动位置scrollTop

  • 假定可视区域高度固定,称之为screenHeight
  • 假定列表每项高度固定,称之为itemSize
  • 假定列表数据称之为listData
  • 假定当前滚动位置称之为scrollTop

则可推算出:

  • 列表总高度listHeight = listData.length * itemSize
  • 可显示的列表项数visibleCount = Math.ceil(screenHeight / itemSize)
  • 数据的起始索引startIndex = Math.floor(scrollTop / itemSize)
  • 数据的结束索引endIndex = startIndex + visibleCount
  • 列表显示数据为visibleData = listData.slice(startIndex,endIndex)

当滚动后,由于渲染区域相对于可视区域已经发生了偏移,此时我需要获取一个偏移量startOffset,通过样式控制将渲染区域偏移至可视区域中。

  • 偏移量startOffset = scrollTop - (scrollTop % itemSize);

![iShot2022-02-1711.20.30](https://images-1256612942.cos.ap-guangzhou.myqcloud.com/iShot2022-02-17 11.20.30.gif)

代码实现

<template>
  <section class="infinite-list-container" ref="instance" @scroll="handleScroll">
    <div class="infinite-list-phantom" :style="{ height: listHeight + 'px' }"></div>
    <div class="infinite-list" :style="{ transform: getTransform }">
      <div ref="items" class="infinite-list-item" v-for="(item, index) in visibleData" :key="index" :style="{ height: ITEMSIZE + 'px', lineHeight: ITEMSIZE + 'px' }">{{ item }}</div>
    </div>
  </section>
</template>
<script lang="ts" setup>
import { start } from 'repl';
import { defineComponent, onMounted, ref, computed, getCurrentInstance, ComponentPublicInstance, VNode } from 'vue';

const ITEMSIZE = 30;

const instance = ref<HTMLElement>();

const listData = Array.from({ length: 1000000 }).map(() => Math.floor(Math.random() * 10000));
const visibleData = ref<number[]>([]);

// 列表总高度
const listHeight = listData.length * ITEMSIZE;
// 可视区高度
const screenHeight = ref(0);
// 可显示的列表数
const visibleCount = computed(() => {
  return Math.ceil(screenHeight.value / ITEMSIZE);
});
// 偏移量对应的style
const getTransform = computed(() => {
  return `translate3d(0, ${startOffset.value}px, 0)`;
});

const startOffset = ref(0);
const startIndex = ref(0);
const endIndex = ref(0);

const handleScroll = () => {
  let scrollTop = instance.value!.scrollTop;
  startIndex.value = Math.floor(scrollTop / ITEMSIZE);
  endIndex.value = startIndex.value + visibleCount.value;
  visibleData.value = listData.slice(startIndex.value, endIndex.value);
  startOffset.value = scrollTop - (scrollTop % ITEMSIZE);
};

onMounted(() => {
  screenHeight.value = instance.value!.clientHeight;
  endIndex.value = startIndex.value + visibleCount.value;
  handleScroll()
});
</script>
<style scoped>
.infinite-list-container {
  position: relative;
  height: 400px;
  overflow-x: hidden;
  overflow-y: auto;
  border: 1px solid skyblue;
}
.infinite-list-phantom {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: -1;
}
</style>

动态高度的虚拟列表

在虚拟列表中应用动态高度的解决方案一般有如下三种:

1.对组件属性itemSize进行扩展,支持传递类型为数字数组函数

  • 可以是一个固定值,如 100,此时列表项是固高的
  • 可以是一个包含所有列表项高度的数据,如 [50, 20, 100, 80, ...]
  • 可以是一个根据列表项索引返回其高度的函数:(index: number): number

这种方式虽然有比较好的灵活度,但仅适用于可以预先知道或可以通过计算得知列表项高度的情况,依然无法解决列表项高度由内容撑开的情况。

2.将列表项渲染到屏幕外,对其高度进行测量并缓存,然后再将其渲染至可视区域内。

由于预先渲染至屏幕外,再渲染至屏幕内,这导致渲染成本增加一倍,这对于数百万用户在低端移动设备上使用的产品来说是不切实际的。

3.以预估高度先行渲染,然后获取真实高度并缓存。

选择这个方案实现,可以避免前两种方案的不足。

面向未来

在前文中我们使用监听scroll事件的方式来触发可视区域中数据的更新,当滚动发生后,scroll事件会频繁触发,很多时候会造成重复计算的问题,从性能上来说无疑存在浪费的情况。

可以使用IntersectionObserveropen in new window替换监听scroll事件,IntersectionObserver可以监听目标元素是否出现在可视区域内,在监听的回调事件中执行可视区域数据的更新,并且IntersectionObserver的监听回调是异步触发,不随着目标元素的滚动而触发,性能消耗极低。

遗留问题

我们虽然实现了根据列表项动态高度下的虚拟列表,但如果列表项中包含图片,并且列表高度由图片撑开,由于图片会发送网络请求,此时无法保证我们在获取列表项真实高度时图片是否已经加载完成,从而造成计算不准确的情况。

这种情况下,如果我们能监听列表项的大小变化就能获取其真正的高度了。我们可以使用ResizeObserveropen in new window来监听列表项内容区域的高度改变,从而实时获取每一列表项的高度。

上次更新:
贡献者: chenzilin