文章

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

深入剖析虚拟列表(Virtual List)的核心技术原理,从定高列表到动态高度,从滚动计算到缓存策略,构建完整知识体系。

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

一句话概括

虚拟列表通过仅渲染可视区域内的 DOM 节点,配合精确的滚动偏移计算和缓存策略,使浏览器能够流畅渲染成千上万条数据而不掉帧。

背景与意义

在数据密集型的现代应用中,长列表渲染是一个无法回避的性能挑战。一个即时通讯应用的消息列表可能有数万条记录,一个数据管理后台的表格可能有数十万行。传统做法是将所有数据渲染为 DOM 节点,但浏览器对 DOM 节点的处理能力存在明显瓶颈:

  • 渲染 10,000 个列表项:DOM 节点数超过 10,000,浏览器布局和绘制时间显著增加
  • 渲染 100,000 个列表项:页面几乎不可交互,滚动卡顿明显
  • 渲染 1,000,000 个列表项:浏览器直接崩溃或内存溢出

为了解决这个问题,虚拟列表(Virtual List,也称为虚拟滚动、窗口化渲染)应运而生。它的核心理念是:既然用户在同一时刻只能看到视口范围内的内容,那为什么要把所有数据都渲染出来?

概念与定义

虚拟列表(Virtual List):一种长列表性能优化技术,只渲染用户视口内(及缓冲区)的数据项,视口外的数据项使用空白占位,通过精确的滚动偏移计算来维持完整的滚动条高度和滚动行为。

核心术语

  • 可见区域(Viewport):列表容器的高度,用户能看到的区域
  • 可视项(Visible Items):当前滚动位置下,显示在可见区域中的列表项
  • 缓冲区(Buffer):在可见区域上下额外渲染的项,防止快速滚动时出现白屏
  • 总高度(Total Height):所有列表项高度之和,用于生成正确的滚动条
  • 偏移量(Scroll Offset):当前滚动距离,用于计算需要显示的项

最小示例

以下是一个最简单的定高虚拟列表实现,只渲染可见区域内的 3 项:

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
// SimpleVirtualList.js - 最小虚拟列表示例
const ITEM_HEIGHT = 50
const VISIBLE_COUNT = 3
const TOTAL_DATA = Array.from({ length: 10000 }, (_, i) => `Item ${i + 1}`)

