之前我写了一篇文章:如何使用 IntersectionObserver API 来实现数据的懒加载 在文章的最后,我们提到如果加载的列表数据越来越多,我们不可能把所有的数据都渲染出来,因为这样会导致页面卡住甚至崩溃。

为了优化这种长列表场景,我们可以使用虚拟列表,核心思想是:仅渲染可视区域内(及其附近)的列表项。即不管列表有多少条数据,只取指定数量的项渲染,比如说 15 条,15 条足以覆盖可视区域以及其上下附近区域,当然这个数量视具体情况决定。

定高场景

定高的意思是我们提前知道每个列表项的高度,比如 100px。
假设我们现在有 10000 条数据,当用户上下滚动的时候,始终取对应的 15 条数据渲染。代码以 vue3 来示例。

思路分析

  • 定义 DOM 结构和滚动事件
    在这里插入图片描述
    在上述代码中,我们定义三层 DOM 结构,最外层是视口 viewport,次外层是所有列表项的父容器(这里设置其高度为整个列表内容的高度:style=“{ height: ${contentHeight}px }”),最里层是通过 v-for 循环生成的列表项。然后我们在视口层监听 scroll 事件。

  • 设置样式
    在这里插入图片描述
    这里视口的高度设置为 800px,列表项 content-item 的高度设置为 100px,且定位设为绝对定位,这很关键,因为上下滚动的时候每个列表项距离顶部的距离需要通过 top 这个定位属性来设置。

  • 渲染数据的截取
    我们整个列表 items 有 10000 条数据,当用户上下滚动的时候,我们需要知道当前位置需要截取列表中的哪 15 条数据?所以我们需要知道截取开始的位置 startIndexstartIndex 是动态变化的,它需要根据用户的滚动位置来计算。

    scrollTop 属性可以获取滚动条距离内容顶部的距离 scrollTop,scrollTop 是由 content-item 的高度撑起来的,那么 startIndex = scrollTop/100px(content-item) ,那么我们当前需要渲染的数据就是:renderItems = items.slice(startIndex, startIndex+15)

    具体代码如下:
    在这里插入图片描述
    在上述代码中,我们在使用 Array.form 方法生成列表数据的时候,每个 item 都设置了 top 属性,top = i * ITEM_HEIGHT,即第一个 item 距离顶部的距离为 0,第二个 item 距离顶部的距离为 100px,第三个 item 距离顶部的距离为 200px……,在 DOM 上设置 style::style=“{ top : ${item.top}px}” 可保证每个 item 处于正确的位置。

运行代码,上下滚动,可得到如下表现:

虚拟列表定高

在视频中我们可以看到,无论我们怎么滑动列表,最终都只会渲染 15 个列表项。

完整代码如下:

<template>
  <div 
    ref="viewportRef"
    class="viewport"
    @scroll="handleScroll"
  >
    <div 
      class="content-wrap"
      :style="{ height: `${contentHeight}px` }"
    >
      <div 
        v-for="item in renderItems"
        :key="item.id"
        :style="{ top : `${item.top}px`}"
        class="content-item"
      >
        <p>{{ item.text }}</p>
      </div>
    </div>
  </div>
</template>

<script setup>
import { computed, ref } from 'vue';

const ITEM_HEIGHT = 100; // 假设每个 item 的高度为 100px
const RENDER_SIZE = 15; // 假设每次渲染 15 条数据
// 假设 10000 条数据
const items = Array.from({ length: 10000 }, (v, i) => {
  return {
    id: i,
    text: `item-${i+1}`,
    top: i * ITEM_HEIGHT
  };
});

const startIndex = ref(0);
const viewportRef = ref(null);

const renderItems = computed(() => {
  const endIndex = startIndex.value + RENDER_SIZE;
  return items.slice(startIndex.value, endIndex);
});
// 整个列表的高度
const contentHeight = computed(() => {
  return items.length * ITEM_HEIGHT;
});

const handleScroll = () => {
  const scrollTop = viewportRef.value?.scrollTop;
  // 更新 startIndex
  startIndex.value = Math.floor(scrollTop / ITEM_HEIGHT);
}
</script>

