文章

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

从零开始手写一个支持定高和动态高度的虚拟列表,逐行拆解滚动计算、可见区域渲染和高度缓存的核心逻辑。

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

一句话概括

手写虚拟列表的核心在于三件事:计算可见范围、渲染可见项、维持正确的滚动条高度,本文从零构建一个完整的生产级虚拟列表实现。

背景与意义

在实际项目中,许多开发者依赖第三方库(如 react-window、react-virtualized)来实现虚拟列表。这些库固然功能完善,但当遇到定制化需求时——比如列表项高度动态变化、需要嵌入动画、或者实现虚拟表格时——理解底层原理就变得不可或缺。

为什么要手写?

  1. 灵活定制:第三方库覆盖 80% 的场景,但剩下的 20% 需要自己实现
  2. 性能极致:了解底层才能针对业务场景做极致优化
  3. 调试能力:出现 bug 时能快速定位是库的问题还是自身用法问题
  4. 面试必备:手写虚拟列表是大厂前端面试的常见压轴题

本文将从最简单的定高虚拟列表开始,逐步升级到动态高度、缓存优化、双向滚动、动画支持等进阶功能,最终形成一个可在生产中使用的完整实现。

概念与定义

滚动引擎:虚拟列表的心脏,负责根据滚动位置计算可见项的起止索引。核心公式为:

  • 定高:startIndex = floor(scrollTop / itemHeight)
  • 动高:需要维护一个「偏移数组」,使用二分查找定位

可见窗口:由 startIndexendIndex 界定的数据区间,这个区间内的数据会被渲染为 DOM 节点。

缓冲区:在可见窗口上下各多渲染 N 个项,防止快速滚动时出现白屏。缓冲区的大小既要足够大以防白屏,又要足够小以避免过多 DOM 节点。

首次渲染优化:页面前往列表位置时,可能需要直接跳转到某个特定位置(如搜索引擎结果页),此时需要支持「滚动到指定项」。

最小示例——20 行实现核心思想

先写一个最简单但可运行的版本,只有 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
<!DOCTYPE html>
<html>
<body>
<div id="container" style="height:400px;overflow:auto;border:1px solid #333"></div>
<script>
const TOTAL = 100000
const ITEM_H = 30
const VISIBLE = Math.ceil(400 / ITEM_H) + 5 // +5 缓冲区
const data = Array.from({length: TOTAL}, (_, i) => `Item ${i + 1}`)

const container = document.getElementById('container')
const inner = document.createElement('div')
inner.style.position = 'relative'
inner.style.height = TOTAL * ITEM_H + 'px'
container.appendChild(inner)

function render(scrollTop) {
  const start = Math.floor(scrollTop / ITEM_H)
  const end = Math.min(start + VISIBLE, TOTAL)
  inner.innerHTML = data.slice(start, end)
    .map((d, i) => `<div style="position:absolute;top:${(start + i) * ITEM_H}px;height:${ITEM_H}px;line-height:${ITEM_H}px;padding:0 12px">${d}</div>`)
    .join('')
}

container.onscroll = () => render(container.scrollTop)
render(0)
</script>
</body>
</html>

核心知识点拆解

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
// 定高虚拟列表的计算模型
class FixedVirtualList {
  constructor({ containerHeight, itemHeight, totalCount, bufferCount = 5 }) {
    this.containerHeight = containerHeight    // 容器高度
    this.itemHeight = itemHeight              // 每个项的高度(固定值)
    this.totalCount = totalCount              // 数据总量
    this.bufferCount = bufferCount            // 缓冲区项数

    // 派生计算
    this.totalHeight = totalCount * itemHeight // 总滚动高度
    this.maxVisibleCount = Math.ceil(containerHeight / itemHeight) // 可见项数
  }