function SimpleVirtualList() {
  const [scrollTop, setScrollTop] = useState(0)

  const totalHeight = TOTAL_DATA.length * ITEM_HEIGHT
  const startIndex = Math.floor(scrollTop / ITEM_HEIGHT)
  const visibleItems = TOTAL_DATA.slice(startIndex, startIndex + VISIBLE_COUNT)

  return (
    <div
      style={{ height: 200, overflowY: 'auto', border: '1px solid #ccc' }}
      onScroll={(e) => setScrollTop(e.target.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ position: 'absolute', top: startIndex * ITEM_HEIGHT }}>
          {visibleItems.map((item, i) => (
            <div key={startIndex + i} style={{ height: ITEM_HEIGHT }}>
              {item}
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

核心知识点拆解

1. 定高虚拟列表的完整实现

定高虚拟列表是所有虚拟列表方案的基础。它假设每个列表项的高度完全相同,这使得偏移量计算变得简单:知道滚动距离,就能算出应该显示哪些项。

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
// FixedSizeVirtualList.js
import { useState, useRef, useCallback, useMemo } from 'react'

function FixedSizeVirtualList({
  data = [],
  itemHeight = 50,
  bufferSize = 5,
  containerHeight = 400,
  renderItem,
}) {
  const [scrollTop, setScrollTop] = useState(0)
  const containerRef = useRef(null)

  const totalHeight = data.length * itemHeight
  const visibleCount = Math.ceil(containerHeight / itemHeight) + bufferSize
  const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - bufferSize)
  const endIndex = Math.min(data.length, startIndex + visibleCount)

  const visibleData = useMemo(
    () => data.slice(startIndex, endIndex),
    [data, startIndex, endIndex]
  )

  const handleScroll = useCallback((e) => {
    setScrollTop(e.target.scrollTop)
  }, [])

  return (
    <div
      ref={containerRef}
      style={{
        height: containerHeight,
        overflow: 'auto',
        border: '1px solid #e0e0e0',
      }}
      onScroll={handleScroll}
    >
      <div
        style={{
          height: totalHeight,
          position: 'relative',
        }}
      >
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            transform: `translateY(${startIndex * itemHeight}px)`,
          }}
        >
          {visibleData.map((item, index) => (
            <div key={startIndex + index} style={{ height: itemHeight }}>
              {renderItem(item, startIndex + index)}
            </div>
          ))}
        </div>
      </div>
    </div>
  )
}

关键逻辑

  • startIndexendIndex 构成了「可见窗口」
  • bufferSize 确保快速滚动时不会出现空白
  • 使用 transform 代替 top 定位,避免触发重排
  • key 使用绝对索引,避免 React 中的 key 冲突

2. 动态高度虚拟列表

实际业务中,列表项往往是变高的——评论区的文字有长有短,IM 消息中的图片和文字混排。此时,我们无法通过简单的数学计算来确定每项的位置。

方案一:预先测量并缓存高度

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
// DynamicSizeVirtualList.js - 带高度缓存的虚拟列表
import { useState, useRef, useCallback } from 'react'

function DynamicSizeVirtualList({
  data = [],
  estimatedItemHeight = 80,
  containerHeight = 400,
  bufferSize = 5,
  renderItem,
}) {
  const [scrollTop, setScrollTop] = useState(0)
  // 缓存每项的实际高度
  const heightsRef = useRef(new Map())
  // 缓存每项的累积偏移位置
  const offsetsRef = useRef(null)

  // 计算所有项的偏移位置
  const getOffsets = useCallback(() => {
    if (offsetsRef.current) return offsetsRef.current

    const offsets = [0]
    data.forEach((_, index) => {
      const height = heightsRef.current.get(index) ?? estimatedItemHeight
      offsets.push(offsets[index] + height)
    })
    offsetsRef.current = offsets
    return offsets
  }, [data, estimatedItemHeight])

  // 二分查找:根据滚动距离找到起始索引
  const findStartIndex = useCallback((scrollTop) => {
    const offsets = getOffsets()
    let left = 0
    let right = offsets.length - 1

    while (left < right) {
      const mid = Math.floor((left + right) / 2)
      if (offsets[mid] < scrollTop) {
        left = mid + 1
      } else {
        right = mid
      }
    }
    return Math.max(0, left - 1)
  }, [getOffsets])

  // 记录某项的实际高度
  const updateHeight = useCallback((index, height) => {
    if (heightsRef.current.get(index) !== height) {
      heightsRef.current.set(index, height)
      offsetsRef.current = null // 标记缓存失效

      // 注意:缓存失效后需要强制重新计算,这里简化处理
      // 实际生产环境下通常需要调度更新或使用状态管理
    }
  }, [])

  const offsets = getOffsets()
  const totalHeight = offsets[offsets.length - 1] || 0

  const startIndex = Math.max(
    0,
    findStartIndex(scrollTop) - bufferSize
  )

  // 从 startIndex 开始累积高度,找到 endIndex
  let accumulated = offsets[startIndex]
  let endIndex = startIndex
  while (endIndex < data.length && accumulated < scrollTop + containerHeight + bufferSize * estimatedItemHeight) {
    const h = heightsRef.current.get(endIndex) ?? estimatedItemHeight
    accumulated += h
    endIndex++
  }

  const visibleData = data.slice(startIndex, endIndex)

  return (
    <div
      style={{ height: containerHeight, overflow: 'auto' }}
      onScroll={(e) => setScrollTop(e.target.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            transform: `translateY(${offsets[startIndex]}px)`,
          }}
        >
          {visibleData.map((item, i) => {
            const realIndex = startIndex + i
            return (
              <div
                key={realIndex}
                ref={(el) => {
                  if (el) {
                    updateHeight(realIndex, el.getBoundingClientRect().height)
                  }
                }}
              >
                {renderItem(item, realIndex)}
              </div>
            )
          })}
        </div>
      </div>
    </div>
  )
}

方案二:IntersectionObserver + 占位预渲染

这种方式不预先测量高度,而是渲染少量「探针」元素来触发测量:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 使用 IntersectionObserver 动态测量
function useItemVisibility(containerRef, dataLength) {
  const observerRef = useRef(null)
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 20 })

  useEffect(() => {
    if (!containerRef.current) return

    observerRef.current = new IntersectionObserver(
      (entries) => {
        // 通过观察特定标记元素来判断滚动位置
        // 然后更新 visibleRange
      },
      { root: containerRef.current }
    )

    return () => observerRef.current?.disconnect()
  }, [dataLength])

  return visibleRange
}

