文章

手写:虚拟列表核心逻辑深度解析

从零手写一个支持定高和动态高度的虚拟列表组件,逐行拆解核心计算、缓存策略和 transform 优化

手写:虚拟列表核心逻辑深度解析

一句话概括

手写虚拟列表就三步:用占位 div 撑起正确滚动条 → 根据 scrollTop 算当前该显示哪些项 → 用 transform 把可见项偏移到正确位置。定高版本 30 行,动高版本加上二分查找和高度缓存约 80 行。

核心知识点

1. 定高版本(30 行,面试直接写这个)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function FixedVirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
  const [scrollTop, setScrollTop] = useState(0)

  const totalHeight   = items.length * itemHeight
  const startIndex    = Math.floor(scrollTop / itemHeight)
  const visibleCount  = Math.ceil(containerHeight / itemHeight)
  const endIndex      = Math.min(startIndex + visibleCount + 2, items.length)  // +2 缓冲
  const offsetY       = startIndex * itemHeight

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight }} />  {/* 占位层 */}
      <div style={{ transform: `translateY(${offsetY}px)` }}>  {/* 可视层 */}
        {items.slice(startIndex, endIndex).map((item, i) => (
          <div key={startIndex + i} style={{ height: itemHeight }}>{item}</div>
        ))}
      </div>
    </div>
  )
}

逐行解释:

  1. totalHeight — 占位 div 的高度 = 所有项的总高,让滚动条长度正确
  2. startIndex — scrollTop / itemHeight 向下取整,算出滚动到了第几项
  3. endIndex — startIndex + visibleCount + 2,多渲 2 项防止快速滚动白屏
  4. offsetY — 可视数据块应该从 startIndex * itemHeight 的位置开始显示
  5. transform: translateY — 用 GPU 合成而不是改 top,避免回流

2. 动态高度版本:二分查找 + 高度缓存

定高版本的核心假设 itemHeight 是常量——现实中几乎没有一个列表能满足这个条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
interface Position {
  index: number
  top: number
  bottom: number
  height: number
}

function DynamicVirtualList({ items, containerHeight = 600, estimateHeight = 50 }) {
  const [scrollTop, setScrollTop] = useState(0)
  const positionsRef = useRef<Position[]>([])

  // 初始化:预估每个项的高度
  useEffect(() => {
    positionsRef.current = items.map((_, i) => ({
      index: i,
      top: i * estimateHeight,
      bottom: (i + 1) * estimateHeight,
      height: estimateHeight,
    }))
  }, [items.length, estimateHeight])

  // 二分查找:找到第一个 bottom > scrollTop 的索引
  const findStartIndex = useCallback((scrollTop: number): number => {
    let low = 0, high = positionsRef.current.length - 1
    while (low <= high) {
      const mid = (low + high) >> 1
      if (positionsRef.current[mid].bottom <= scrollTop) low = mid + 1
      else high = mid - 1
    }
    return low
  }, [])

  const positions = positionsRef.current
  if (positions.length === 0) return null

  const startIndex = findStartIndex(scrollTop)
  const endIndex   = positions.findIndex(
    p => p.bottom > scrollTop + containerHeight
  )
  const actualEnd  = endIndex === -1 ? items.length : Math.min(endIndex + 2, items.length)
  const offsetY    = startIndex > 0 ? positions[startIndex - 1].bottom : 0
  const totalHeight = positions[positions.length - 1]?.bottom ?? 0

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight }} />
      <div style={{ transform: `translateY(${offsetY}px)` }}>
        {items.slice(startIndex, actualEnd).map((item, i) => (
          <ItemRow
            key={startIndex + i}
            item={item}
            index={startIndex + i}
            onHeightMeasured={(index, height) => {
              // 高度校准:实测后更新缓存
              const pos = positions[index]
              const diff = height - pos.height
              if (diff === 0) return
              pos.height = height
              pos.bottom = pos.top + height
              // 向后修正所有后续项的位置
              for (let j = index + 1; j < positions.length; j++) {
                positions[j].top += diff
                positions[j].bottom += diff
              }
            }}
          />
        ))}
      </div>
    </div>
  )
}

三个关键细节:

  1. 二分查找 — 动态高度的 startIndex 不能用除法算,必须二分找。O(log n) 保证滚动计算不成为瓶颈
  2. 高度校准 — 首次用预估值,DOM 渲染后真实高度如果不一致,必须修正 positions 数组中所有后续项的 top/bottom,否则滚动位置会错乱
  3. positions[startIndex - 1].bottom 做 offset — 注意是 startIndex - 1 的 bottom,因为第 0 项的 top 是 0

3. 高度校准的实现细节

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// ItemRow 组件:每次渲染后用 ResizeObserver 测量实际高度
function ItemRow({ item, index, onHeightMeasured }) {
  const ref = useRef<HTMLDivElement>(null)

  useEffect(() => {
    if (!ref.current) return
    const observer = new ResizeObserver(([entry]) => {
      onHeightMeasured(index, entry.contentRect.height)
    })
    observer.observe(ref.current)
    return () => observer.disconnect()
  }, [index, onHeightMeasured])

  return <div ref={ref}>{item}</div>
}

用 ResizeObserver 而非 getBoundingClientRect + onScroll,因为前者是异步回调、不阻塞主线程,而且能捕获内容变化引起的高度变化(如图片加载后撑开高度)。

4. 性能要点总结

要点原因
transform 而非 top/lefttransform 在合成线程,不触发 layout
二分查找O(log n) 找索引,百万级数据也能瞬间定位
ResizeObserver 而非 getBoundingClientRect异步、不强制同步回流
key 用 startIndex + i使 React 在滚动时复用 DOM 而非重建
缓冲区 2-5 项覆盖快速滚动时 scroll 事件触发的延迟

其实你每天都在用

  1. 微信消息列表 — 几万条聊天记录只渲染你看得到的那十几条,就是虚拟列表
  2. Chrome 开发者工具的 Network 面板 — 几百个请求的列表只渲染可见行
  3. VSCode 的文件树 — 大项目不卡就是因为只渲染展开且可见的节点
  4. 飞书多维表格 — 百万行数据表滚动不卡的工作量全在虚拟化上
  5. Ant Design Table 的 virtual prop — 内部就实现了这些逻辑

常见误解(FAQ)

❌ 误区:「虚拟列表就是无限滚动(Infinite Scroll)」

两者解决不同问题。无限滚动解决「数据太多一次加载不完」,通过分页加载;虚拟列表解决「DOM 太多一次渲染不完」,数据全在内存但只渲染可见 DOM。生产环境通常是两者组合:分页加载数据 + 虚拟化渲染当前页。

❌ 误区:「预估高度和实际高度差距大也没事」

预估值偏差越大,首次渲染后的校准步伐越剧烈——用户会看到列表项突然跳位置。好的做法是用后端返回的数据推断:文字量多的项预估高一些,固定模板的项直接写死精确值。

❌ 误区:「动态高度列表会很慢」

二分查找 + ResizeObserver 的组合已经让动态高度不会成为瓶颈。真正慢的是频繁校准大量未渲染项的高度——所以校准逻辑只需要更新 positions 数组中的数字(纯 JS 操作),不要触发额外渲染。

一句话总结

虚拟列表是「用计算换 DOM」的典范——scrollTop 除 height、二分查索引,两行核心公式把十万个 DOM 节点压缩到十几个,但前提是缓存校准必须做对,否则滚动条会背叛你。

本文由作者按照 CC BY 4.0 进行授权