  // 核心:根据滚动偏移量计算可见范围
  calculateRange(scrollOffset) {
    // 防止负值和溢出
    const offset = Math.max(0, Math.min(scrollOffset, this.totalHeight - this.containerHeight))

    // 计算起始索引(核心公式)
    const startIndex = Math.floor(offset / this.itemHeight)

    // 计算结束索引(包含缓冲区)
    const endIndex = Math.min(
      this.totalCount - 1,
      startIndex + this.maxVisibleCount + this.bufferCount * 2
    )

    // 修正起始索引(包含上方缓冲区)
    const adjustedStart = Math.max(0, startIndex - this.bufferCount)

    return {
      startIndex: adjustedStart,
      endIndex: endIndex,
      // 渲染列表的偏移量(用于 translateY)
      offset: adjustedStart * this.itemHeight,
      // 实际可见项的起始(用于判断预加载触发时机)
      visibleStart: startIndex,
      visibleEnd: Math.min(this.totalCount - 1, startIndex + this.maxVisibleCount),
    }
  }

  // 反向计算:根据项索引推算出滚动到该项需要的位置
  calculateOffsetForIndex(index) {
    return Math.max(0, index * this.itemHeight - (this.containerHeight - this.itemHeight) / 2)
  }
}

2. 动态高度虚拟列表的偏移数组