3. 缓存策略

虚拟列表的高效除了依赖 DOM 数量减少,缓存也扮演着至关重要的角色。

位置缓存:在动态高度虚拟列表中,偏移量数组的计算频繁且昂贵。合理的缓存策略可以避免重复计算。

节点缓存:某些场景下(如列表项展开/折叠),可以缓存已计算好的 DOM 节点,避免重复创建。

数据预取:当用户快速滚动时,提前加载远端数据:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Data Prefetch Logic
function useDataPrefetch(data, scrollTop, itemHeight) {
  const currentPage = Math.floor(scrollTop / (itemHeight * 20)) // 每页 20 项

  useEffect(() => {
    // 预取前后各 2 页的数据
    for (let i = -2; i <= 2; i++) {
      const page = currentPage + i
      if (page >= 0 && !loadedPages.current.has(page)) {
        prefetchPage(page)
        loadedPages.current.add(page)
      }
    }
  }, [currentPage])
}

实战案例:即时通讯消息列表

以下是一个完整的 IM 消息列表虚拟滚动实现:

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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
// ChatVirtualList.jsx
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'

const MESSAGE_ESTIMATED_HEIGHT = 72
const BUFFER_COUNT = 10

function ChatMessage({ message, onHeightReady }) {
  const ref = useRef(null)

  useEffect(() => {
    if (ref.current && onHeightReady) {
      const height = ref.current.getBoundingClientRect().height
      onHeightReady(message.id, height)
    }
  }, [message.content])

  return (
    <div ref={ref} className="chat-message">
      <div className="chat-message-avatar">
        <img src={message.avatar} alt={message.sender} />
      </div>
      <div className="chat-message-body">
        <div className="chat-message-sender">{message.sender}</div>
        <div className="chat-message-content">{message.content}</div>
        {message.image && (
          <img
            src={message.image}
            className="chat-message-image"
            alt=""
          />
        )}
        <div className="chat-message-time">
          {new Date(message.timestamp).toLocaleString()}
        </div>
      </div>
    </div>
  )
}

export default function ChatVirtualList({ messages = [] }) {
  const containerRef = useRef(null)
  const [scrollTop, setScrollTop] = useState(0)
  const [heights, setHeights] = useState(() => new Map())

  // 缓存位置偏移
  const offsets = useMemo(() => {
    const result = [0]
    for (let i = 0; i < messages.length; i++) {
      const h = heights.get(messages[i].id) ?? MESSAGE_ESTIMATED_HEIGHT
      result.push(result[i] + h)
    }
    return result
  }, [messages, heights])

  const totalHeight = offsets[offsets.length - 1] || 0
  const containerHeight = containerRef.current?.clientHeight || 600

  // 二分查找起始索引
  const startIndex = useMemo(() => {
    let left = 0
    let right = offsets.length - 1
    const adjustedTop = Math.max(0, scrollTop - BUFFER_COUNT * 50)
    while (left < right) {
      const mid = (left + right) >> 1
      if (offsets[mid] < adjustedTop) left = mid + 1
      else right = mid
    }
    return Math.max(0, left - 1)
  }, [scrollTop, offsets])

  // 计算结束索引
  const visibleCount = useMemo(() => {
    let count = 0
    let acc = offsets[startIndex]
    const limit = scrollTop + containerHeight + BUFFER_COUNT * MESSAGE_ESTIMATED_HEIGHT
    while (startIndex + count < messages.length && acc < limit) {
      const idx = startIndex + count
      const h = heights.get(messages[idx]?.id) ?? MESSAGE_ESTIMATED_HEIGHT
      acc += h
      count++
    }
    return count + BUFFER_COUNT
  }, [startIndex, offsets, scrollTop, containerHeight, messages.length, heights])

  const endIndex = Math.min(messages.length, startIndex + visibleCount)
  const visibleMessages = messages.slice(startIndex, endIndex)

  const handleHeightReady = useCallback((id, height) => {
    setHeights((prev) => {
      if (prev.get(id) === height) return prev
      const next = new Map(prev)
      next.set(id, height)
      return next
    })
  }, [])

  // 每次新消息自动滚到底部
  useEffect(() => {
    if (containerRef.current) {
      containerRef.current.scrollTop = containerRef.current.scrollHeight
    }
  }, [messages.length])

  return (
    <div
      ref={containerRef}
      className="chat-container"
      onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div
          style={{
            position: 'absolute',
            top: 0,
            left: 0,
            right: 0,
            transform: `translateY(${offsets[startIndex]}px)`,
          }}
        >
          {visibleMessages.map((msg) => (
            <ChatMessage
              key={msg.id}
              message={msg}
              onHeightReady={handleHeightReady}
            />
          ))}
        </div>
      </div>
    </div>
  )
}

