一文弄懂虚拟列表原理及实现(图解&码上掘金)

6,605 阅读11分钟

前言

在谈论到前端性能优化时,我们可能会想到很多方向,但虚拟列表一定算是一个值得谈谈的话题,很多人都是略懂一二,对其实现原理与过程还不太了解,所以当面试官问到比较细节的问题时往往语塞,本篇文章将从最简单的固定子项高度的虚拟列表出发,内含大量图解及码上掘金实操案例,让你深入浅出地理解虚拟列表。

本文代码实现采用vue3框架

什么是虚拟列表

虚拟列表其实是按需显示的一种实现,即只对可见区域进行渲染,对非可见区域中的数据不渲染或部分渲染的技术,从而达到极高的渲染性能。

虚拟列表原理【固定子项高度】

首先,我们先从最简单的一种场景入手,也就是列表子项高度固定的场景。

我们知道,普通列表是由可视区(滚动容器,以下统称为可视区)与内容区组成的。

当内容区域内的子项元素不断增加时,需要渲染的元素也越来越多,性能明显下降,甚至会造成页面的卡顿。

实际上,可视区域是固定的,在理想状态下我们只需要渲染可视区域附近的元素即可。由此,虚拟列表的解决方案应运而生。普通列表的内容区是实打实的内容,虚拟列表将这个内容区拆分为两块:虚拟区、内容区

  1. 虚拟区:就是用来撑开可视区的一个元素,我们借助它来实现可视区的滚动效果。
  2. 内容区:与普通列表内容区会渲染所有的列表子项不同,虚拟列表的内容区只会渲染可视区内的列表子项,组成内容区。

接下来,我们一起实现下在子项高度固定时的虚拟列表。

可视区域是固定的,需要我们来实现的是虚拟区与内容区。

虚拟区

虚拟区的高度获取比较简单,即为子项高度 * 子项数量

phantomHeight = itemHeight * listData.length

内容区

首先,这里的内容可视区样式是相对定位的,如下示例

.container {
  width: 200px;
  height: 300px;
  -webkit-overflow-scrolling: touch;
  overflow: auto;
  position: relative;
}
.content {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
}

对于内容区域,我们最重要的是需要得到可视区内展示的数据,所以我们需要得到如下数据

  1. 可视区内列表的起始索引
  2. 可视区内列表的结束索引

得到这些数据之后,我们就可以得到内容区的数据了,即为列表数据从起始索引到结束索引的拷贝

对于起止索引的获取,我们需要分为两个场景来考究,首先是初始态,其次是滚动态

初始态

对于初始态,起始索引为0,结束索引为起始索引与可视区展示个数之和。

获取可视区展示个数的方法如下,为可视区高度除以子项高度向上取整

为什么是ceil:ceil是向上取整 有小数就+1 确保足量数量不留白

/* 可视区的展示个数 = Math.ceil( 可视区高度 / 子项高度) */
visibleCount = Math.ceil(container.clientHeight  / itemHeight)

结束索引为:

endIndex = 0 + visibleCount

滚动态

首先需要计算起始索引,是可视区滚动的距离除以子项高度向下取整:

为什么是floor:floor是向下取整 取整数部分 确保每个子项完整滚动

startIndex = Math.floor(container.scrollTop / itemHeight)

结束索引为起始索引与可视区展示个数之和:

endIndex = startIndex + visibleCount

完整实现代码

虚拟列表原理【动态子项高度】

实际开发过程中,我们存在一些子项高度并非固定的场景需要处理。通常我们无法预先获取每个子项的高度,需要在屏幕外提前渲染子项元素才能获取到它的高度。

面对这个场景,我们可以采取 “预估高度+动态获取” 的方式来处理这个问题,具体操作方式和固定高度有点类似,只是在几个时机的处理有所不同,接下来我们来一起实现动态子项高度的虚拟列表吧。

我们这里需要模拟动态子项高度,所以在这里我们借助 faker.js 来模拟一些数据。Faker

const listData = ref(
  new Array(1000).fill({}).map((item, index) => ({ 
    index, text: faker.lorem.sentences() 
  }))
)

重点

通过之前固定子项高度虚拟列表的实现,我们可以知道,在实现虚拟列表的过程中,非常重要的点有三个:可视区撑开高度、可视区数据、可视区偏移量

可视区撑开高度

需要在初始化时、子项高度变化时重新计算。

可视区数据

可视区数据主要是通过起止索引确定的,截取数据源起止索引这一段数据。

起止索引在初始化、滚动状态时,都需要重新计算。

const visibleData = computed(() => {
  return listData.value.slice(
    visibleInfo.startIndex,
    Math.min(visibleInfo.endIndex, listData.value.length)
  )
})

偏移量

即为已经滚动出的子项高度之和,在初始化、子项高度变化、滚动状态,都需要重新计算。

综上所述,需要终端关注三个时机:初始化、子项高度变化、滚动时机。那我们接下来就从这三个角度入手来弄清动态子项高度虚拟列表的实现。