当列表项高度不固定时,无法使用 startIndex = Math.floor(scrollTop / 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
// 偏移数组的构建和维护
class DynamicVirtualListStore {
  constructor(estimatedItemHeight = 80) {
    this.estimatedItemHeight = estimatedItemHeight
    this.heights = new Map()     // index → actual height
    this.offsets = null           // 缓存的计算结果
    this.totalHeight = 0
  }

  // 更新某项的高度
  updateHeight(index, height) {
    if (this.heights.get(index) === height) return false // 没变化
    this.heights.set(index, height)
    this.offsets = null // 标记缓存失效
    return true
  }

  // 构建或重建偏移数组
  buildOffsets(totalCount) {
    if (this.offsets && this.offsets.length === totalCount + 1) {
      return this.offsets // 缓存命中
    }

    const offsets = [0]
    for (let i = 0; i < totalCount; i++) {
      const height = this.heights.get(i) ?? this.estimatedItemHeight
      offsets.push(offsets[i] + height)
    }

    this.offsets = offsets
    this.totalHeight = offsets[totalCount]
    return offsets
  }

  // 二分查找:给定滚动距离,找到对应的起始索引
  findStartIndex(scrollTop, totalCount) {
    const offsets = this.buildOffsets(totalCount)
    let left = 0
    let right = offsets.length - 1

    while (left < right) {
      const mid = (left + right) >>> 1 // 无符号右移,快速取中
      if (offsets[mid] < scrollTop) {
        left = mid + 1
      } else {
        right = mid
      }
    }

    return Math.max(0, left - 1)
  }

  // 从起始索引开始,累加高度找到结束索引
  findEndIndex(startIndex, maxBottom, totalCount) {
    const offsets = this.buildOffsets(totalCount)
    let current = startIndex
    while (current < totalCount && offsets[current] < maxBottom) {
      current++
    }
    return Math.min(totalCount - 1, current + 5) // +5 缓冲区
  }
}

3. 与 React 结合的完整 Hook 封装

将上述逻辑封装为 React Hook,便于在项目中使用:

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
// useVirtualList.js - 完整的虚拟列表 Hook
import { useState, useRef, useCallback, useMemo, useEffect } from 'react'

/**
 * 虚拟列表 Hook
 * @param {Array} data - 全部数据
 * @param {Object} options
 * @param {number} options.itemHeight - 定高时传入,动态高度时传预估高度
 * @param {number} options.overscan - 缓冲区项数
 * @param {number} options.containerHeight - 容器高度
 * @returns
 */
export function useVirtualList(data, options = {}) {
  const {
    itemHeight = 50,
    overscan = 5,
    containerHeight = 400,
  } = options

  const [scrollTop, setScrollTop] = useState(0)
  const containerRef = useRef(null)
  const heightsRef = useRef(new Map())
  const offsetsRef = useRef(null)
  const totalCount = data.length

  // 构建偏移数组(动态高度用)
  const buildOffsets = useCallback(() => {
    if (offsetsRef.current && offsetsRef.current.length === totalCount + 1) {
      return offsetsRef.current
    }

    const offsets = [0]
    for (let i = 0; i < totalCount; i++) {
      const h = heightsRef.current.get(i) ?? itemHeight
      offsets.push(offsets[i] + h)
    }
    offsetsRef.current = offsets
    return offsets
  }, [totalCount, itemHeight])

  // 二分查找
  const binarySearch = useCallback((offsets, target) => {
    let lo = 0
    let hi = offsets.length - 1
    while (lo < hi) {
      const mid = (lo + hi) >>> 1
      if (offsets[mid] < target) lo = mid + 1
      else hi = mid
    }
    return lo
  }, [])

  // 计算可见范围
  const range = useMemo(() => {
    if (totalCount === 0) {
      return { start: 0, end: 0, offset: 0, totalHeight: 0 }
    }

    const offsets = buildOffsets()
    const totalHeight = offsets[totalCount]

    // 定高模式:直接数学计算
    if (heightsRef.current.size === 0) {
      const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
      const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2
      const end = Math.min(totalCount, start + visibleCount)
      return {
        start,
        end,
        offset: start * itemHeight,
        totalHeight,
      }
    }

    // 动态高度模式:二分查找
    const baseIndex = Math.max(0, binarySearch(offsets, scrollTop) - 1)
    const start = Math.max(0, baseIndex - overscan)
    const bottomLimit = scrollTop + containerHeight
    let end = start
    while (end < totalCount && offsets[end] < bottomLimit + overscan * itemHeight) {
      end++
    }
    end = Math.min(totalCount, end + overscan)

    return {
      start,
      end,
      offset: offsets[start],
      totalHeight,
    }
  }, [scrollTop, totalCount, itemHeight, overscan, containerHeight, buildOffsets, binarySearch])

  // 同步滚动事件
  const onScroll = useCallback((e) => {
    setScrollTop(e.currentTarget.scrollTop)
  }, [])

  // 记录某项的实际高度
  const recordHeight = useCallback((index, height) => {
    heightsRef.current.set(index, height)
    offsetsRef.current = null // 缓存失效
  }, [])

  // 滚动到指定索引
  const scrollToIndex = useCallback((index) => {
    const offsets = buildOffsets()
    const top = offsets[index]
    if (containerRef.current) {
      containerRef.current.scrollTop = top
    }
  }, [buildOffsets])

  // 可见数据
  const visibleData = useMemo(
    () => data.slice(range.start, range.end),
    [data, range.start, range.end]
  )

  return {
    containerRef,
    onScroll,
    range,
    visibleData,
    scrollToIndex,
    recordHeight,
    totalCount,
    isDynamic: heightsRef.current.size > 0,
  }
}

4. 排序和过滤时的特殊处理

当列表数据发生变化(排序、过滤、添加)时,高度缓存数组需要重建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 列表变化时的处理
function useVirtualListWithFilter(allData, filterFn) {
  const filteredData = useMemo(() => allData.filter(filterFn), [allData, filterFn])
  const [version, setVersion] = useState(0)

  // 过滤条件变化时,重置高度缓存
  useEffect(() => {
    setVersion((v) => v + 1)
  }, [filteredData.length])

  // 使用重置后的虚拟列表
  const virtual = useVirtualList(filteredData, {
    itemHeight: 72,
    key: version, // 通过 key 变化触发内部缓存重置
  })

  return virtual
}

实战案例:带分组的树形虚拟列表

以下实现一个更复杂的场景——可展开的树形虚拟列表:

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
// TreeVirtualList.jsx - 树形虚拟列表
import { useState, useMemo, useCallback, useRef, memo } from 'react'

// 扁平化树数据,并记录层级信息
function flattenTree(nodes, parentId = null, depth = 0) {
  const result = []
  for (const node of nodes) {
    result.push({
      ...node,
      parentId,
      depth,
      isGroup: node.children && node.children.length > 0,
    })
    if (node.expanded && node.children) {
      result.push(...flattenTree(node.children, node.id, depth + 1))
    }
  }
  return result
}

const TreeNode = memo(function TreeNode({ node, onToggle, style }) {
  return (
    <div
      style={{
        ...style,
        paddingLeft: 16 + node.depth * 24,
        display: 'flex',
        alignItems: 'center',
        borderBottom: '1px solid #eee',
      }}
    >
      {node.isGroup && (
        <button
          onClick={() => onToggle(node.id)}
          style={{ marginRight: 8, cursor: 'pointer', border: 'none', background: 'none' }}
        >
          {node.expanded ? '' : ''}
        </button>
      )}
      <span>{node.name}</span>
      {!node.isGroup && (
        <span style={{ marginLeft: 12, color: '#999', fontSize: 12 }}>
          {node.value}
        </span>
      )}
    </div>
  )
})

export default function TreeVirtualList({ treeData }) {
  const [expandedIds, setExpandedIds] = useState(new Set(['root']))

  // 1. 为每个节点添加上 expanded 状态
  const enrichedData = useMemo(() => {
    function enrich(node) {
      return {
        ...node,
        expanded: expandedIds.has(node.id),
        children: node.children?.map(enrich),
      }
    }
    return treeData.map(enrich)
  }, [treeData, expandedIds])

  // 2. 扁平化
  const flatList = useMemo(() => flattenTree(enrichedData), [enrichedData])

  // 3. 使用虚拟列表 Hook
  const virtual = useVirtualList(flatList, { itemHeight: 48, overscan: 5 })
  const containerRef = useRef(null)
  const [scrollTop, setScrollTop] = useState(0)

  const handleToggle = useCallback((id) => {
    setExpandedIds((prev) => {
      const next = new Set(prev)
      if (next.has(id)) next.delete(id)
      else next.add(id)
      return next
    })
  }, [])

  // 展开/折叠时需要重置偏移缓存(内容高度变化了!)
  const range = useMemo(() => {
    const itemHeight = 48
    const overscan = 5
    const totalHeight = flatList.length * itemHeight
    const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan)
    const visibleCount = Math.ceil((containerRef.current?.clientHeight || 600) / itemHeight) + overscan * 2
    const end = Math.min(flatList.length, start + visibleCount)
    return { start, end, offset: start * itemHeight, totalHeight }
  }, [scrollTop, flatList.length])

  return (
    <div ref={containerRef} style={{ height: 600, overflow: 'auto' }}
         onScroll={(e) => setScrollTop(e.target.scrollTop)}>
      <div style={{ height: range.totalHeight, position: 'relative' }}>
        <div style={{
          position: 'absolute',
          top: 0, left: 0, right: 0,
          transform: `translateY(${range.offset}px)`
        }}>
          {flatList.slice(range.start, range.end).map((node, i) => (
            <TreeNode
              key={node.id}
              node={node}
              onToggle={handleToggle}
              style={{ height: 48 }}
            />
          ))}
        </div>
      </div>
    </div>
  )
}

