虚拟列表解析

Published on
7 mins read
--- views

历史发布:掘金

发布数据

截至 2026 年 8 月 4 日,掘金上的公开数据如下。展现和阅读由平台分别统计,这里只作为历史记录。

平台展现阅读点赞评论收藏
掘金18375000

虚拟列表解决的核心问题是:当列表数据非常多时,不要一次性把所有 DOM 节点都渲染出来,只保留当前真正需要展示的部分。

一个虚拟列表通常可以划分为三个区域:

上缓冲区
可视区
下缓冲区

可视区负责展示用户当前看到的元素,上下缓冲区额外保留少量元素,减少滚动过程中频繁出现空白的概率。

当滚动使一个元素离开可视范围时,渲染窗口会跟着移动:去掉上方已经不需要的元素,再把下方即将进入视区的元素补进来。

虚拟列表的核心计算

滚动发生后,大致会经过下面这条计算链:

容器滚动
  -> 得到 scrollTop
  -> 根据元素累计高度查找 startIndex
  -> 计算当前需要渲染的元素范围
  -> 计算内容偏移量 offset
  -> 渲染可视区和缓冲区

其中最重要的是两个值:

  • 起始索引 startIndex:可视区从第几个元素开始展示。
  • 完整容器高度 totalHeight:所有列表项完整渲染时占据的总高度。

确定起始索引后,还要得到内容容器的偏移量 offset。它表示第一个已渲染元素在完整列表里的真实顶部位置。

渲染区域通常会通过绝对定位或 transform 移动到正确位置:

.virtual-list-content {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  transform: translateY(var(--offset));
}

外层占位容器仍然保持 totalHeight,这样浏览器才能生成长度正确的滚动条;内层只渲染当前窗口里的少量节点。

固定高度列表

如果每个元素高度相同,计算会很直接。

假设:

itemHeight = 每项高度
itemCount = 元素数量
scrollTop = 当前滚动距离

那么:

const totalHeight = itemCount * itemHeight
const startIndex = Math.floor(scrollTop / itemHeight)
const offset = startIndex * itemHeight

再根据容器高度计算可见元素数量,并在前后加上缓冲区:

const visibleCount = Math.ceil(viewportHeight / itemHeight)
const renderStart = Math.max(0, startIndex - overscan)
const renderEnd = Math.min(itemCount, startIndex + visibleCount + overscan)

固定高度不需要测量每个节点,也不需要维护复杂的高度缓存,因此实现和性能都更稳定。

动态高度列表

元素高度不固定时,不能再通过 索引 × 固定高度 直接计算位置。

这时需要为元素维护高度和累计位置,例如:

interface ItemPosition {
  index: number
  height: number
  top: number
  bottom: number
}

当用户滚动到某个尚未完整计算的位置时,可以:

  1. 从最后一个已知元素开始。
  2. 继续累加未知元素的预估高度或实测高度。
  3. 把每个元素的 topbottom 缓存起来。
  4. 当累计位置覆盖 scrollTop 时,确定 startIndex

有了按位置排序的缓存数组后,还可以通过二分查找快速定位 startIndex,避免每次滚动都从第一项开始线性扫描。

function findStartIndex(positions: ItemPosition[], scrollTop: number) {
  let left = 0
  let right = positions.length - 1

  while (left <= right) {
    const middle = Math.floor((left + right) / 2)
    const item = positions[middle]

    if (item.bottom <= scrollTop) {
      left = middle + 1
    } else if (item.top > scrollTop) {
      right = middle - 1
    } else {
      return middle
    }
  }

  return Math.max(0, left)
}

动态高度列表的难点也在这里:真实高度可能在节点渲染后才知道。如果实测高度和预估高度不同,需要更新后续元素的累计位置,同时尽量避免滚动位置突然跳动。

滚动事件的性能

scroll 事件触发频率很高。如果每次触发都立即读取布局、修改状态和重新渲染,很容易阻塞主线程。

因此通常要通过节流或 requestAnimationFrame 控制计算频率:

let frameId: number | null = null

function handleScroll(event: Event) {
  const scrollTop = (event.currentTarget as HTMLElement).scrollTop

  if (frameId !== null) return

  frameId = requestAnimationFrame(() => {
    updateVisibleRange(scrollTop)
    frameId = null
  })
}

还要尽量避免在同一帧里交替读取和写入布局信息,否则可能触发额外的同步布局计算。

优化的核心

虚拟列表的优化可以收束成三件事:

  1. 减少真实渲染节点数量:只渲染缓冲区和可视区。
  2. 复用正在使用的节点:让框架通过稳定的 Diff 和 Patch 减少 DOM 创建与销毁。
  3. 缓存数据与计算结果:缓存元素高度、累计位置和查找结果,而不是缓存大量 DOM 节点。

无论使用哪一种虚拟列表库,最终都绕不开同一个问题:

根据 scrollTop 快速找到当前应该展示哪些元素,并让这些元素出现在完整列表里的正确位置。

参考阅读:三种虚拟列表原理与实现