✨前端十万条数据渲染(下) -- 虚拟列表

lxf2023-05-05 19:06:01

持续创作,加速成长!这是我参与「AdminJS · 10 月更文挑战」的第2天,点击查看活动详情

序言

在上一篇文章中,我们讲述了如何通过时间分片的方式去渲染十万条数据的解决方案,如果还不了解时间切片的读者可以先去看看上一篇文章 --✨前端十万条数据渲染(上) -- 时间分片

本篇文章将会带你了解以及实现该场景下的另一个解决方案 -- 虚拟列表

什么是虚拟列表?

有了解过按需加载概念的读者或许会很容易理解虚拟列表,虚拟列表的本质就是一种按需加载的设计,我们在浏览列表中的数据时,实际上只需要渲染出可视区域中的列表项即可,而不在可视区域内的列表项其实没必要进行渲染

这样一来无论数据量有多少,管他是十万条还是百万条,最终交给浏览器渲染的只有可视区域那一块的部分数据要渲染而已,相比于一次性渲染大量数据的粗暴方式,能够大幅度降低浏览器的渲染压力,就如下图所示:

✨前端十万条数据渲染(下) -- 虚拟列表

Vue3 + TypeScript 实现虚拟列表

我们的目标就是要保证可视区域内能够渲染出对应的列表项,为了方便实现,这里我就直接使用Vue

明确使用到的数据

基于数据驱动的思想,我们先明确一下可能需要用到哪些数据:

✨前端十万条数据渲染(下) -- 虚拟列表

首先要维护一下上图中的这几个状态数据:

  1. startIdxendIdx用于控制可视区域内展示的数据,会在滚动条发生变化的时候动态变化
  2. itemHeight用于描述列表中元素的高度,这里为了简单起见,我们将元素的高度设为一个固定值,之后再考虑如何优化成任意高度的元素渲染
  3. visibleAreaHeight则用于记录可视区域的高度,这样我们就可以结合itemHeight知道可视区域内的元素个数了

以上这四个都是作为状态去保存的,但仅有它们还不够,我们还需要一些额外的计算属性数据

虚拟列表总高度

首先我们要知道全部数据渲染之后的一个总高度,这样才能将其设置为我们的占位列表容器元素list-container-phantom的高度

因为我们的真实存放元素的列表容器它的高度应当是确定的,但我们又需要知道全部数据#渲染后的一个总高度,这样才方便我们监听滚动条的滚动事件,根据scrollTop去更改我们的startIdxendIdx从而更新虚拟列表中的数据

那么所有数据渲染后的总高度怎么计算呢?我们在接口返回的数据中能够知道一共有多少条数据,并且我们已经知道每条数据最终渲染后的一个高度itemHeight了,那么总高度就是data.length * itemHeight即可

可视区域中元素个数

为什么需要知道这个数据呢?这是为我们的endIdx准备的,滚动条变化时,startIdx根据scrollTop动态变化,而endIdx只需要在startIdx的基础上加上当前可视区域中的元素个数即可,所以需要用到该数据

而这个数据可以通过visibleAreaHeight / itemHeight获得,因此也将其作为一个计算属性

根据以上描述,我们可以维护一个state去记录startIdxendIdxvisibleAreaHeightitemHeight和接口返回的数据data这五个状态数据

其余的则作为计算属性即可

interface IData {
  id: number
  content: string
}

// 可视区域中渲染元素的起始下标
const state = reactive({
  // 要渲染的数据
  data: [] as IData[],
  // 可视区域内的第一个元素在 data 中的下标
  startIdx: 0,
  // 可视区域内的最后一个元素在 data 中的下标
  endIdx: 0,
  // 元素的高度
  itemHeight: 24,
  // 可视区域的高度
  visibleAreaHeight: 0,
})

// 获取接口数据
interface IResponseData {
  code: number
  msg: string
  data: IData[]
}
const fetchData = (dataCount = 100000) => {
  return new Promise<IResponseData>(resolve => {
    const response: IResponseData = {
      code: 0,
      msg: 'success',
      data: [],
    }

    for (let i = 0; i < dataCount; i++) {
      response.data.push({
        id: i,
        content: `content-${i + 1}`,
      })
    }

    setTimeout(() => {
      resolve(response)
    }, 300)
  })
}