底层原理(含源码分析)

1. 浏览器滚动帧的精确控制

虚拟列表需要与浏览器的滚动机制密切配合。一组关键数字:

1
2
3
滚动事件频率:约 60~120 Hz(取决于设备和浏览器)
requestAnimationFrame:与显示器刷新率同步(通常 60Hz)
JavaScript 执行帧预算:16.67ms(60fps 下)

这意味着每次滚动事件处理 + 虚拟列表渲染的总时间必须在 16ms 内完成,否则就会出现卡顿。

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
// 高性能滚动处理模式
class HighPerformanceScroller {
  constructor(renderCallback) {
    this.renderCallback = renderCallback
    this.ticking = false
    this.lastScrollTop = 0
    this.smoothFactor = 0.3 // 平滑系数
  }

  handleScroll(e) {
    const target = e.target
    this.lastScrollTop = target.scrollTop

    if (!this.ticking) {
      // 使用 requestAnimationFrame 确保与帧同步
      window.requestAnimationFrame(() => {
        this.ticking = false

        // 使用 transform 时,可以配合 will-change 创建独立合成层
        // 这样即使主线程繁忙,滚动也不会卡顿
        this.renderCallback(this.lastScrollTop)
      })
      this.ticking = true
    }

    // 额外的平滑处理:滚动降采样
    // 当滚动速度超过阈值时,跳过中间帧的渲染
    if (this.lastScrollTop % 5 !== 0) return
  }
}

