文章

虚拟列表实现原理深度解析

从定高到动态高度,从 offset 定位到 transform 优化,彻底搞懂虚拟列表的核心原理和实现细节

虚拟列表实现原理深度解析

一句话概括

虚拟列表把「渲染 10000 个 DOM」变成「渲染屏幕上能看到的 10-20 个 DOM」——通过一个占位元素撑起正确的滚动条高度,再用滚动位置反算当前该显示哪些数据项,让浏览器的 DOM 节点数从 O(n) 降到 O(1)。

核心知识点

1. 核心公式:一个除法决定一切

虚拟列表的数学本质就两个公式:

1
2
3
const startIndex = Math.floor(scrollTop / itemHeight)            // 从第几个开始
const endIndex   = Math.ceil((scrollTop + containerHeight) / itemHeight)  // 到第几个结束
const offsetY    = startIndex * itemHeight                       // 数据块的视觉偏移量

用这三个值渲染的列表:

  • 顶部放一个 height = offsetY 的空白 div(撑出正确的起始位置)
  • 中间渲染 items.slice(startIndex, endIndex) 的真实 DOM
  • 底部自然落在剩余空间内
1
2
3
4
5
6
7
8
9
<div class="container" style="height:600px; overflow:auto" onscroll="render()">
  <div class="phantom"  style="height:10000px"></div>  <!-- 撑起完整滚动高度 -->
  <div class="viewport" style="transform:translateY(500px); position:absolute">
    <!-- 只有当前可见的 DOM -->
    <div class="item">Item 10</div>
    <div class="item">Item 11</div>
    ...
  </div>
</div>

2. 定高虚拟列表:20 行代码就能跑

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
function FixedVirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
  const [scrollTop, setScrollTop] = useState(0)

  const totalHeight = items.length * itemHeight
  const startIndex  = Math.floor(scrollTop / itemHeight)
  const endIndex    = Math.min(
    Math.ceil((scrollTop + containerHeight) / itemHeight),
    items.length
  )
  const visibleItems = items.slice(startIndex, endIndex)

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={e => setScrollTop(e.currentTarget.scrollTop)}
    >
      {/* 占位层:撑起完整滚动高度 */}
      <div style={{ height: totalHeight }} />

      {/* 可视层:用 transform 偏移到正确位置,避免 reflow */}
      <div style={{ transform: `translateY(${startIndex * itemHeight}px)` }}>
        {visibleItems.map((item, i) => (
          <div key={startIndex + i} style={{ height: itemHeight }}>
            {item}
          </div>
        ))}
      </div>
    </div>
  )
}

为什么用 transform: translateY 而不是 top? top 触发 layout/reflow,transform 只触发 composite——GPU 合成线程处理,不阻塞主线程。

3. 动态高度:缓存是灵魂

实际场景中列表项高度几乎不会一致。核心思路:维护一个高度缓存数组 positions,滚动时二分查找 startIndex。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 每个列表项的元信息
interface ItemPosition {
  index: number
  top: number        // 该项顶部距离列表顶部的距离
  bottom: number     // 该项底部距离列表顶部的距离
  height: number     // 该项的实际高度(首次测量后写入)
}

// 给定 scrollTop,二分找到第一个 bottom > scrollTop 的项
function findStartIndex(positions: ItemPosition[], scrollTop: number): number {
  let low = 0, high = positions.length - 1
  while (low <= high) {
    const mid = (low + high) >> 1
    if (positions[mid].bottom <= scrollTop) low = mid + 1
    else high = mid - 1
  }
  return low
}

关键点:虚拟列表首次渲染时,height 是估算值(比如 50)。DOM 渲染后用 getBoundingClientRect() 拿到真实高度后,更新缓存并向后修正所有后续项的 top 和 bottom——这一步叫「缓存校准」,不做会导致滚动条跳动。

4. 缓冲区:防止快速滚动白屏

视口内显示 10 项时,快速滚动可能出现短暂白屏——因为 onScroll 还没触发,新项还未渲染。解决方案:在可视区域上下各多渲染 3-5 项缓冲:

1
2
3
4
5
6
7
8
const BUFFER = 4  // 上下各 4 项

const renderStart = Math.max(0, startIndex - BUFFER)
const renderEnd   = Math.min(items.length, endIndex + BUFFER)

// 渲染范围 = 真的先用 startIndex 算 offset,但渲染时用 renderStart
// 偏移量修正:
const offsetY = renderStart > 0 ? positions[renderStart - 1]?.bottom ?? 0 : 0

5. 使用现成库:不要重复造轮子

生产环境直接用成熟的库:

1
2
3
4
5
6
7
8
9
10
11
12
13
// react-window(41KB,够用)
import { FixedSizeList } from 'react-window'
<FixedSizeList height={600} itemCount={10000} itemSize={50}>
  {({ index, style }) => <div style={style}>Row {index}</div>}
</FixedSizeList>

// @tanstack/virtual(框架无关,支持动态高度)
import { useVirtualizer } from '@tanstack/react-virtual'
const virtualizer = useVirtualizer({
  count: 10000,
  getScrollElement: () => ref.current,
  estimateSize: () => 50,
})

自己造轮子只在两种场景有意义:面试手写题 + 有特殊性能需求且能写对。

其实你每天都在用

  1. 微信聊天记录:几年几万条消息,往上翻时只有看到的几条有 DOM——这就是虚拟列表的典型场景。
  2. Ant Design / Element Plus 的 Table 组件:它们内置的 virtual 属性开启后,100 万行表也不会崩。
  3. Notion / 飞书文档的大纲树:侧边栏无限嵌套的文档树,肉眼看不到的节点全被虚拟化了。
  4. VSCode 的文件列表:打开大项目时 File Explorer 不会卡,因为只渲染可见区域的文件项。
  5. 无限滚动的信息流:知乎、小红书往下刷,旧的 DOM 被回收、新的被创建——虚拟列表 + 数据分页的组合。

常见误解(FAQ)

❌ 误区:「虚拟列表就是懒加载,跟图片懒加载一样」

完全不同。图片懒加载是「不下载资源」,虚拟列表是「不创建 DOM」。一个省带宽,一个省 DOM 计算——虚拟列表的数据必须全量在内存里,只是不全部渲染。

❌ 误区:「虚拟列表总是更快」

列表项少于 100 条时,虚拟列表反而更慢——因为滚动计算和 DOM 交换有固定开销。经验值:超过 500 项再开虚拟化,否则直接渲染更简单。

❌ 误区:「transform: translateY 一定比 top 快」

用 transform 的前提是需要配合 will-change: transform 来提升到独立合成层。否则浏览器可能会为每个 scroll 事件重新合成。并且 will-change 也有代价——过度使用会导致 GPU 内存爆炸。

❌ 误区:「动态高度列表的预估高度可以随便填」

预估值越接近真实值,虚拟列表的初始布局越准确、后续校准开销越小。如果预估 50 实际 200,首次滚动时会频繁跳位置。好的做法是用实际数据的历史均值做预估。

一句话总结

虚拟列表是「用计算换 DOM」的典型——用几行除法和二分查找,把 O(n) 的渲染负担砍到 O(1),让浏览器可以平静地渲染百万级数据。

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