虚拟列表+无限滚动的实现

952 阅读7分钟

一、什么是虚拟列表和无限滚动?

这里我们从它们的概念、原理、优点和场景进行回答。

首先,虚拟列表和无限滚动是在网页开发和应用程序开发中用于优化列表展示和用户体验的两种技术

1. 虚拟列表

  • 概念:虚拟列表是一种只渲染可见区域内列表项的技术。它通过计算当前视口内可见的列表项范围,只创建和更新这些可见项的 DOM 元素,而不是一次性渲染整个列表的所有项。

  • 原理:需要计算可视区域中,渲染元素的开始下标、结束下标,在滚动的时候,去更改这个开始下标,结束下标依赖开始下标。然后,仅渲染这个范围内的列表项,并在用户滚动时动态更新可见区域的内容。(在下面的内容会具体讲解)

  • 优点:显著减少了 DOM 元素的数量,提高了页面的渲染性能和加载速度,尤其是在处理大量数据时,可以避免因创建过多 DOM 元素而导致的内存占用过高和页面卡顿问题。

  • 适用场景:适用于数据量庞大的场景,如商品列表、员工列表、学生名单等。

2. 无限滚动

  • 概念:无限滚动是一种当用户滚动到页面底部时,自动加载更多内容的技术,给用户一种列表内容无限的感觉。
  • 工作原理:这里有两种方式,来加载更多
    • 第一种:通过监听页面的滚动事件,当检测到用户滚动到离页面底部一定距离时,触发加载更多数据的操作。新数据会被追加到当前列表的末尾,实现无缝滚动加载。
    • 第二种:在底部设置一个容器,通过 IntersectionObserver API 来判断这个元素是否出现在视口,从而来加载更多数据。
  • 优点:提供了一种流畅的浏览体验,用户无需手动点击 “加载更多” 按钮或翻页,能够持续浏览内容,提高了用户获取信息的效率和连贯性。
  • 适用场景:常用于社交媒体动态、新闻资讯列表、图片画廊等场景,这些场景通常有大量的内容需要展示,且用户希望能够不断浏览新的信息而不被打断。

总结:

  • 虚拟列表 -> 性能优化
  • 无限滚动 -> 用户体验

二、固定高度虚拟列表

固定高度的虚拟列表实现起来还是比较简单的,因为每个子项的高度是固定的。

需要计算:

  • 开始下标:Math.floor(scrollTop / 每一项高度)
  • 结束下标:开始下标 + 可视区域数量
  • offset:开始下标 * 每一项高度

固定高度滚动.png

具体实现

<template>
  <!-- 列表容器,监听滚动事件 -->
  <div
    class="infinite-container"
    ref="containerRef"
    @scroll="handleScroll"
  >
    <!-- 占位区域,用于模拟完整列表的高度 -->
    <div
      class="infinite-placeholder"
      :style="{ height: `${containerHeight}px` }"
    ></div>
    <!-- 实际渲染的内容,根据偏移量动态调整位置,因为上面这个滚动条展位容器采用的是定位,
        但是因为 .render-container 这个容器高度是不会变化的,永远只有5个元素这么高(如果只渲染5个可视元素)
        随着滚动,这个容器一个是在外层容器的顶部,不会进行偏移,所以需要使用便宜,让 .render-container 容器能正常移动到视口
     -->
    <div
      class="render-container"
      :style="{ transform: `translate3D(0, ${offset}px, 0)` }"
    >
      <!-- 渲染的列表项 -->
      <div
        class="infinite-item"
        v-for="item in renderedItems"
        :key="item.uid"
        :style="{ height: `${props.itemSize}px` }"
      >
        {{ item.value }}
      </div>
    </div>
  </div>
</template>

<script
  setup
  lang="ts"