2. React 18 批量更新与虚拟列表

React 18 的自动批处理对虚拟列表有显著的正向影响:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// React 17 中 scroll 事件不会自动批处理
// 这意味着每次 setScrollTop 都会触发一次渲染
function ScrollHandler() {
  const [scrollTop, setScrollTop] = useState(0)
  const [velocity, setVelocity] = useState(0)

  const handleScroll = useCallback((e) => {
    const top = e.target.scrollTop
    // React 17:两次 setState 会触发两次渲染
    setScrollTop(top)
    setVelocity(top - lastTop.current)
    lastTop.current = top
  }, [])

  // React 18:两次 setState 合并为一次渲染
}

3. CSS containment 优化策略

现代浏览器提供了 contain 属性,可以进一步优化虚拟列表的渲染:

1
2
3
4
5
6
7
8
9
10
11
12
13
/* 虚拟列表容器优化 */
.virtual-list-container {
  contain: strict; /* 完全隔离:layout、style、paint、size 均不向外传播 */
  /* 等同于:contain: layout style paint size */
  overflow: auto;
  will-change: scroll-position; /* 创建独立合成层 */
}

.virtual-list-item {
  contain: content; /* 内容的 layout 和 paint 不向外传播 */
  content-visibility: auto; /* 浏览器可根据可见性自动跳过渲染 */
  contain-intrinsic-size: 48px; /* 为未渲染内容提供占位大小 */
}

content-visibility: auto 是一个值得关注的「原生虚拟列表」技术。它允许浏览器自动跳过视口外元素的渲染,效果类似于框架级的虚拟列表。但需要注意的是,content-visibility 会跳过元素的 displaylayoutpaint,但不会跳过 JavaScript 的执行,因此与框架配合使用时需要注意:

1
2
3
4
5
6
7
8
9
10
// 使用 content-visibility 配合 React
function ListItem({ item }) {
  return (
    <div style={{ contentVisibility: 'auto', containIntrinsicSize: '48px' }}>
      {/* React 仍然会创建 VNode 和执行 render function */}
      {/* 但浏览器只渲染可见的 DOM 节点 */}
      <ExpensiveContent data={item} />
    </div>
  )
}

4. 滚动位置恢复

在 SPA 应用中,用户从列表页点进详情页再返回时,滚动位置需要恢复:

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

function useScrollRestoration(listKey) {
  const positionsRef = useRef(new Map())

  const savePosition = useCallback((key, position) => {
    positionsRef.current.set(key, position)
    // 也可以持久化到 sessionStorage
    sessionStorage.setItem(`scroll_${key}`, String(position))
  }, [])

  const restorePosition = useCallback((key) => {
    const saved = positionsRef.current.get(key)
    if (saved !== undefined) return saved
    const fromStorage = sessionStorage.getItem(`scroll_${key}`)
    return fromStorage ? Number(fromStorage) : 0
  }, [])

  useEffect(() => {
    return () => {
      // 组件卸载时自动保存
    }
  }, [listKey])

  return { savePosition, restorePosition }
}

高频面试题解析

Q1: 虚拟列表在「快速滚动」时出现白屏的根因是什么?如何优化?

考察点:对浏览器渲染管线与虚拟列表耦合的深度理解。

答案核心

白屏的根本原因是「渲染滞后于滚动」:

  1. 主线程阻塞:滚动事件触发 → JS 执行 → VDOM diff → DOM 操作 → Layout → Paint 整个流程如果超过 16ms,就会出现掉帧
  2. Chunk 加载延迟:如果数据是异步加载的,网络延迟会造成空白

