为什么需要虚拟滚动

假设一个 vue 组件用 v-for 一次性不分页渲染 100000 条列表数据,且不说从后端请求这些数据所需要的时间,光是从加载数据到可以交互的时间就能直观感觉到缓慢,数据量越大感觉越明显,这种性能完全无法达到良好的用户体验,但这种一次性渲染大量列表数据的需求其实挺常见,因此需要一种手段优化大数据列表的性能,虚拟滚动就是最主流的解决方案,不过在解释虚拟滚动的原理之前,需要先了解普通渲染出现性能瓶颈的原因

分析

首先创建一个 vue 组件用于渲染列表数据

src/components/NormalScroller.vue
<script setup lang="ts">
const props = defineProps<{
  // 可视区高度
  height: number
  // 所有数据
  data: { id: number; height: number }[]
}>()
</script>
 
<template>
  <div id="scroller" :style="{ height: props.height + 'px' }">
    <div class="item" v-for="item in data" :key="item.id" :style="{ height: item.height + 'px' }">
      {{ item.id }}
    </div>
  </div>
</template>
 
<style scoped>
#scroller {
  border: 1px solid #eee;
  overflow: auto;
}
 
.item {
  box-sizing: border-box;
  border: 1px solid #eee;
  background-color: #000;
  color: #fff;
}
</style>

在父组件中创建数据并向该组件传递数据,每个列表项高度固定

src/App.vue
<script setup lang="ts">
import NormalScroller from './components/NormalScroller.vue'
import { ref, type Ref, onMounted } from 'vue'
 
const data: Ref<{ id: number; height: number }[]> = ref([])
 
function getData() {
  const temp = []
  for (let i = 0; i < 100000; i++) {
    temp.push({
      id: i + 1,
      height: 50
    })
  }
  data.value = temp
}
 
onMounted(() => {
  getData()
})
</script>
 
<template>
  <NormalScroller :height="500" :data="data"></NormalScroller>
</template>

加载速度

首先分析一下页面的加载速度