底层原理(含源码分析)

1. 浏览器渲染管线与虚拟列表

理解虚拟列表的高效性,需要先了解浏览器的渲染管线:

1
JavaScript 执行 → Style 计算 → Layout(回流)→ Paint(重绘)→ Composite(合成)

当 DOM 节点数量巨大时,每个环节的耗时都会显著增加。虚拟列表的核心优化在于:

  1. 减少 DOM 节点:从 N 万个减少到几十个,Layout 和 Paint 的成本大幅降低
  2. 避免回流:使用 transform 进行偏移,触发合成层
  3. 滚动平滑:滚动事件本身不可压缩,但渲染工作变轻了

为什么 transform 比 top 性能更好?

1
2
3
4
5
6
7
8
9
10
11
/* 使用 top:触发回流 */
.item-list {
  position: absolute;
  top: 10000px; /* 改变 top → 触发 Layout */
}

/* 使用 transform:不会触发回流 */
.item-list {
  position: absolute;
  transform: translateY(10000px); /* 改变 transform → 只触发 Composite */
}

transform 的变化由 GPU 合成线程处理,不涉及主线程的 Layout 和 Paint。而 top 的变化会触发重新 Layout,代价高昂。

2. React-Virtualized 和 React-Window 源码分析

React-Virtualized(较早)和 React-Window(新一代)是最广泛使用的虚拟列表库。以下是 React-Window 的核心逻辑简化:

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
// React-Window FixedSizeList 核心逻辑简化
class FixedSizeList extends PureComponent {
  state = { scrollOffset: 0 }

  // 核心方法:获取可见项的范围
  getVisibleRange() {
    const { itemCount, itemSize, height } = this.props
    const { scrollOffset } = this.state
    const overscanCount = this.props.overscanCount || 1

    const visibleStartIndex = Math.floor(scrollOffset / itemSize)
    const visibleStopIndex = Math.ceil((scrollOffset + height) / itemSize) - 1

    // 添加缓冲区
    const startIndex = Math.max(0, visibleStartIndex - overscanCount)
    const stopIndex = Math.min(itemCount - 1, visibleStopIndex + overscanCount)

    return { startIndex, stopIndex }
  }

  // 渲染优化:仅渲染可见项的实例
  render() {
    const { height, itemSize, itemCount, children: Component } = this.props
    const { startIndex, stopIndex } = this.getVisibleRange()

    const items = []
    for (let index = startIndex; index <= stopIndex; index++) {
      items.push(
        <Component
          key={index}
          index={index}
          style={{
            position: 'absolute',
            top: index * itemSize,
            height: itemSize,
            left: 0,
            right: 0,
          }}
        />
      )
    }

    return (
      <div style={{ overflow: 'auto', height }}>
        <div style={{ height: itemCount * itemSize, position: 'relative' }}>
          {items}
        </div>
      </div>
    )
  }
}

关键设计

  • PureComponent 配合 shouldComponentUpdate 精确控制更新范围
  • 外部容器负责 overflow: auto,内部容器负责 position: relativeheight
  • overscanCount 参数用于控制缓冲区大小,平衡「避免白屏」和「渲染性能」