>
  import { computed, onMounted, ref } from 'vue'

  // 定义组件的 props
  const props = defineProps<{
    listData: { value: any; uid: any }[] // 列表数据,每项包含值和唯一标识符
    itemSize: number // 每项的高度
    bufferCount: number // 缓冲区大小
  }>()

  // 屏幕高度(用于计算可视区域的显示数量)
  const screenHeight = ref(0)
  // 当前滚动距离(顶部到当前可视区域顶部的距离)
  const scrollTop = ref(0)
  // 列表容器的引用,用于获取容器的实际高度
  const containerRef = ref<HTMLElement | null>(null)

  // 列表总数量
  const totalItemCount = computed(() => props.listData.length)

  // 列表容器的总高度,计算方法为:总数量 * 每项高度
  const containerHeight = computed(() => totalItemCount.value * props.itemSize)

  // 可视区域显示的数量,取决于容器高度和每项的高度(向上取整) Math.ceil(1.6) => 2
  const visibleCount = computed(() =>
    Math.ceil(screenHeight.value / props.itemSize)
  )

  // // 当前滚动位置对应的起始索引
  // const startIndex = computed(() => Math.floor(scrollTop.value / props.itemSize))
  // // 当前滚动位置对应的结束索引,基于起始索引和可视数量
  // const endIndex = computed(() => startIndex.value + visibleCount.value)

  // 计算当前可视范围的顶部索引
  const topIndex = computed(() => Math.floor(scrollTop.value / props.itemSize))

  // 当前滚动位置对应的起始索引
  const startIndex = computed(() =>
    Math.max(0, topIndex.value - props.bufferCount)
  )

  // 当前滚动位置对应的结束索引,基于起始索引和可视数量
  const endIndex = computed(() =>
    Math.min(
      totalItemCount.value - 1,
      topIndex.value + visibleCount.value + props.bufferCount
    )
  )

  // 当前需要渲染的列表项,基于起始索引和结束索引
  const renderedItems = computed(() =>
    props.listData.slice(startIndex.value, endIndex.value)
  )

  // 偏移位置,用于调整显示位置,使列表与滚动位置对齐
  const offset = computed(() => startIndex.value * props.itemSize)

  // 滚动事件处理函数,更新当前滚动位置
  const handleScroll = (e: Event) => {
    scrollTop.value = (e.target as HTMLElement).scrollTop
  }

  // 组件挂载后,初始化屏幕高度
  onMounted(() => {
    screenHeight.value = containerRef.value?.clientHeight ?? 0
  })
</script>

<style scoped>
  /* 列表容器样式 */
  .infinite-container {
    border: 1px solid red; /* 红色边框,用于调试 */
    position: relative; /* 相对定位 */
    overflow: auto; /* 可滚动 */
    height: 100%; /* 占满父容器高度 */
  }

  /* 占位元素样式,用于模拟完整列表高度 */
  .infinite-placeholder {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    z-index: -1; /* 放在内容后面 */
  }

  /* 列表项样式 */
  .infinite-item {
    border-bottom: 1px solid blue; /* 每项底部的蓝色边框 */
  }
</style>

三、不定高虚拟列表 + 无限滚动

思路:

  • 需要传递一个新的参数estimatedHeight,表示预估高度,初始化的时候,列表的源数据 * 预估高度,来计算列表的总高度占位
  • 需要缓存一个positions的位置数组,用来记录每个元素的位置信息,类型为:
// 记录 dataSource 数据的位置信息
interface IPosInfo {
  // 当前pos对应的元素索引
  index: number
  // 元素顶部所处位置
  top: number
  // 元素底部所处位置
  bottom: number
  // 元素高度
  height: number
  // 高度差:判断是否需要更新
  dHeight: number
}