优化方案:

1
2
3
4
5
6
7
8
9
10
11
12
方案一:增大缓冲区(最简单)
  将 overscan 从 5 增加到 10~20

方案二:降采样渲染(更平滑)
  滚动速度 > 1000px/s 时,跳过中间帧渲染
  只在滚动停止后重新渲染

方案三:节点复用 + DOM 回收池
  复用已创建的 DOM 节点,减少创建/销毁开销

方案四:渐进式渲染
  先用骨架屏占位,500ms 内完成实际渲染

Q2: 如何处理虚拟列表中的「错位」问题(常见于动态高度场景)?

考察点:动态高度测量的边界情况处理。

答案核心

错位的根本原因是「预估高度 ≠ 实际高度」,导致累计偏移量计算错误。

1
2
3
4
5
6
7
8
9
10
11
错位类型 1:单项错位
  根源:测量失败(如图片未加载完成时测量)
  解决:使用 ResizeObserver 监听尺寸变化

错位类型 2:累积错位(往后越错越远)
  根源:整个偏移数组的误差累积
  解决:每次渲染时重新计算偏移数组

错位类型 3:展开/折叠后的跳跃
  根源:内容高度变化后偏移量未及时更新
  解决:变化时触发全量重算,或使用 transition 平滑过渡

可以使用 ResizeObserver 自动监听尺寸变化:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function useItemSize(containerRef) {
  const sizes = useRef(new Map())

  useEffect(() => {
    const observer = new ResizeObserver((entries) => {
      for (const entry of entries) {
        const index = Number(entry.target.dataset.index)
        const height = entry.contentBoxSize?.[0]?.blockSize
        if (height && sizes.current.get(index) !== height) {
          sizes.current.set(index, height)
          // 触发重新计算
        }
      }
    })
    // containerRef 中的子元素都需要监听
    // ...
    return () => observer.disconnect()
  }, [])

  return sizes.current
}

Q3: 虚拟列表和 Pagination(分页)在性能上有什么区别?什么时候用哪个?

考察点:不同技术方案的选型判断。

答案核心

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
虚拟列表                     分页
─────────────────────  ─────────────────────
DOM 节点稳定(几十个)       DOM 节点随页变化(可多可少)
滚动体验连续                 需要手动翻页
所有数据在内存中             数据按页加载
适合阅读型场景               适合管理型场景
不需要「加载更多」按钮       有明确的翻页操作

选型建议:
  - 社交信息流 → 虚拟列表
  - IM 消息列表 → 虚拟列表
  - 后端管理系统表格 → 虚拟列表(当行数 > 1000 时)
  - 用户搜索列表 → 分页(搜索结果通常不会很多)
  - 内容管理系统 → 分页(需要明确的分页操作)
  - 移动端无限滚动 → 虚拟列表 + 分页加载混合

总结与扩展

本文从零开始构建了一个完整的虚拟列表系统。从定高列表的线性计算模型,到动态高度的偏移数组和二分查找,再到树形列表、滚动恢复等进阶场景,构建了一个完整的知识体系。

核心难点

  1. 偏移量的精确计算:动态高度下的二分查找是关键
  2. 缓存的维护和失效:高度变化时需要重建偏移数组
  3. 滚动事件的节流:要与帧率同步,避免不必要的渲染

扩展思考

在 Electron 或 Native 应用中,虚拟列表的实现会更加复杂:需要考虑双缓存(Double Buffer)渲染策略、GPU 纹理缓存、滚动惯性模拟等高级技术。

Web Platform 层面,content-visibilityIntersectionObserver 的融合正在改变虚拟列表的生态。理论上,浏览器未来完全可能原生支持「列表中只渲染可见项」的能力,届时开发者可能不再需要手写虚拟列表。但在那之前,手写虚拟列表依然是每个前端工程师的必备技能。

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