虚拟滚动实现原理 - 废材壶 BLOG
    虚拟滚动实现原理
    基于Vue三方库,简要分析虚拟滚动的原理与实现
    11 July, 2024

    简介

    虚拟滚动是一项数据列表优化技术,用于处理大量数据列表的渲染和显示。通过仅渲染可见区域的列表项,虚拟滚动显著提升了性能,减少了内存占用,并提供了更流畅的用户体验。

    本文将基于 useVirtualListvue-virtual-scroller 常用库的实现,分析虚拟滚动列表的原理

    基础概念

    概念
    概念

    可视区域(Viewport)

    图中的 Container容器

    可视区域是指当前用户在屏幕上可以看到的部分内容。虚拟滚动只会渲染这个区域内的列表项,而不会渲染超出可视区域的部分

    偏移(Offset)

    图中的offset

    偏移是指从列表顶部到当前可视区域顶部之间的距离,通常以像素为单位。偏移量决定了需要跳过多少列表项,以便只渲染可见的部分。

    列表项高度(Item Height)

    图中wrapper容器的高度

    列表项高度是指每个列表项的高度,可以是固定值或动态计算值。固定高度的列表项较易处理,而动态高度的列表项需要更复杂的计算。

    缓存区(Overscan)

    图中 overscan

    缓存区是指在可视区域的上下方多渲染的一些列表项,用于预加载即将进入可视区域的内容,从而提供更流畅的滚动体验。

    内容容器(Wrapper)

    图中 wrapper容器

    内容容器是实际渲染列表项的内部元素,其高度或宽度通常根据整个列表的大小来设置。通过设置内容容器的大小,虚拟滚动可以生成滚动条。

    useVirtualList 原理

    适用于固定大小的数据列表

    基本使用

    <template>
      <div v-bind="containerProps" style="height: 300px">
        <div v-bind="wrapperProps">
          <div v-for="item in list" :key="item.index" style="width: 200px">
            Row: {{ item.data }}
          </div>
        </div>
      </div>
    </template>
    
    <script setup>
      import { useVirtualList } from '@vueuse/core'
    
    const { list, containerProps, wrapperProps } = useVirtualList(
      Array.from(Array(99999).keys()),
      {
        // Keep `itemHeight` in sync with the item's row.
        itemHeight: 22,
      },
    )
    </script>
    

    通过看示例代码,发现滚动列表的具有几个要点:

    • containerProps 外部容器,具有固定的高度或宽度
    • wrapperProps 内容容器,具有列表真实的高度
    • list 当前可见区域的渲染列表
    • itemHeight 行高度,可以静态高度,动态高度需传入函数
    • overscan 视区上、下缓存展示的DOM节点数量

    useVirtualList 的源代码如下

    export function useVirtualList<T = any>(list: MaybeRef<T[]>, options: UseVirtualListOptions): UseVirtualListReturn<T> {
      const { containerStyle, wrapperProps, scrollTo, calculateRange, currentList, containerRef } = 'itemHeight' in options
        ? useVerticalVirtualList(options, list)
        : useHorizontalVirtualList(options, list)
    
      return {
        list: currentList,
        scrollTo,
        containerProps: {
          ref: containerRef,
          onScroll: () => {
            calculateRange()
          },
          style: containerStyle,
        },
        wrapperProps,
      }
    }
    

    源代码中会返回几个参数:

    • list
    • scrollTo
    • containerProps: {}
    • wrapperProps: {}

    其中可看到containerProps中,ref为绑定的DOM 引用, onScroll监听滚动事件,

    其中的onScroll事件的函数回调为核心

    进一步查看源代码,发现useVerticalVirtualListuseHorizontalVirtualList 是两个核心函数,分别处理垂直和水平方向的列表。 我们先看useVerticalVirtualList 的源代码

    function useVerticalVirtualList<T>(options: UseVerticalVirtualListOptions, list: MaybeRef<T[]>) {
      const resources = useVirtualListResources(list)
    
      const { state, source, currentList, size, containerRef } = resources
    
      const containerStyle: StyleValue = { overflowY: 'auto' }
    
      const { itemHeight, overscan = 5 } = options
    
      const getViewCapacity = createGetViewCapacity(state, source, itemHeight)
    
      const getOffset = createGetOffset(source, itemHeight)
    
      const calculateRange = createCalculateRange('vertical', overscan, getOffset, getViewCapacity, resources)
      // 获取开始 子项开始索引到顶部的距离
      const getDistanceTop = createGetDistance(itemHeight, source)
      // 这个也就是marginTop
      const offsetTop = computed(() => getDistanceTop(state.value.start))
      // 总所有子项加起来的高度
      const totalHeight = createComputedTotalSize(itemHeight, source)
      // 监听外层容器的size有变化,或 列表数据有变化时,重新计算范围
      useWatchForSizes(size, list, calculateRange)
      // 计算滚动距离scrollTop
      const scrollTo = createScrollTo('vertical', calculateRange, getDistanceTop, containerRef)
    
      // 包裹层wrapper
      const wrapperProps = computed(() => {
        return {
          style: {
            width: '100%',
            height: `${totalHeight.value - offsetTop.value}px`,
            marginTop: `${offsetTop.value}px`,
          },
        }
      })
    
      return {
        calculateRange,
        scrollTo,
        containerStyle,
        wrapperProps,
        currentList,
        containerRef,
      }
    }
    

    通过源代码可以看到几个参数的函数:

    1. wrapperProps 会计算出原列表的总高度以及marginTop。
      1. height 为实际高度。目的是可以生成滚动条。
      2. marginTop 外边距。目的是为了把渲染区域的列表可以在外部容器中展示。因为,当内部容器开始进行切割只渲染可视区域的列表时,默认情况下,列表的scrollTop都会是0,也就是会在内容容器的顶部展示下来。计算marginTop是为了把顶部的渲染列表,对齐外部容器布局。

    继续查看 calculateRange()

    function createCalculateRange<T>(type: 'horizontal' | 'vertical', overscan: number, getOffset: ReturnType<typeof createGetOffset>, getViewCapacity: ReturnType<typeof createGetViewCapacity>, { containerRef, state, currentList, source }: UseVirtualListResources<T>) {
      return () => {
        const element = containerRef.value
        if (element) {
          const offset = getOffset(type === 'vertical' ? element.scrollTop : element.scrollLeft)
          // 可见视图中,能看到的个数,例如: viewCapacity = 12
          const viewCapacity = getViewCapacity(type === 'vertical' ? element.clientHeight : element.clientWidth)
          // 计算包含缓存区域后的第一个子项下表
          const from = offset - overscan
          // 包含缓冲区后,最后一个子项的位置
          const to = offset + viewCapacity + overscan
          // 记录最新的可见区域的状态位置
          state.value = {
            start: from < 0 ? 0 : from,
            end: to > source.value.length
              ? source.value.length
              : to,
          }
          currentList.value = source.value
            .slice(state.value.start, state.value.end)
            .map((ele, index) => ({
              data: ele,
              index: index + state.value.start,
            }))
        }
      }
    }
    

    从代码中可看到,这段函数的核心为计算可视区域(container)中的当前能被渲染的列表,其中包含有的参数:

    1. offset: 已经“滚动” 过的列表项个数
    2. from: 渲染列表的第一个下标
    3. end: 渲染列表的最后一个子项下标
    4. currentList: 完整的渲染列表

    每次通过监听滚动事件,对渲染列表的个数重新计算:

    1. 计算可见区域列表的个数 getViewCapacity
    2. 计算偏移个数函数 getOffset
    3. 计算顶部距离 offsetTop
    4. 监听 容器大小变更、list数据变更,重新计算
    5. 外部可以调用一个 scrollTo函数
    6. 计算内容容器的 height marginTop

    通过对 useVirtualList 的分析,我们可以发现,其实现虚拟滚动思路为:

    通过滚动事件,计算滚动的偏移列表个数,计算对应的列表索引,最后计算可视区域的列表项

    // offset滚动的个数
    const from = offset - overscan
    // viewCapacity 可视区域的个数
    const to = offset + viewCapacity + overscan
    state.value = {
      start: from < 0? 0 : from,
      end: to > source.value.length
     ? source.value.length
        : to,
    }
    // 更新可视区域的列表数据
    currentList.value = source.value
      .slice(state.value.start, state.value.end)
      .map((ele, index) => ({
        data: ele,
        index: index + state.value.start,
      }))
    

    vue-virtual-scroller 原理

    useVirtualList 的实现中可以看出其应用的场景是简单的固定高度列表项的情况,而对于一些复杂的动态高度列表项,可以使用 vue-virtual-scroller 来处理

    我们来看看 vue-virtual-scroller 是如何处理动态高度列表项的。

    基础使用

    <template>
      <DynamicScroller
        :items="items"
        :min-item-size="54"
        class="scroller"
      >
        <template v-slot="{ item, index, active }">
          <DynamicScrollerItem
            :item="item"
            :active="active"
            :size-dependencies="[
              item.message,
            ]"
            :data-index="index"
          >
            <div class="avatar">
              <img
                :src="item.avatar"
                :key="item.avatar"
                alt="avatar"
                class="image"
              >
            </div>
            <div class="text">{{ item.message }}</div>
          </DynamicScrollerItem>
        </template>
      </DynamicScroller>
    </template>
    
    <script>
    export default {
      props: {
        items: Array,
      },
    }
    </script>
    
    
    

    通过demo使用案例,我们会发现,在使用vue-virtual-scroller 组件时,需要在DynamicScroller 组件中,把 items 传入即可,可想而知item的实际高度是在内部获取到的。

    获取动态列表大小

    翻看DynamicScroller 组件中,我们会发现,子项的实际高度是通过 ResizeObserver 监听实时更新item的宽度、高度

    这里利用 requestAnimationFrame 避免了ResizeObserver 监听的闪烁问题

        if (typeof ResizeObserver !== 'undefined') {
          this.$_resizeObserver = new ResizeObserver(entries => {
            requestAnimationFrame(() => {
              if (!Array.isArray(entries)) {
                return
              }
              for (const entry of entries) {
                // $_vs_onResize 是 自定义的属性方法,在`DynamicScrollerItem` 组件中绑定的
                if (entry.target && entry.target.$_vs_onResize) {
                  let width, height
                  if (entry.borderBoxSize) {
                    const resizeObserverSize = entry.borderBoxSize[0]
                    width = resizeObserverSize.inlineSize
                    height = resizeObserverSize.blockSize
                  } else {
                    // @TODO remove when contentRect is deprecated
                    width = entry.contentRect.width
                    height = entry.contentRect.height
                  }
                  entry.target.$_vs_onResize(entry.target.$_vs_id, width, height)
                }
              }
            })
          })
    

    计算可视区域列表

    useVirtualList 获取可视区域的个数计算方式大同小异。

    • vue-virtual-scroller 是同样通过滚动获取获取滚动距离和可视区域起止距离单位。这点区别于 useVirtualList
    getScroll() {
      return {
        start:el.scrollTop,
        end: el.scrollTop + el.clientHeight,
      }
    }
    const scroll = this.getScroll()
    
    
    • 根据滚动的距离,计算查找开始索引、结束索引。利用二分查找法查找开始索引
      // 利用二分查找法,找到startIndex
      do {
        oldI = i
        // accumulator 为每个子项距离顶部的距离
        h = sizes[i].accumulator
        if (h < scroll.start) {
          // 在可见区域外,更新start为第几个索引
          a = i
        } else if (i < count - 1 && sizes[i + 1].accumulator > scroll.start) {
          // 在可视区域内了,更新end为第几个索引
          b = i
        }
        // 二分查找法,更新i
        i = ~~((a + b) / 2)
      } while (i !== oldI)
      i < 0 && (i = 0)
    
      // 找到开始索引
      startIndex = i
    
      // 获取实际items的总高度
      totalSize = sizes[count - 1].accumulator
       // 遍历找到endIndex索引
      for (endIndex = i; endIndex < count && sizes[endIndex].accumulator < scroll.end; endIndex++);
    
    
    • 开始索引、结束索引 会包含缓冲区的列表子项,故还需获取可视区域的真实起止索引
      // search visible startIndex
      for (visibleStartIndex = startIndex; visibleStartIndex < count && (beforeSize + sizes[visibleStartIndex].accumulator) < scroll.start; visibleStartIndex++);
    
      // search visible endIndex
      for (visibleEndIndex = visibleStartIndex; visibleEndIndex < count && (beforeSize + sizes[visibleEndIndex].accumulator) < scroll.end; visibleEndIndex++);
    
    • 最后,根据可视区域的起止索引,更新可视化列表数据

    小结

    本文仅通过简要代码去了解虚拟滚动的核心原理。细节边界的处理,默认不展开

    通过分析两个常用库的虚拟滚动实现,我们知道虚拟滚动的核心原理为:

    1. 可视区域的列表数据的计算:可通过滚动距离计算起止索引
      1. 查找方法有:二分查找法、个数计算法
    2. ResizeObserver 监听列表项的大小变化,更新列表数据的实际大小
    3. 监听滚动事件,更新可视区域的列表数据
    Jimhug

    基础不牢,地动山摇

    Share with the post url and description