const positions = ref<IPosInfo[]>([])
  • 在元素进入可视区域后,去根据元素实际渲染的高度,去更新这个positions数组。当前视口内和已经滚动过的元素,这个 positions 记录的 top、bottom、height 都是实际值,而未出现过的元素,就使用的是预估高度。

    • 计算初始位置信息
    // 初始化:只会计算新增的位置信息,通过estimatedHeight预设高度,进行默认配置
    const initPosition = () => {
      // 计算加载更多的位置信息(默认为 estimatedHeight * 当前元素下标,需要累加)
      const pos: IPosInfo[] = []
      // “加载更多”的数据长度,如果初次渲染,就是当前渲染的数据长度
      const disLen = props.dataSource.length - state.preLen
      // 获取当前数据源的长度(加载更多之前的)
      const currentLen = positions.value.length
      // 获取当前数据源最后一个元素的高度(加载更多之前的),第一次渲染时,为0
      const preBottom = positions.value[currentLen - 1]
        ? positions.value[currentLen - 1].bottom
        : 0
      // 遍历当前加载数据源,对每个元素通过 estimatedHeight 预设高度,计算初始高度信息
      for (let i = 0; i < disLen; i++) {
        const item = props.dataSource[state.preLen + i]
        pos.push({
          index: item.id,
          // 预设高度
          height: props.estimatedHeight,
          top: preBottom
            ? preBottom + i * props.estimatedHeight
            : item.id * props.estimatedHeight,
          bottom: preBottom
            ? preBottom + (i + 1) * props.estimatedHeight
            : (item.id + 1) * props.estimatedHeight,
          // 预设高度与真实高度差:判断是否需要更新,默认为0
          dHeight: 0
        })
      }
      // 更新数据源位置信息
      positions.value = [...positions.value, ...pos]
      // 更新新的长度
      state.preLen = props.dataSource.length
    }
    
    • 滚动后,更新视口内元素的真实高度,并更新未显示元素的信息
    // 数据 item 渲染完成后,更新数据item的真实高度
    // 但也只是计算可视区域中每一个渲染元素的高度,来重新计算后续未渲染的元素,但是在可视区域前的元素位置信息会被缓存记录
    // 对于可视区域前的元素,其位置信息会被缓存记录,不会被重新计算,从而优化性能。
    const setPosition = () => {
      // 渲染区域中的元素
      const nodes = listRef.value?.children
      if (!nodes || !nodes.length) return
      Array.from(nodes).forEach((node) => {
        // 获取每个元素高度
        const rect = node.getBoundingClientRect()
        // 在每个渲染元素上添加id标识,:id="String(i.id)",用于滚动时定位
        // 获取当前元素
        const item = positions.value[Number(node.id)]
        // 判断是否需要更新,通过position之前计算的高度 - 当前渲染后的真实高度,来得到一个差值,如果差值不为0,则更新位置信息
        const dHeight = item.height - rect.height
        // 更新当前元素的位置信息
        if (dHeight) {
          item.height = rect.height
          item.bottom = item.bottom - dHeight
          item.dHeight = dHeight
        }
      })
    
      // 获取渲染区域中第一个元素的id
      const startId = Number(nodes[0].id)
      // 获取第一个元素的dHeight 差值信息
      let startDHeight = positions.value[startId].dHeight
      const len = positions.value.length
      // 第一个元素在上面的循环中,已经调整过了,虽然上面遍历了可视区域的其他元素,但是因为有这个dheight,导致高度不是很准确,需要重新计算
      positions.value[startId].dHeight = 0
      // 遍历渲染区域中第二个元素到数据源最后一个数据,在可视区域的元素通过dheight进行校准,
      // 不在可视区域的根据estimatedHeight高度,来调整top、bottom
      for (let i = startId + 1; i < len; i++) {
        const item = positions.value[i]
        item.top = positions.value[i - 1].bottom
        item.bottom = item.bottom - startDHeight
        if (item.dHeight !== 0) {
          startDHeight += item.dHeight
          item.dHeight = 0
        }
      }
    
      // 更新列表高度
      state.listHeight = positions.value[len - 1].bottom
    }
    
  • 当滚动到底部时,触发加载更多,并向外抛出事件

完整代码:

<template>
  <div class="virtuallist-container">
    <div
      class="virtuallist-content"
      ref="contentRef"
      @scroll="handleScroll"
    >
      <div
        class="virtuallist-placeholder"
        :style="{ height: `${state.listHeight}px` }"
      ></div>
      <div
        class="virtuallist-list"
        ref="listRef"
        :style="scrollStyle"
      >
        <div
          class="virtuallist-list-item"
          v-for="i in renderList"
          :key="i.id"
          :id="String(i.id)"
        >
          <slot
            name="item"
            :item="i"
          ></slot>
        </div>
      </div>
    </div>
  </div>