重点步骤一:初始化

在初始状态,我们主要需要通过预估高度(estimateHeight)做三件事:

  1. 计算内容区域撑开高度(phantomHeight)
  2. 计算可视区展示子项个数及可视区起止索引值
  3. 初始化每个子项的位置记录表(itemPositions)

itemPositions位置记录表非常重要,它可以帮助我们很方便的获取子项位置数据,在计算起止索引和偏移量的时候提供了很大的帮助。

onMounted(() => {
  // step1: 计算可视区高度、内容区撑开高度
  visibleInfo.height = containerRef.value.clientHeight
  phantomHeight.value = listData.value.length * estimateHeight.value

  // step2: 根据预估高度来初始化可视区索引、可视区展示子项个数
  visibleInfo.startIndex = 0
  visibleInfo.count = Math.ceil(visibleInfo.height / estimateHeight.value)
  visibleInfo.endIndex = visibleInfo.startIndex + visibleInfo.count

  // step3:初始化 itemPositions 用来记录每个子项的高度及起止距离
  itemPositions.value = listData.value.map((item, index) => {
    return {
      index,
      top: index * estimateHeight.value,
      bottom: (index + 1) * estimateHeight.value,
      height: estimateHeight.value,
    }
  })
})

重点步骤二:监听可视区子项高度变动

通过监听可视区子项高度变化,去更新可视区的子项的位置信息

1. ref及id绑定

我们在 dom 中通过 ref 来绑定子项元素集合,以便于后续获取子项元素的数据,通过id来绑定子项index,以便后续灵活获取每个子项元素的索引。

<div v-for="item in visibleData" :key="item.index" class="visible-list-item" ref="itemRefs" :id="item.index"> {{ item.text }} </div>

2. onUpdated 监听变化

在vue3中我们可以使用 onUpdated 这个生命周期钩子来监听子项高度变化。

3. 计算可视区子项位置集合(itemPositions)

通过遍历可视区子项的子项,拿到每个子项的高度,实时去更新对应索引子项集合中的位置信息。

需要注意的是,如果有一个子项的高度有变化,后面的所有元素的位置信息都会对应变化,所以需要遍历并更新其后的每个元素的位置信息。

4. 计算偏移量

偏移量就是已滑出的子项高度集合,需要增补回来,所以可以视为起始子项元素的top值

// 子项元素集合
const itemRefs = ref([])
onUpdated(() => {
  if (!itemRefs.value || !itemRefs.value.length) return
  // 计算更新可视区子项位置集合
  computedVisualSize()
  // 计算虚拟占位高度
  phantomHeight.value = itemPositions.value[itemPositions.value.length - 1].bottom
  // 计算偏移量
  getOffsetY()
})
// 计算更新可视区子项位置集合
const computedVisualSize = () => {
  itemRefs.value.map(item => {
    const id = +item.id
    const curHeight = item.clientHeight
    const oldHeight = itemPositions.value[id].height
    const dValue = curHeight - oldHeight
    // 对比已有的位置信息 查看有无变化 有变化再去更新
    if (dValue) {
      itemPositions.value[id].height = curHeight
      itemPositions.value[id].bottom = itemPositions.value[id].bottom + dValue
      // 遍历此元素之后的元素并更新位置信息
      for (let index = id + 1; index < itemPositions.value.length; index++) {
        itemPositions.value[index].top = itemPositions.value[index].top + dValue
        itemPositions.value[index].bottom = itemPositions.value[index].bottom + dValue
      }
    }
  })
}
// 计算偏移量
const getOffsetY = () => {
  startOffset.value =
    visibleInfo.startIndex >= 1 ? itemPositions.value[visibleInfo.startIndex].top : 0
}

重点步骤三: 滚动状态处理

滚动时需要根据滚动距离来更新可视区起止索引及偏移量。

其中的重点是通过二分法来查找初始索引,那么何为二分法?

我们这里用到的二分法,简单来说就是获取中间点底部距离与滚动距离比较

  1. 中间点底部距离 === 滚动距离 => 起始索引即为 中间点索引+1
  2. 中间点底部距离 < 滚动距离 => 起始索引的范围确定为 中间点索引+1 ~ 终止索引
  3. 中间点底部距离 > 滚动距离 => 起始索引的范围确定为 起始索引 ~ 终止索引 -1

通过二分法计算初始索引,每次计算之后范围都会缩小,直至算出目的初始索引,减少了逐个计算可能带来的计算量。

