面试官:如何设计一个不定高的虚拟列表

566 阅读3分钟

前言

虚拟列表(Virtual List)是什么?这是一种前端性能优化的技术,主要是应用于渲染超长列表的场景。其核心原理是:只渲染可视区域内的列表项,而非全部数据,从而大幅度地减少了DOM节点的数量,提升页面性能。

最近刚好了解到虚拟列表的相关内容,虚拟列表主要有定高和不定高两种情景,其实现方法还挺有意思的。前面研究了定高的虚拟列表的实现方法,即在每次滚动页面时,更新 startIndex 和 endIndex 的值,计算列表的Y轴偏移量 offset ,通过设置样式 { transform: `translateY(${offset}px)` }实现虚拟滚动效果。

本文是关于实现一个不定高的虚拟列表,即列表的每一项是不固定高度的,这种场景在实际中是普遍存在的。

示意图

不定高的虚拟列表的示意图如下所示。

不定高的虚拟列表的示意图.png

主要分为以下几个部分:

  • 外部容器:固定高度(即可视区域的高度),超出隐藏;监听页面的 scroll 滚动事件。

  • 占位元素区域:设置的是所有内容的高度,用于撑开滚动区域,其高度会远远大于外部容器的高度以实现滚动的效果。

  • 实际列表区域:实际渲染的列表内容是很少的,通过 transform 属性模拟滚动效果。

接下来详细分析其实现原理。

实现原理

首先,页面框架是这样设置的:容器是 .virtual-scroll,占位元素是 .scroll-phantom,实际渲染的列表是 .scroll-content,其中,每一项是<div class="item"></div>。而 totalHeight 是占位元素的总高度,是动态设置的。

<template>
  <div class="virtual-scroll" ref="container" @scroll="handleScroll">
    <div class="scroll-phantom" :style="{ height: `${totalHeight}px` }"></div>

    <div class="scroll-content" :style="{ transform: `translateY(${offset}px)` }">
      <div 
        v-for="item in visibleData"
        :key="item.id"
        class="item"
        :style="{ height: `${item.height}px`}"
        :ref="(el) => renderItemsRef(el, item.id)"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

<style>
.virtual-scroll {
  height: 100%;
  overflow-y: auto;
  position: relative;
}

.scroll-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
}

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

其次,我们生成全部的模拟数据 allData,每项数据的高度是不固定的,用属性 height 表示,是用 Math.random() 随机生成的。可视区域的数据 visibleData 获取的是 allData 的 [startIndex, endIndex] 范围的数据。

再次,总高度 totalHeight,是对于模拟数据 allData 的每一项的高度进行累加得到的。

那么,我们会有以下几个疑问:

1、我们需要考虑的是如何确定 startIndex 和 endIndex 的值?

将 startIndex 初始化为 0,在每次滚动页面时,动态更新。而 endIndex 的值为 startIndex + visibleCount,这里的 visibleCount 设置为 15。

由于每一项的高度不固定,因此我们无法通过 滚动距离/每一项的高度 直接获取 startIndex 的值。在每次滚动时,我们通过二分查找找到第一个超过当前滚动位置的项,也就是 startIndex 的值。使用二分查找的方法更加高效,时间复杂度是 O(nlogn) 而非 O(n)。

2、我们怎么模拟滚动效果?

对于实际渲染的列表 .scroll-content ,通过设置样式{ transform: `translateY(${offset}px)` },offset 就是页面向上滑动的距离,在每次滚动页面时,动态更新即可。这里跟定高的虚拟列表是一样的。

3、怎么获取 offset 的值?

由于我们已经预先计算并缓存所有数据项的累计高度(accumulatedHeights 数组),因此得到 startIndex 的值后,就能直接得到 offset 的值:

offset.value = startIndex > 0 ? accumulatedHeights.value[startIndex - 1] : 0

至此,核心原理就是这样。

理解了上面的几个问题,也就不难掌握不定高的虚拟列表的实现方法了。

完整代码

完整的示例代码如下:

<template>
  <div class="virtual-scroll" ref="container" @scroll="handleScroll">
    <div class="scroll-phantom" :style="{ height: `${totalHeight}px` }"></div>

    <div class="scroll-content" :style="{ transform: `translateY(${offset}px)` }">
      <div 
        v-for="item in visibleData"
        :key="item.id"
        class="item"
        :style="{ height: `${item.height}px`}"
        :ref="(el) => renderItemsRef(el, item.id)"
      >
        {{ item.content }}
      </div>
    </div>
  </div>
</template>

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

const visibleCount = 15 // 假设每次渲染 15 条数据
const ITEM_HEIGHT = 100 // 假设每个 item 真实渲染后高度接近 100px

// 假设 10000 条数据
const allData = Array.from({ length: 10000 }, (v, i) => {
  return {
    id: i,
    content: `item-${i+1}`,
    height: Math.floor(Math.random() * 100) + 50
  }
})

const container = ref(null)
const visibleData = ref([])
const totalHeight = ref(0)
const renderedItemsHeight = ref({})
const offset = ref(0)

// 核心渲染更新函数
const updateRenderItems = () => {
  const scrollTop = container.value?.scrollTop
  let startIndex = 0

  // i. 预先计算并缓存所有项的累计高度
  const accumulatedHeights = computed(() => {
    let sum = 0
    return allData.map(item => {
      sum += renderedItemsHeight.value[item.id] || ITEM_HEIGHT
      return sum
    })
  })

  // ii. 二分查找找到第一个超过当前滚动位置的项
  let left = 0
  let right = accumulatedHeights.value.length - 1
  while (left <= right) {
    const mid = Math.floor((left + right) / 2)
    if (accumulatedHeights.value[mid] >= scrollTop) {
      right = mid - 1
      startIndex = mid
    } else {
      left = mid + 1
    }
  }

  visibleData.value = allData.slice(startIndex, startIndex + visibleCount)
  offset.value = startIndex > 0 ? accumulatedHeights.value[startIndex - 1] : 0
}

// 收集已渲染项的实际高度
const renderItemsRef = (el, id) => {
 if (el) {
  renderedItemsHeight.value[id] = el.offsetHeight
  nextTick(updateTotalHeight)
 }
}

// 更新占位的总高度
const updateTotalHeight = () => {
  totalHeight.value = allData.reduce((sum, item) => sum + (renderedItemsHeight.value[item.id] || ITEM_HEIGHT), 0)
}

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

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

<style>
.virtual-scroll {
  height: 100%;
  overflow-y: auto;
  position: relative;
}
.scroll-phantom {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
}

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

后记

简而言之,不定高的虚拟列表是一种前端性能优化的技术,可以应用于渲染超长列表的场景。由于每一项的高度不固定,因此我们无法直接获取 startIndex 的值。我们通过二分查找的方法,能够以 O(nlogn) 的效率找到 startIndex 的值,结果更加高效。

开卷有益!