</template>

<script setup lang="ts" generic="T extends {id:number}">
import {
  type CSSProperties,
  computed,
  nextTick,
  onMounted,
  reactive,
  ref,
  watch
} from 'vue'
import { useThrottle } from './tool'

// 不定高虚拟列表
interface IEstimatedListProps<T> {
  loading: boolean
  // 预估高度(越小越好,尽量比实际渲染高度小)
  estimatedHeight: number
  dataSource: T[]
}
const props = defineProps<IEstimatedListProps<T>>()

// 加载更多触发的事件
const emit = defineEmits<{
  getMoreData: []
}>()

defineSlots<{
  item(props: { item: T }): any
}>()

// 容器 ref
const contentRef = ref<HTMLDivElement>()
// 列表 ref
const listRef = ref<HTMLDivElement>()
// 记录 dataSource 数据的位置信息
interface IPosInfo {
  // 当前pos对应的元素索引
  index: number
  // 元素顶部所处位置
  top: number
  // 元素底部所处位置
  bottom: number
  // 元素高度
  height: number
  // 高度差:判断是否需要更新
  dHeight: number
}
const positions = ref<IPosInfo[]>([])

const state = reactive({
  // contentRef 容器高度
  viewHeight: 0,
  // 列表高度
  listHeight: 0,
  // 渲染列表开始下标
  startIndex: 0,
  // 可视区域最大渲染数量
  maxCount: 0,
  // 存储“加载更多”前的数据长度
  preLen: 0
})

const init = () => {
  // 获取容器高度
  state.viewHeight = contentRef.value ? contentRef.value.offsetHeight : 0
  // 获取可视区域最大渲染数量(+1是为了设置缓冲大小)
  state.maxCount = Math.ceil(state.viewHeight / props.estimatedHeight) + 1
}

onMounted(() => {
  init()
})

// 渲染列表结束下标
const endIndex = computed(() =>
  Math.min(props.dataSource.length, state.startIndex + state.maxCount)
)

// 渲染列表,做截取
const renderList = computed(() =>
  props.dataSource.slice(state.startIndex, endIndex.value)
)

// 列表偏移量(offset)
const offsetDis = computed(() =>
  state.startIndex > 0 ? positions.value[state.startIndex - 1].bottom : 0
)

// 列表偏移样式
const scrollStyle = computed(
  () =>
    ({
      transform: `translate3d(0, ${offsetDis.value}px, 0)`
    } as CSSProperties)
)

// 在数据初始化,数据源改变时会重新计算位置信息
watch(
  () => props.dataSource.length,
  () => {
    initPosition()
    // 确保在最新dom中,能获取到可视区域的元素,并调整位置信息
    nextTick(() => {
      setPosition()
    })
  }
)

// 初始化:只会计算新增的位置信息,通过estimatedHeight预设高度,进行默认配置
const initPosition = () => {
  // 计算加载更多的位置信息(默认为 estimatedHeight * 当前元素下标,需要累加)
  const pos: IPosInfo[] = []
  // “加载更多”的数据长度,如果初次渲染,就是当前渲染的数据长度
  const disLen = props.dataSource.length - state.preLen
  // 获取当前数据源的长度(加载更多之前的)
  const currentLen = positions.value.length
  // 获取当前数据源最后一个元素的高度(加载更多之前的),第一次渲染时,为0
  const preBottom = positions.value[currentLen - 1]
    ? positions.value[currentLen - 1].bottom
    : 0
  // 遍历当前加载数据源,对每个元素通过 estimatedHeight 预设高度,计算初始高度信息
  for (let i = 0; i < disLen; i++) {
    const item = props.dataSource[state.preLen + i]
    pos.push({
      index: item.id,
      // 预设高度
      height: props.estimatedHeight,
      top: preBottom
        ? preBottom + i * props.estimatedHeight
        : item.id * props.estimatedHeight,
      bottom: preBottom
        ? preBottom + (i + 1) * props.estimatedHeight
        : (item.id + 1) * props.estimatedHeight,
      // 预设高度与真实高度差:判断是否需要更新,默认为0
      dHeight: 0
    })
  }
  // 更新数据源位置信息
  positions.value = [...positions.value, ...pos]
  // 更新新的长度
  state.preLen = props.dataSource.length
}