<style>
.viewport {
  height: 800px;
  overflow-y: auto;
}
.content-wrap {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
.content-item {
  height: 100px; /* 假设每个项目高度为100px */
  position: absolute;
  width: 100%;
  border: 1px solid #000;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

不定高场景

不定高是我们不能提前知道每个列表项的高度,它的高度是动态变化的,具体多高由它的数据的多少决定,有些项数据比较多,那么它最终渲染出来的 DOM 高度就比较高。这种情况就需要我们动态去计算每个列表项的高度。

思路分析

  • 定义 DOM 结构和滚动事件
    在这里插入图片描述
    这里最外层视口层的结构和之前定高的情况是一样的,次外层有两个 DOM:一个是 content-placeholder ,用于占位,它的高度就是整个列表渲染后的高度。另一个是 content-wrap ,是当前渲染列表的父容器,注意它的样式::style=“{ transform: translateY(${offset}px) }”,offset 的值是动态计算的,为的是确保在滚动的过程中,当前渲染的列表处于整个列表中正确的位置。

    最里层是当前渲染的列表,注意,我们这里的 ref 使用了函数 :ref=“(el) => renderItemsRef(el, item.id)”renderItemsRef 函数中把已渲染的列表项真实的高度存下来,用于后续的计算。

  • 设置样式
    在这里插入图片描述
    占位元素是绝对定位的

  • 渲染数据的截取
    不定高的情况下要确认 startIndex 要比定高的情况复杂得多。
    同定高的情况一下,我同样需要 scrollTop 来用于 startIndex 的计算,观察下图:
    在这里插入图片描述
    观察上图得知,当前视口渲染的第一个列表项 curList1 在整个列表中的位置即为 startIndex,我们可以根据滚动条的位置(scrollTop)来计算,也就是说,我们需要确定在 curList1 之前有几个列表项,我们从索引 0 开始遍历整个列表,把每个列表项的高度相加,当 totalHeight >= scrollTop 的时候,我们就遍历到了 curList1 这个列表项,那么当前的索引 index 就是我们需要的 startIndex,具体代码如下:
    在这里插入图片描述
    上述代码中,allItems 是整个列表数据,具体如下:
    在这里插入图片描述
    allItems 这里我们设置了一个随机高度,模拟不定高的情况,但在实际场景中,height 应该设置为一个接近于 item 渲染后的真实高度,即这里的 ITEM_HEIGHT

    同时 hasRenderedItemsHeight 就是已经渲染的列表项的高度的集合,它是一个对象,key 是列表项,value 是列表项渲染后的高度,具体的赋值代码如下,也就是我们前面提到的 renderItemsRef 函数:
    在这里插入图片描述
    在上述代码中,每次有新的列表项渲染完成,我们都需要调用 updateRenderTotalHeight 函数去更新整个列表的实际高度。

    最后,我们在组件挂载的时候和滚动事件触发的时候调用 updateRenderItems 即可实现不定高的虚拟列表
    在这里插入图片描述

    运行代码,可得到如下表现:

虚拟列表不定高

完整代码如下:

<template>
  <div 
    ref="viewportRef"
    class="viewport"
    @scroll="handleScroll"
  >
    <div class="content-placeholder" :style="{ height: `${renderTotalHeight}px` }"></div>
    <div 
      class="content-wrap"
      :style="{ transform: `translateY(${offset}px)` }"
    >
      <div 
        v-for="item in renderItems"
        :ref="(el) => renderItemsRef(el, item.id)"
        :key="item.id"
        :style="{ height: `${item.height}px`}"
        class="content-item"
      >
        {{ item.text }}
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, onMounted, nextTick } from 'vue';

const RENDER_SIZE = 15; // 假设每次渲染 15 条数据
const ITEM_HEIGHT = 100; // 假设每个 item 真实渲染后高度接近 100px
// 假设 10000 条数据
const allItems = Array.from({ length: 10000 }, (v, i) => {
  return {
    id: i,
    text: `item-${i+1}`,
    // 设置随机高度, 在实际项目中应该根据 item 的实际情况设置一个接近于 item 渲染后的高度
    height: Math.floor(Math.random() * 100) + 50
  };
});

const viewportRef = ref(null);
const renderItems = ref([]); // 当前需要渲染的 item
const renderTotalHeight = ref(0); // 整个已渲染列表的高度
const hasRenderedItemsHeight = ref({}); // 已渲染的 item 数据 height 
const offset = ref(0);

const updateRenderItems = () => {
  const scrollTop = viewportRef.value?.scrollTop;
  
  let startIndex = 0;
  let startOffset = 0;

  for (let i = 0; i < allItems.length; i++) {
    const h = hasRenderedItemsHeight.value[allItems[i].id] || ITEM_HEIGHT;
    startOffset += h;
    if (startOffset >= scrollTop) {
      startIndex = i;
      break;
    }
  }

  renderItems.value = allItems.slice(startIndex, startIndex + RENDER_SIZE);
  offset.value = startOffset - hasRenderedItemsHeight.value[allItems[startIndex].id];
  
}

const renderItemsRef = (el, id) => {
 if (el) {
  // 存放已渲染的 item 的高度
  hasRenderedItemsHeight.value[id] = el.offsetHeight;
  // 更新容器的高度
  nextTick(updateRenderTotalHeight);
 }
}

const updateRenderTotalHeight = () => {
  renderTotalHeight.value = allItems.reduce((sum, item) => sum + (hasRenderedItemsHeight[item.id] || ITEM_HEIGHT), 0);
}

const handleScroll = () => {
  updateRenderItems();
}

onMounted(() => {
  updateRenderItems();
})
</script>

<style>
.viewport {
  height: 800px;
  overflow-y: auto;
  position: relative;
}
.content-placeholder {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
}

.content-item {
  width: 100%;
  border: 1px solid #000;
  display: flex;
  justify-content: center;
  align-items: center;
}
</style>

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