// 监听可视区滚动事件
const scrollEvent = e => {
  const scrollTop = e.target.scrollTop
  visibleInfo.startIndex = getStartIndex(scrollTop)
  visibleInfo.endIndex = visibleInfo.startIndex + visibleInfo.count
  startOffset.value = getOffsetY()
}
// 二分法查找初始索引
const getStartIndex = scrollTop => {
  let start = 0
  let end = listData.value.length - 1
  let tempIndex = null
  while (start <= end) {
    const midIndex = parseInt(String((end + start) / 2))
    const midBottom = itemPositions.value[midIndex].bottom
    if (midBottom === scrollTop) {
      tempIndex = midIndex + 1
      return tempIndex
    } else if (midBottom < scrollTop) {
      start = midIndex + 1
    } else if (midBottom > scrollTop) {
      if (tempIndex === null || tempIndex > midIndex) {
        tempIndex = midIndex
      }
      end = end - 1
    }
  }
  return tempIndex
}

完整实现代码

虚拟列表优化

一、增加缓冲区

上述解决方案只是提供一个思路,整体还有一些优化的空间,比如在快速滚动时,可能会出现子项元素未占满可视区的现象,如下所示:

对于这种情况,我们可以适当的增加一些缓冲元素,也就是在列表滚动时前后各增加多一点子项元素。

增加缓冲区域之后,相应的可视区域的子项会增加,可视区数据会有所变化。与此同时,偏移量的计算也会有所改变。

  1. 可视区数据的计算

起始索引需要减去加入的前置缓冲元素,结束的索引需要加上后置缓冲区的元素。

需要注意的是,前置缓冲个数不能超过前置索引,后置缓冲区个数加上已有可视区个数之和不能超过总数据个数。

代码实现如下:

// 可视区数据
const visibleData = computed(() => {
  const start = visibleInfo.startIndex - aboveCount.value
  const end = visibleInfo.endIndex + belowCount.value
  return listData.value.slice(start, end)
})
const aboveCount = computed(() => {
  return Math.min(visibleInfo.startIndex, Math.floor(visibleInfo.count * bufferRatio))
})
const belowCount = computed(() => {
  return Math.min(
    listData.value.length - visibleInfo.endIndex,
    Math.floor(visibleInfo.count * bufferRatio)
  )
})
  1. 偏移量的计算
// 获取偏移量
const getOffsetY = () => {
  // 实际滑出可视区个数
  const realStart = visibleInfo.startIndex - aboveCount.value
  if (realStart) {
    startOffset.value = itemPositions.value[realStart].top
  } else {
    startOffset.value = 0
  }
}

二、节流

监听子项高度变化及可视区滚动事件时,短时间会触发很多次计算,但实际上我们可以使用节流与防抖结合的思路,取一定时间内一次的计算即可,这样渲染的效率将会更高效。

delay 时间内,我可以为你重新生成定时器;但只要delay的时间到了,我必须要给用户一个响应。这个 throttle 与 debounce “合体”思路

// fn是我们需要包装的事件回调, delay是时间间隔的阈值
function throttle(fn, delay) {
  // last为上一次触发回调的时间, timer是定时器
  let last = 0, timer = null
  // 将throttle处理结果当作函数返回
  
  return function () { 
    // 保留调用时的this上下文
    let context = this
    // 保留调用时传入的参数
    let args = arguments
    // 记录本次触发回调的时间
    let now = +new Date()
    
    // 判断上次触发的时间和本次触发的时间差是否小于时间间隔的阈值
    if (now - last < delay) {
    // 如果时间间隔小于我们设定的时间间隔阈值,则为本次触发操作设立一个新的定时器
       clearTimeout(timer)
       timer = setTimeout(function () {
          last = now
          fn.apply(context, args)
        }, delay)
    } else {
        // 如果时间间隔超出了我们设定的时间间隔阈值,那就不等了,无论如何要反馈给用户一次响应
        last = now
        fn.apply(context, args)
    }
  }
}
// 监听可视区滚动事件
const scrollEvent = throttle((e)=> {
  const scrollTop = e.target.scrollTop
  visibleInfo.startIndex = getStartIndex(scrollTop)
  visibleInfo.endIndex = visibleInfo.startIndex + visibleInfo.count
  startOffset.value = getOffsetY()
}, 100)
// 监听子项高度变动
onUpdated(throttle(() => {
  if (!itemRefs.value || !itemRefs.value.length) return
  // 计算更新可视区子项位置集合
  computedVisualSize()
  // 计算虚拟占位高度
  phantomHeight.value = itemPositions.value[itemPositions.value.length - 1].bottom
  // 计算偏移量
  getOffsetY()
}, 100))

完整代码

虚拟列表实现要点

以下是虚拟列表实现的要点以及重要的处理时机,只要掌握这些要点,手写虚拟列表也是很轻易的事情。

后记

相信只要弄清虚拟列表实现的要点及重要处理时机,靠自己的记忆手写代码也是比较轻松的事情。

很多业务技巧都是相互贯通的,本篇看似介绍的是虚拟列表的实现方案,但更多的也是传递一种解决问题的技巧。所有看似比较复杂的问题,只要弄清各类问题的重点,逐个攻破,所有的问题都将迎刃而解。这种感觉就像找到了动画片里面怎么也打不死的大怪兽的命门,然后一击致命,岂不快哉。

最后,感谢各位铁铁的阅读,如有疏漏,欢迎指正交流。