// 数据 item 渲染完成后,更新数据item的真实高度
// 但也只是计算可视区域中每一个渲染元素的高度,来重新计算后续未渲染的元素,但是在可视区域前的元素位置信息会被缓存记录
// 对于可视区域前的元素,其位置信息会被缓存记录,不会被重新计算,从而优化性能。
const setPosition = () => {
  // 渲染区域中的元素
  const nodes = listRef.value?.children
  if (!nodes || !nodes.length) return
  Array.from(nodes).forEach((node) => {
    // 获取每个元素高度
    const rect = node.getBoundingClientRect()
    // 在每个渲染元素上添加id标识,:id="String(i.id)",用于滚动时定位
    // 获取当前元素
    const item = positions.value[Number(node.id)]
    // 判断是否需要更新,通过position之前计算的高度 - 当前渲染后的真实高度,来得到一个差值,如果差值不为0,则更新位置信息
    const dHeight = item.height - rect.height
    // 更新当前元素的位置信息
    if (dHeight) {
      item.height = rect.height
      item.bottom = item.bottom - dHeight
      item.dHeight = dHeight
    }
  })

  // 获取渲染区域中第一个元素的id
  const startId = Number(nodes[0].id)
  // 获取第一个元素的dHeight 差值信息
  let startDHeight = positions.value[startId].dHeight
  const len = positions.value.length
  // 第一个元素在上面的循环中,已经调整过了,虽然上面遍历了可视区域的其他元素,但是因为有这个dheight,导致高度不是很准确,需要重新计算
  positions.value[startId].dHeight = 0
  // 遍历渲染区域中第二个元素到数据源最后一个数据,在可视区域的元素通过dheight进行校准,
  // 不在可视区域的根据estimatedHeight高度,来调整top、bottom
  for (let i = startId + 1; i < len; i++) {
    const item = positions.value[i]
    item.top = positions.value[i - 1].bottom
    item.bottom = item.bottom - startDHeight
    if (item.dHeight !== 0) {
      startDHeight += item.dHeight
      item.dHeight = 0
    }
  }

  // 更新列表高度
  state.listHeight = positions.value[len - 1].bottom
}

const handleScroll = useThrottle(() => {
  const { scrollTop, clientHeight, scrollHeight } = contentRef.value!
  // 根据二分查找更新开始下标
  state.startIndex = binarySearch(positions.value, scrollTop)
  // 如果滚动到底部,则触发加载更多,这里也可以通过 IntersactionObserver 来实现
  const bottom = scrollHeight - clientHeight - scrollTop
  if (bottom <= 20) {
    !props.loading && emit('getMoreData')
  }
})

// 监听开始下标,更新位置信息
watch(
  () => state.startIndex,
  () => {
    setPosition()
  }
)

// 二分查找
const binarySearch = (list: IPosInfo[], value: number) => {
  let left = 0,
    right = list.length - 1,
    templateIndex = -1
  while (left < right) {
    const midIndex = Math.floor((left + right) / 2)
    const midValue = list[midIndex].bottom
    if (midValue === value) return midIndex + 1
    else if (midValue < value) left = midIndex + 1
    else if (midValue > value) {
      if (templateIndex === -1 || templateIndex > midIndex)
        templateIndex = midIndex
      right = midIndex
    }
  }
  return templateIndex
}
</script>

<style scoped lang="scss">
.virtuallist-placeholder {
  position: absolute;
  left: 0;
  top: 0;
  right: 0;
  z-index: -1; /* 放在内容后面 */
}
.virtuallist {
  &-container {
    width: 100%;
    height: 100%;
  }
  &-content {
    width: 100%;
    height: 100%;
    overflow: auto;
    position: relative;
  }

  &-list-item {
    width: 100%;
    box-sizing: border-box;
  }
}
</style>