Skip to content

虚拟列表

面试题:一次性给你 10000 条数据,前端怎么渲染到页面上?

长列表常见的 3 种处理方式:

  1. 懒加载
  2. 时间分片
  3. 虚拟列表

懒加载

懒加载的原理在于:只有视口内的内容会被加载,其他内容在用户滚动到视口时才会被加载。这可以显著减少初次加载的时间,提高页面响应速度。这样的好处在于:

  1. 节省带宽
  2. 提升用户体验

懒加载的缺点:懒加载只能应对数据不是太多的情况。如果列表项有几万甚至几十万项,最终会有对应数量的 DOM 存在于页面上,严重降低页面性能。

时间分片

时间分片的本质是通过 requestAnimationFrame,由浏览器来决定回调函数的执行时机。大量的数据会被分多次渲染,每次渲染对应一个片段。在每个片段中处理定量的数据后,会将主线程还给浏览器,从而实现快速呈现页面内容给用户。

时间分片的缺点:

  1. 效率低
  2. 不直观
  3. 性能差

总结:无论是懒加载还是时间分片,最终都是将完整数量的列表项渲染出来,这在面对列表项非常非常多的时候,页面性能是比较低的。

虚拟列表

原理:设置一个可视区域,然后用户在滚动列表的时候,本质上是动态修改可视区域里面的内容

例如,一开始渲染前面 5 个项目

image-20240701162418114

之后用户进行滚动,就会动态的修改可视区域里面的内容,如下图所示:

image-20240701162813149

也就是说,始终渲染的只有可视区的那 5 个项目,这样能够极大的保障页面性能。

实现:实现定高的虚拟列表,这里所指的定高是说列表项的每一项高度相同

涉及到的信息:

  1. 可视区域起始数据索引(startIndex)
  2. 可视区域结束数据索引(endIndex)
  3. 可视区域的数据
  4. 整个列表中的偏移位置 startOffset

如下图所示:

image-20240701164454859

接下来整个虚拟列表的设计如下:

html
<div class="infinite-list-container">
  <!-- 该元素高度为总列表的高度,目的是为了形成滚动 -->
  <div class="infinite-list-phantom"></div>
  <!-- 该元素为可视区域,里面就是一个一个列表项 -->
  <div class="infinite-list">
    <!-- item-1 -->
    <!-- item-2 -->
    <!-- ...... -->
    <!-- item-n -->
  </div>
</div>
  • infinite-list-container:可视区域的容器
  • infinite-list-phantom:容器内的占位,高度为总列表高度,用于形成滚动条
  • infinite-list:列表项的渲染区域

如下图所示:

image-20240701165847905

接下来监听 infinite-list-container 的 scroll 事件,获取滚动位置的 scrollTop,因为回头需要设置 list 向下位移的距离

  • 假定可视区域高度固定,称之为 screenHeight
  • 假定列表每项高度固定,称之为 itemSize
  • 假定列表数据称之为 listData(就是很多的列表数据,几万项、几十万项列表数据)
  • 假定当前滚动位置称之为 scrollTop

那么我们能够计算出这么一些信息:

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

当发生滚动后,由于渲染区域相对于可视区域发生了偏移,因此我们需要计算出这个偏移量,然后使用 transform 重新偏移回可视区域。

偏移量的计算:startOffset = scrollTop - (scrollTop % itemSize)

思考 🤔:为什么要减去 scrollTop % itemSize ?

答案:之所以要减去 scrollTop % itemSize,是为了将 scrollTop 调整到一个与 itemSize 对齐的位置,避免显示不完整的列表项。

image-20240701170126764

实战

实战演练:实现定高的虚拟列表

定高的虚拟列表存在一些问题,或者说可以优化的地方:

  1. 动态高度
  2. 白屏现象
  3. 滚动事件触发频率过高

VituralList.vue

vue
<template>
  <div class="infinite-list-container" ref="listRef" @scroll="onScroll">
    <!-- 该元素高度为总列表的高度,目的是为了形成滚动 -->
    <div
      class="infinite-list-phantom"
      :style="{ height: listHeight + 'px' }"
    ></div>
    <!-- 该元素为可视区域,里面就是一个一个列表项 -->
    <div class="infinite-list" :style="{ transform: getTranslateY }">
      <div
        class="infinite-list-item"
        v-for="item in visibleData"
        :key="item.id"
        :style="{
          height: `${itemSize}px`,
          lineHeight: `${itemSize}px`,
        }"
      >
        {{ item.value }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, computed, onMounted } from "vue";
const props = defineProps({
  data: {
    type: Array,
    default: () => [],
  },
  itemSize: {
    type: Number,
    default: 100,
  },
});

// 起始/结束索引
const startIndex = ref(0);
const endIndex = ref(0);

// 视口的高度/其实就是infinite-list-container可视高度
const screenHeight = ref(0);
const listRef = ref(null);

// 列表的总高度
const listHeight = computed(() => {
  return props.data.length * props.itemSize;
});

// 可显示的列表项数
const visibleCount = computed(() => {
  return Math.ceil(screenHeight.value / props.itemSize);
});

// 真正显示的列表项
const visibleData = computed(() => {
  //这里 取最小值是为了防止超出数组长度,因为endIndex是起始+可渲染数
  //如果此时渲染到倒数第二个在页面的第一条,那么就会出现endIndex超出数组长度的情况
  return props.data.slice(
    startIndex.value,
    Math.min(endIndex.value, props.listData.length)
  );
});

// 滚动事件
const startOffset = ref(0);
const getTranslateY = computed(() => {
  return `translateY(${startOffset.value}px)`;
});
const onScroll = () => {
  // 更新偏移量和起始结束索引
  let scrollTop = listRef.value.scrollTop;
  startIndex.value = Math.floor(scrollTop / props.itemSize);
  endIndex.value = startIndex.value + visibleCount.value;
  //取余是为了将scrollTop调整到对应的itemSize对齐的位置,避免显示不完整的itemSize
  startOffset.value = scrollTop - (scrollTop % props.itemSize);
};

onMounted(() => {
  screenHeight.value = listRef.value.clientHeight;
  startIndex.value = 0;
  endIndex.value = startIndex.value + visibleCount.value;
});
</script>

<style scoped>
.infinite-list-container {
  height: 100%;
  overflow: auto;
  position: relative;
  -webkit-overflow-scrolling: touch;
}

.infinite-list-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1;
}

.infinite-list {
  left: 0;
  right: 0;
  top: 0;
  position: absolute;
  text-align: center;
}

.infinite-list-item {
  padding: 10px;
  color: #555;
  box-sizing: border-box;
  border-bottom: 1px solid #999;
}
</style>

App.vue

vue
<template>
  <VitureList :data="data" :itemSize="100" />
</template>

<script setup>
import { ref } from "vue";
import VitureList from "./components/VitureList.vue";

// 创建一个长列表的数据
let d = [];
for (let i = 0; i < 10000; i++) {
  d.push({
    id: i + 1,
    value: `第${i + 1}条数据`,
  });
}
const data = ref(d);
</script>

<style>
html {
  height: 100%;
}
body {
  height: 100%;
  margin: 0;
}
#app {
  height: 100%;
}
</style>

-EOF-

MIT License