3. 滚动事件的节流与 RAF

高频率的滚动事件如果直接触发 setState 会导致严重卡顿:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 不推荐:滚动事件直接触发状态更新
container.addEventListener('scroll', (e) => {
  setScrollTop(e.target.scrollTop) // 每秒可能触发 100+ 次
})

// ✅ 推荐:使用 requestAnimationFrame 进行节流
let ticking = false
container.addEventListener('scroll', (e) => {
  if (!ticking) {
    requestAnimationFrame(() => {
      setScrollTop(e.target.scrollTop)
      ticking = false
    })
    ticking = true
  }
})

requestAnimationFrame 会在下一次浏览器绘制前执行,确保渲染与显示频率同步,避免多余的渲染工作。

高频面试题解析

Q1: 虚拟列表相比「分页加载」有什么优势和劣势?

考察点:了解不同长列表方案的适用场景。

答案核心

  • 优势
    • 无限滚动体验,用户无中断感
    • 保留滚动位置历史(回到顶部不会重新请求数据)
    • 适合实时更新的列表(IM、实时数据看板)
  • 劣势
    • 所有数据必须在内存中(数据量超过百万需要考虑分页+虚拟列表混合方案)
    • 需要手动处理滚动位置恢复(切换页面再返回时的位置保持)
    • SEO 不友好(搜索爬虫只能看到少量可见项)

Q2: 虚拟列表在快速滚动时出现白屏如何解决?

考察点:对缓冲区策略和加载策略的理解。

答案核心: 白屏的原因是渲染速度跟不上滚动速度。解决方案:

  1. 增大缓冲区overscanCount 从 5 增加到 15-20
  2. 使用 IntersectionObserver + 占位元素:快速滚动时先显示占位,等稳定后再渲染
  3. CSS content-visibility
    1
    2
    3
    4
    
    .list-item {
      content-visibility: auto; /* 浏览器自动跳过不可见项渲染 */
      contain-intrinsic-size: 72px; /* 保留高度占位 */
    }
    
  4. 尾部渲染优化:利用 will-change: transform 让列表容器进入单独的合成层

Q3: 如何处理虚拟列表中的动态高度(图文混排、折叠展开)?

考察点:动态高度的测量和缓存方案。

答案核心: 核心思路是「先估算,后测量,再修正」:

  1. 估算高度:estimatedItemHeight 作为初始值
  2. 渲染后测量:通过 getBoundingClientRect() 获取实际高度
  3. 缓存高度:用 Map 存储每项的实际高度
  4. 更新偏移量:测量的高度变化会触发重新计算偏移数组
  5. 二分查找:使用二分法在偏移数组中快速定位滚动位置

对于频繁折叠/展开的场景,每次展开时需要重新测量被展开的内容,然后更新缓存的偏移量。为了提高性能,建议在展开时使用 requestAnimationFrame 批量更新。

总结与扩展

虚拟列表是解决大数据量列表渲染问题的最佳实践之一。本文从定高虚拟列表的数学原理开始,逐步深入到动态高度、缓存策略、底层渲染管线等核心知识点。

关键要点

  1. 定高虚拟列表的偏移计算是 O(1),动态高度需要 O(log n) 的二分查找
  2. 缓冲区是避免白屏的关键参数,建议设为可见项数的 1-2 倍
  3. 使用 transform 而非 top 进行偏移,利用 GPU 合成层
  4. 滚动事件需要用 requestAnimationFrame 节流

扩展场景

在 Grid/Table 场景中,二维虚拟化(Grid Virtualization)需要同时处理横向和纵向的可见范围。库如 react-virtualizedMultiGrid 实现了行列双轴虚拟化。此外,Canvas 虚拟列表(如 Tony Hawk Pro)将列表内容绘制到 Canvas 上,完全绕过 DOM 操作,可以支撑 10 万+ 的极致性能场景。

未来趋势上,CSS content-visibility 属性和 contain 规范的成熟可能改变虚拟列表的格局——当浏览器能原生跳过不可见内容的渲染时,开发者可能不再需要手写复杂的虚拟列表逻辑。

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