const virtualListRef = ref<HTMLDivElement>()

const dataDisplay = computed(() =>
  state.data.slice(state.startIdx, state.endIdx),
)

// 数据的总高度
const dataTotalHeight = computed(() => state.itemHeight * state.data.length)

// 可视区域中的元素个数
const visualAreaItemCount = computed(() =>
  Math.ceil(state.visibleAreaHeight / state.itemHeight),
)

由于虚拟列表中的数据是由startIdxendIdx决定的,所以这里我又加了一个dataDisplay计算属性,在接口返回的数据数组中进行切片即可

组件结构

虽然我们只渲染可视区域中的列表项,但是还是需要知道完整的列表有多高,这样才方便滑动滚动条去按需加载不同的数据,所以这里我们不仅要有一个列表容器,还需要有一个占位用的列表容器,用于形成滚动条

占位列表容器的高度就是最终全部数据渲染完成后的高度,至于这个高度怎么计算可就有讲究了,稍后会讲,先看看页面结构吧

<template>
  <div ref="virtualListRef" class="virtual-list" @scroll="handleScroll">
    <!-- 占位容器 高度和数据的真实高度一致 用于形成滚动条 phantom: 幻影 -->
    <div
      class="list-container-phantom"
      :style="{ height: `${dataTotalHeight}px` }"
    ></div>

    <!-- 真实的列表容器 -->
    <div class="list-container">
      <div
        v-for="item in dataDisplay"
        :key="item.id"
        ref="listItemsRef"
        class="list-item"
        :data-position-idx="item.id"
      >
        <span>
          {{ item.content }}
        </span>
      </div>
    </div>
  </div>
</template>

这里我们还需要监听有滚动条的元素的滚动事件,获取它的scrollTop

const handleScroll = (e: UIEvent) => {
  const scrollTop = (e.target as HTMLDivElement).scrollTop
  state.startIdx = ~~(scrollTop / state.itemHeight)
  state.endIdx = state.startIdx + visualAreaItemCount.value
}

数据初始化

onMounted(async () => {
  // 获取接口数据
  const response = await fetchData()
  state.data = response.data

  // 获取虚拟列表容器的高度
  state.visibleAreaHeight = virtualListRef.value!.clientHeight

  // 初始化 startIdx 和 endIdx
  state.startIdx = 0
  state.endIdx = state.startIdx + visualAreaItemCount.value
})

现在就已经完成了,我们先来看看效果吧~

✨前端十万条数据渲染(下) -- 虚拟列表

咦?怎么好像不太对劲,虽然滚动条移动时虚拟列表内的数据是跟着变化了,但是整个列表貌似也随着滚动条在往上移动,这是怎么回事呢?

加入偏移量让虚拟列表整体偏移到容器中央

之所以会出现上图的情况,是因为我们的滚动条并不是由虚拟列表容器产生的,而是虚拟列表的虚拟列表产生的

说起来有点绕,还记得前面组件结构中我们有一个.list-container-phantom元素吗?这个元素用来占位产生一个滚动条,该元素的高度就是所有数据最终渲染后的高度

因此我们拖拽滚动条的时候,实际上可以理解为整个可视区域在移动,而真正渲染数据的那个虚拟列表一直在最顶部

这就导致滚动条往下拉的时候,产生了整个虚拟列表上移的现象,那这很简单,我们让虚拟列表整体跟着滚动条的可视区域一起移动不就好了吗?

为此我们再引入一个虚拟列表在垂直方向上的偏移量状态,通过transform: translateY()让其随着滚动条的滚动而偏移

const state = reactive({
  // 要渲染的数据
  data: [] as IData[],
  // 可视区域内的第一个元素在 data 中的下标
  startIdx: 0,
  // 可视区域内的最后一个元素在 data 中的下标
  endIdx: 0,
  // 元素的高度
  itemHeight: 24,
  // 可视区域的高度
  visibleAreaHeight: 0,
  // 可视区域在垂直方向上的偏移量
  offset: 0,
})

offset的值实际上就是滚动条的scrollTop,只需要在滚动条变化时加上对它的更新即可