把项目跑起来后,打开页面,打开开发者工具,进入开发者工具的 Performance 面板,点击左上角的 Record and reload 按钮,点击后浏览器就会自动刷新并且开始录制从刷新到加载完成的性能数据(不需要手动点击 Stop

录制完后得到的信息很多,重点关注一下底部 Summary 的指标,

其中 Scripting 表示加载过程中脚本执行的时间,因为脚本执行过程中会阻塞页面的渲染,同时如果脚本修改 DOM 会导致重排与重绘,是影响页面加载速度的重要因素

Rendering 表示实际页面的渲染耗时,包括重排、重绘、合成的过程

Painting 表示页面重绘的耗时

Idle 表示主线程的空闲时间,不影响页面加载速度

可以看到,单单 Scripting + Rendering + Painting 加起来的时间已经超过 4s ,这显然是不能被接受

滚动帧率

打开开发者工具,按下 Ctrl + Shift + p 后出现命令选择下拉框,搜索 Show frames per second(FPS) meter ,这个命令会在页面左上角出现一个可以实时查看帧率的小弹窗,通过滚动列表分析页面的交互流畅度

目前流畅度还是可以的,稳定在 60 帧左右,因为所有 DOM 都已经加载完,滚动过程不会发生重排重绘

Lighthouse

打开开发者工具,进入 Lighthouse 面板,分别勾选 NavigationDesktopPerformance 后点击 Analyze page load 按钮,这个工具就会自动从各个方面分析页面的性能并给出相应分数和修改建议

可以看到 First Contentful PaintFCP ,这个指标表示浏览器首次绘制来自 DOM 内容的耗时 ,用于评估用户首次看到有用内容的耗时,简单来说就是耗时越短,加载速度越快,这里分析出将近 1s 而且用黄色高亮,是需要优化的点

往下看有这个工具提供的优化建议,重点看一下 Avoid an excessive DOM size ,表示你的页面 DOM 节点数量过多,显示包括所有节点数量,最深的节点嵌套层级,最大包含的子节点数量

大量的 DOM 节点不仅影响加载速度,还会因为交互时发生重排重绘的范围过大导致页面卡顿,同时还存在因为引用大量节点导致内存溢出的隐患

按照官方介绍

当 body 元素所含的节点超过 800 个左右时发出警告 当 body 元素所含的节点数量超过约 1,400 个时出现的错误

总结

根据上面的分析,可以得出一个结论:加载慢是因为 DOM 节点数量太多,且大部分为可视区以外的节点

因此可以得到一个优化的方向:只渲染可视区内节点,同时随滚动动态更新可视区内节点

定高

虚拟滚动的难点在于计算当前滚动位置可视区内需要显示的数据,如果只显示需要的数据,那么高度固定的可视区内包含的节点数量不足以撑开可视区显示滚动条,更别说触发滚动事件进行动态更新了

首先要解决的就是可视区的滚动条显示问题,只需要可视区内有一个节点的高度大于可视区容器高度即可,高度则是所有数据的总高度,这个节点我称之为滚动容器,有两个作用:

  • 撑开可视区容器,营造出所有数据已被渲染的错觉
  • 容纳那些需要被动态更新的节点,滚动的同时不仅要更新数据还要更新位置

假设列表中每条数据项的高度固定,那么滚动容器的高度就是:数据项数量 x 数据项高度

<script setup lang="ts">
import { computed, ref, type Ref, watch } from 'vue'
 
const props = defineProps<{
  height: number
  itemHeight: number
  data: { id: number; height: number }[]
}>()
 
const totalHeight = computed(() => {
  return props.data.length * props.itemHeight
})
</script>
 
<template>
  <div class="virtual-scroller" @scroll="onScroll" :style="{ height: props.height + 'px' }">
    <div :style="{ minHeight: totalHeight + 'px' }">
    </div>
  </div>
</template>

有滚动条自然就可以触发滚动事件,触发滚动事件就可以获取可视区容器的滚动高度,配合滚动高度和数据项高度计算出当前滚动位置下可视数据的开始索引和结束索引,然后根据这两个索引提取出需要的数据

<script setup lang="ts">
import { computed, ref, type Ref, watch } from 'vue'
 
// ...
 
const visibleCount = computed(() => {
  return Math.ceil(props.height / props.itemHeight)
})
 
const renderList: Ref<{ id: number; height: number }[]> = ref([])
 
const onScroll = (e: any) => {
  const scrollTop = e.currentTarget.scrollTop
  updateRenderList(scrollTop)
}
 
const updateRenderList = (scrollTop: number) => {
  const startIndex = getStartIndex(scrollTop)
  const endIndex = getEndIndex(startIndex)
  renderList.value = getRenderList(startIndex, endIndex)
}
 
const getStartIndex = (scrollTop: number) => {
  return Math.max(0, Math.ceil(scrollTop / props.itemHeight) - 1)
}
 
const getEndIndex = (startIndex: number) => {
  return Math.min(props.data.length - 1, startIndex + visibleCount.value - 1)
}
 
const getRenderList = (startIndex: number, endIndex: number) => {
  return props.data.slice(startIndex, endIndex + 1)
}
 
watch(
  () => props.data,
  () => {
    updateRenderList(0)
  }
)
</script>
 
<template>
  <div class="virtual-scroller" @scroll="onScroll" :style="{ height: props.height + 'px' }">
    <div :style="{ minHeight: totalHeight + 'px' }">
        <div
          class="item"
          v-for="item in renderList"
          :key="item.id"
          :style="{ height: item.height + 'px' }"
        >
          {{ item.id }}
        </div>
      </div>
    </div>
  </div>
</template>

如果现在尝试滚动的话会发现列表随着向下滚动逐渐消失在可视区外,同时滚动到一定位置松手后还是会不停地触发滚动事件,导致要显示的数据不停更新而出现闪烁的现象

先解决列表消失在可视区外的问题,出现这个问题的原因是列表是贴着滚动容器顶部向下排列的,当滚动时其实相对于滚动容器相对于可视区容器向上偏移,要解决这个问题,需要单独给所有列表项添加一个偏移量,即滚动容器向上偏移的同时所有列表项要向下偏移,使列表项一直处于可视区内

<script setup lang="ts">
import { computed, ref, type Ref, watch } from 'vue'
 
// ...
 
const translateY = ref(0)
 
const updateRenderList = (scrollTop: number) => {
  // ...
  translateY.value = getTranslateY(startIndex)
}
</script>
 
<template>
  <div class="virtual-scroller" @scroll="onScroll" :style="{ height: props.height + 'px' }">
    <div :style="{ minHeight: totalHeight + 'px' }">
      <div :style="{ transform: `translateY(${translateY}px)` }">
        <div
          class="item"
          v-for="item in renderList"
          :key="item.id"
          :style="{ height: item.height + 'px' }"
        >
          {{ item.id }}
        </div>
      </div>
    </div>
  </div>
</template>

现在滚动可以正常看到效果,甚至闪烁问题都解决了,只是在滚动过程中会看到数据项是一个一个跳出来的,而且底部会出现白色空隙,一眼就能看出来是动态更新的,没有营造出一次性渲染所有数据的错觉,其实就是因为滚动事件的触发不够及时,可视区内渲染的节点数量还不够导致的,需要给上下可视区外添加缓冲区,这样在滚动到缓冲区时就能提前预渲染好下一组数据

<script setup lang="ts">
import { computed, ref, type Ref, watch } from 'vue'
 
const props = defineProps<{
  // ...
  cacheCount: number
}>()
 
const updateRenderList = (scrollTop: number) => {
  const startIndex = getStartIndex(scrollTop)
  const endIndex = getEndIndex(startIndex)
  const cacheStartIndex = Math.max(0, startIndex - props.cacheCount)
  const cacheEndIndex = Math.min(props.data.length - 1, endIndex + props.cacheCount)
  translateY.value = getTranslateY(cacheStartIndex)
  renderList.value = getRenderList(cacheStartIndex, cacheEndIndex)
}
</script>

目前滚动看起来基本实现了需求,但是有一个优化点,在执行滚动更新时候没有做节流处理,一帧内触发多次,浏览器也无法及时渲染多次,造成多余的执行时间,这里可以使用 requestAnimationFrame 将延迟更新操作

const onScroll = (e: any) => {
  const scrollTop = e.currentTarget.scrollTop
  requestAnimationFrame(() => {
    updateRenderList(scrollTop)
  })
}

不定高

假设列表每条数据的高度是不固定的,就无法提前计算出滚动容器的高度,进而导致当前滚动位置与实际需要渲染的数据不符

整体解决思路就是预估每条数据的最小高度,每一条数据首次渲染后获取数据的真实高度并缓存起来,再根据真实高度进行二次渲染,这两次渲染要保证在一帧内完成

先看看数据不定高的情况下会有什么问题

<script setup lang="ts">
import DynamicScroller from './components/DynamicScroller.vue'
import { ref, type Ref, onMounted } from 'vue'
 
const data: Ref<{ id: number; height: number }[]> = ref([])
 
function getData() {
  const temp = []
  for (let i = 0; i < 50; i++) {
    temp.push({
      id: i + 1,
      height: Math.random() * 100 + 50
    })
  }
  data.value = temp
}
 
onMounted(() => {
  getData()
})
</script>
 
<template>
  <DynamicScroller :height="500" :data="data" :cache-count="2" :item-height="50"></DynamicScroller>
</template>

以上代码基于定高更改

在滚动过程中会发现每次出现新的数据,画面的都会跳动一下,这是因为当前滚动位置所显示数据的真实偏移量与计算出来的偏移量不一致导致的,不仅是原本偏移量的计算方式有问题,数据开始索引的计算也有问题

按照预估的最小高度进行首次渲染

const props = defineProps<{
  // ...
  minHeight: number
  data: { id: number; height: number }[]
}>()
 
const totalHeight = computed(() => props.data.length * props.minHeight)

用一个数组缓存数据项的真实高度和偏移量,这个偏移量相当于当前数据项前面所有数据项加起来的高度,后面计算开始索引时会用到,假设每个数据项都是以预估的最小高度进行初始化

let itemSizes: { offset: number; height: number }[] = updateItemSizes()
 
function updateItemSizes() {
  const l = props.data.length
 
  const minHeight = props.minHeight
 
  const sizes = []
 
  let currentOffset = 0
 
  for (let i = 0; i < l; i++) {
    const currentHeight = itemSizes[i]?.height || minHeight
 
    sizes.push({
      offset: currentOffset,
      height: currentHeight
    })
 
    currentOffset += currentHeight
  }
 
  return sizes
}

注意这里避免在遍历中使用 props,每次遍历中读取 props 都会走一遍 Vue 的内部逻辑,会拖慢遍历速度,可以用变量存起来再读取

计算开始索引和结束索引,开始索引这里使用二分法提高查找效率

// 开始索引
const getStartIndex = () => {
  let a = 0
 
  let b = props.data.length - 1
 
  let i = Math.floor((a + b) / 2)
 
  let oldI
 
  while (i !== oldI) {
    oldI = i
 
    const offset = itemSizes[i].offset
 
    const height = itemSizes[i].height
 
    if (offset + height <= scrollTop) {
      a = i
    } else if (offset + height > scrollTop) {
      if (i > 0) {
        const prevOffset = itemSizes[i - 1].offset
 
        const prevHeight = itemSizes[i - 1].height
 
        if (prevOffset + prevHeight <= scrollTop) break
      }
 
      b = i
    }
    i = Math.floor((a + b) / 2)
  }
 
  return i
}
// 结束索引
const getEndIndex = (startIndex: number) => {
  let index = startIndex
 
  let offset = itemSizes[index].offset
 
  let height = itemSizes[index].height
 
  const theLastIndex = props.data.length - 1
 
  while (offset + height < scrollTop + props.height && index < theLastIndex) {
    index++
 
    offset = itemSizes[index].offset
 
    height = itemSizes[index].height
  }
 
  return index
}

滚动容器的总高度等于最后一个数据项的偏移量加自身的真实高度

const totalHeight = ref(props.data.length * props.minHeight)
 
const getTotalHeight = () => {
  return itemSizes[itemSizes.length - 1].offset + itemSizes[itemSizes.length - 1].height
}

首次渲染后,要进行二次渲染获取真实高度进行滚动位置的矫正,使用 nextTick 等待首次渲染完成才能二次渲染获取真实高度

let scrollRef: Ref<HTMLDivElement | undefined> = ref()
 
const updateRenderList = () => {
  // ...
  nextTick(() => reUpdateRenderList(cacheStartIndex, cacheEndIndex))
}
 
const reUpdateRenderList = (startIndex: number, endIndex: number) => {
  const domList = containerRef.value?.getElementsByClassName('item') || []
 
  for (let index = startIndex; index <= endIndex; index++) {
      itemSizes[index].height = (domList[index - startIndex] as HTMLElement).offsetHeight
  }
 
  const oldStartOffset = itemSizes[startIndex].offset + itemSizes[startIndex].height
 
  itemSizes = updateItemSizes()
 
  totalHeight.value = getTotalHeight()
 
  if (scrollTop !== 0) { const newOldStartOffset = itemSizes[startIndex].offset + itemSizes[startIndex].height
 
  const offset = newOldStartOffset - oldStartOffset
 
  if (scrollRef.value) {
    scrollTop += offset
 
    scrollRef.value.scrollTop = scrollTop
  }
 
  updateRenderList()
  }
}

对于计算过真实高度的数据项可以优化一下,用 Set 缓存已经计算过真实高度的数据项索引,下次遇到时就跳,避免不必要的渲染

let measuredItemIndexs = new Set()
 
const reUpdateRenderList = (startIndex: number, endIndex: number) => {
  const domList = containerRef.value?.getElementsByClassName('item') || []
  let hasChanged = false
 
  for (let index = startIndex; index <= endIndex; index++) {
    if (!measuredItemIndexs.has(index)) {
      measuredItemIndexs.add(index)
 
      itemSizes[index].height = (domList[index - startIndex] as HTMLElement).offsetHeight
 
      hasChanged = true
    }
  }
 
  if (hasChanged) {
    const oldStartOffset = itemSizes[startIndex].offset + itemSizes[startIndex].height
 
    itemSizes = updateItemSizes()
 
    totalHeight.value = getTotalHeight()
 
    if (scrollTop !== 0) {
      const newOldStartOffset = itemSizes[startIndex].offset + itemSizes[startIndex].height
 
      const offset = newOldStartOffset - oldStartOffset
 
      if (scrollRef.value) {
        scrollTop += offset
 
        scrollRef.value.scrollTop = scrollTop
      }
    }
 
    updateRenderList()
  }
}

源码

https://github.com/ReinerLau/virtual-scroller.git