Skip to content

虚拟列表优化

遗留问题:

  • 动态高度
  • 白屏问题
  • 滚动事件触发频率过高

动态高度

在实际应用中,列表项目里面可能包含一些可变内容,导致列表项高度并不相同。例如新浪微博:

image-20240702084546314

不固定的高度就会导致:

  • 列表总高度:listHeight = listData.length * itemSize
  • 偏移量的计算:startOffset = scrollTop - (scrollTop % itemSize)
  • 数据的起始索引 startIndex = Math.floor(scrollTop / itemSize)

这些属性的计算不能再通过上面的方式来计算。因此我们会遇到这样的一些问题:

  1. 如何获取真实高度?
  2. 相关属性该如何计算?
  3. 列表渲染的项目有何改变?

解决思路:

  1. 如何获取真实高度?

    • 如果能获得列表项高度数组,真实高度问题就很好解决。但在实际渲染之前是很难拿到每一项的真实高度的,所以我们采用预估一个高度渲染出真实 DOM,再根据 DOM 的实际情况去更新真实高度。
    • 创建一个缓存列表,其中列表项字段为 索引、高度与定位,并预估列表项高度用于初始化缓存列表。在渲染后根据 DOM 实际情况更新缓存列表
  2. 相关的属性该如何计算?

    • 显然以前的计算方式都无法使用了,因为那都是针对固定值设计的。
    • 于是我们需要 根据缓存列表重写计算属性、滚动回调函数,例如列表总高度的计算可以使用缓存列表最后一项的定位字段的值。
  3. 列表渲染的项目有何改变?

    • 因为用于渲染页面元素的数据是根据 开始/结束索引数据列表 中筛选出来的,所以只要保证索引的正确计算,那么渲染方式是无需变化的。
    • 对于开始索引,我们将原先的计算公式改为:在 缓存列表 中搜索第一个底部定位大于 列表垂直偏移量 的项并返回它的索引
    • 对于结束索引,它是根据开始索引生成的,无需修改。

白屏问题

在第一版的实现中,我们仅渲染可视区域的元素。此时如果用户滚动过快,会出现白屏闪烁的现象。

之所以会有这个现象,是因为先加载出来的是白屏(没有渲染内容),然后迅速会被替换为表格内容,从而出现闪烁的现象。

并且这种现象在越低性能的浏览器上面表现得越明显。

解决思路:

为了让页面的滚动更加平滑,我们可以在原先列表结构的基础上加上缓冲区,也就是整个渲染区域由 可视区 + 缓冲区 共同组成,这样就给滚动回调和页面渲染留出了更多的时间。

image-20240702090152620

这样设计后,缓冲区的数据会进入到可视区域,然后我们要做的就是更新缓冲区的数据。

代码片段:

js
const aboveCount = computed(() => {
  // 缓冲区列表项个数的计算,其实就是可视区显示个数 * 缓冲比例
  // 但是考虑到可能存在当前虚拟列表处于最顶端,所以需要和 startIndex 做一个比较,取最小值
  return Math.min(startIndex.value, props.bufferScale * visibleCount.value)
})

const belowCount = computed(() => {
  return Math.min(props.listData.length - endIndex.value, props.bufferScale * visibleCount.value)
})

假设我们有如下场景:

  • 总共有 100 项数据(props.listData.length = 100)
  • 当前可视区域显示 10 项(visibleCount.value = 10)
  • bufferScale 设置为 1
  • 当前 startIndex.value = 20(表示当前可视区域从第21项开始显示)
  • 当前 endIndex.value = 29(表示当前可视区域显示到第30项)

计算 aboveCount:

js
const aboveCount = Math.min(20, 1 * 10)
// 计算结果为 Math.min(20, 10) = 10

计算 belowCount

js
const belowCount = Math.min(100 - 30, 1 * 10)
// 计算结果为 Math.min(70, 10) = 10

因此最终上下的缓冲区的缓冲列表项目均为10.

另外关于整个列表的渲染,之前是根据索引来计算的,现在就需要额外加入上下缓冲区大小重新计算,如下所示:

js
const visibleData = computed(() => {
  let startIdx = startIndex.value - aboveCount.value
  let endIdx = endIndex.value + belowCount.value
  return props.listData.slice(startIdx, endIdx)
})

最后,因为多出了缓冲区域,所以偏移量也要根据缓冲区来重新进行计算,如下所示:

js
const setStartOffset = () => {
  let startOffset

  // 检查当前可视区域的第一个可见项索引是否大于等于1(即当前显示的内容不在列表最开始的地方)
  if (startIndex.value >= 1) {
    
    // 计算当前可视区域第一项的顶部位置与考虑上方缓冲区后的有效偏移量
    // positions[startIndex.value].top 是当前可视区域第一项的顶端位置
    // positions[startIndex.value - aboveCount.value].top 是考虑上方缓冲区后,开始位置的顶端位置
    // 如果上方缓冲区存在,则减去它的顶端位置;否则使用 0 作为初始偏移量
    let size =
      positions[startIndex.value].top -
      (positions[startIndex.value - aboveCount.value]
        ? positions[startIndex.value - aboveCount.value].top
        : 0)

    // 计算 startOffset:用当前可视区域第一个项的前一项的底部位置,减去上面计算出的 size,
    // 这个 size 表示的是在考虑缓冲区后需要额外平移的偏移量
    startOffset = positions[startIndex.value - 1].bottom - size
  } else {
    // 如果当前的 startIndex 为 0,表示列表显示从最开始的地方开始,没有偏移量
    startOffset = 0
  }

  // 设置内容容器的 transform 属性,使整个内容平移 startOffset 像素,以确保正确的项对齐在视口中
  content.value.style.transform = `translate3d(0,${startOffset}px,0)`
}

至于这个 startOffset 具体是怎么计算的,如下图所示:

image-20240817152436764

setStartOffset 方法重写完毕后,整个白屏闪烁问题也就完美解决了。

滚动事件触发频率过高

上一版实现中,我们绑定的是 scroll 滚动事件,虽然效果实现了,但是 scroll 事件的触发频率非常高,每次用户一滚动就会触发,而每次触发都会执行 scroll 回调方法。

解决思路:

可以使用 IntersectionObserver 来替换监听 scroll 事件。

相比 scroll,IntersectionObserver 可以设置多个阈值来检测元素进入视口的不同程度,只在必要时才进行计算,没有性能上的浪费。并且监听回调也是异步触发的。


-EOF-

Released under the MIT License.