const handleScroll = (e: UIEvent) => {
  const scrollTop = (e.target as HTMLDivElement).scrollTop
  state.startIdx = ~~(scrollTop / state.itemHeight)
  state.endIdx = state.startIdx + visualAreaItemCount.value
  state.offset = scrollTop
}

最后我们再修改一下虚拟列表的样式,加上translateY

<script>
// 虚拟列表整体的偏移量
const offsetStyle = computed(() => `${state.offset}px`)
</script>
<style scoped lang="scss">
  .list-container {
    // ...
    transform: translateY(v-bind(offsetStyle));
  }
</style>

现在的效果如下:

✨前端十万条数据渲染(下) -- 虚拟列表

适配元素任意高度

目前我们的虚拟列表只支持固定高度的元素,但实际上我们无法保证每个元素的高度都是itemHeight,因此需要做一些额外的工作去支持渲染任意高度的子元素才行

引入预估高度和位置状态

为了做到这个功能,我们可以先给每个元素设置一个预估高度,并且建立一个缓存表缓存每一个元素的高度信息,当我们需要用到元素的高度时就直接去找缓存表即可

为此我们引入一个新的状态 -- positions,用于记录每个元素的高度和位置信息(比如topbottom),在每次加载新的数据的时候就更新这个缓存表,使用的时候则直接使用该缓存表的数据即可

interface IDataItemPosition {
  id: number
  height: number
  top: number
  bottom: number
}

const state = reactive({
  ...
  
  // 预估高度
  estimatedItemHeight: 24,
  // 记录每个元素的位置信息 -- 包括高度和位置(如 top 和 bottom)
  positions: [] as IDataItemPosition[],
})

接下来我们就需要利用这个positions缓存表,修改之前的实现,首先先看看如何将其初始化

onMounted(async () => {
  ...
  
  // 初始化每个元素的位置信息
  state.positions = state.data.map((item, idx) => ({
    id: item.id,
    height: state.estimatedItemHeight,
    top: idx * state.estimatedItemHeight,
    bottom: (idx + 1) * state.estimatedItemHeight,
  }))
})

确定列表总高度

前面在元素高度固定的情况下,是通过data.length * itemHeight去确定列表总高度的,那么现在在元素高度不确定的情况下如何确定列表的总高度呢?

由于我们在positions缓存表中记录了元素的bottom,那么最后一个元素的bottom其实就是整个列表的总高度了

// 列表的总高度
const dataTotalHeight = computed(
  () =>
    state.positions.length &&
    state.positions[state.positions.length - 1].bottom,
)

元素渲染后更新缓存表

由于我们的缓存表中每个元素的高度初始值都是预估高度estimatedItemHeight,真实的高度只有在渲染完成后才能知道,所以在渲染结束后我们需要及时更新缓存表

onUpdated(() => {
  const items = listItemsRef.value
  items?.forEach((item, idx) => {
    const height = item.getBoundingClientRect().height
    const positionIdx = Number(item.dataset.positionIdx)
    const oldHeight = state.positions[positionIdx].height
    const delta = oldHeight - height

    if (delta !== 0) {
      // 渲染后元素的实际高度和原先的预估高度不一致时需要更新缓存表
      state.positions[positionIdx].bottom -= delta
      state.positions[positionIdx].height = height

      // 当前元素的后续元素的 top 和 bottom 都需要更新
      for (let i = positionIdx + 1; i < state.positions.length; i++) {
        state.positions[i].top = state.positions[i - 1].bottom
        state.positions[i].bottom -= delta
      }
    }
  })
})

滚动时startIdx和offset的更新

const handleScroll = (e: UIEvent) => {
  const scrollTop = (e.target as HTMLDivElement).scrollTop
  state.startIdx =
    state.positions.find(item => item.bottom > scrollTop)?.id ?? 0
  state.endIdx = state.startIdx + visualAreaItemCount.value
  state.offset =
    state.startIdx === 0 ? 0 : state.positions[state.startIdx - 1].bottom
}

现在就可以针对任意高度的元素进行虚拟列表渲染啦

✨前端十万条数据渲染(下) -- 虚拟列表