虚拟列表完整实现深度解析
一句话概括
虚拟列表(Virtual List)通过只渲染视口附近的可视项,将万级数据条的 DOM 节点数从数万个压缩到几十个,是解决大数据列表渲染性能问题的核心方案,其技术难点在于处理不定高 item、缓冲区策略、以及滚动位置的动态高度计算。
1. 背景与意义
1.1 大数据列表渲染的困境
考虑一个典型的聊天软件消息列表:一个活跃用户可能有 10,000 条消息。如果全部渲染:
1
2
3
4
5
6
<!-- 渲染 10,000 条消息的 DOM 开销 -->
<div id="chat-list">
<div class="message">...内容1...</div> <!-- ~200 bytes -->
<div class="message">...内容2...</div> <!-- ~200 bytes -->
<!-- ... 重复 9998 次 ... -->
</div>
10,000 条记录的总量:
- DOM 节点数:约 200,000 个节点(每个 li 内部还有子节点)
- 内存占用:约 10-30MB(纯 DOM,不含 JS 数据)
- Layout 计算时间:约 200-500ms(每次重排)
- 初始渲染时间:约 500-2000ms
而用户一次只能看到大约 10-20 条消息。这意味着 99.8% 的 DOM 节点对用户不可见,但它们却在消耗着宝贵的内存和 CPU。
1.2 虚拟列表 vs 传统分页 vs 无限滚动
| 方案 | DOM 节点数 | 用户体验 | 实现复杂度 |
|---|---|---|---|
| 传统分页 | ~20 条/页 | ❌ 需要翻页,打断浏览 | ⭐ |
| 无限滚动 | 无限增长(全量加载) | ✅ 流畅浏览,但滚动越来越卡 | ⭐⭐ |
| 虚拟列表 | 始终 ~30-50 条 | ✅ 无限滚动,永不卡顿 | ⭐⭐⭐⭐⭐ |
1.3 谁在使用虚拟列表
几乎所有处理大量数据的前端应用都在使用虚拟列表:
- VS Code:文件列表、搜索结果
- Slack:消息列表、频道列表
- Discord:会员列表、消息历史
- Grafana:日志查看器
- Ant Design / Element UI:Table 组件、Select 组件
2. 概念与定义
2.1 虚拟列表的核心公式
1
2
3
4
5
实际渲染的 item 数量 = 可见区域 item 数 + 缓冲区 item 数 × 2
其中:
可见区域 item 数 ≈ 容器高度 / item 平均高度
缓冲区 = overscan × 2(前后各多渲染几行,防止白屏)
2.2 关键概念
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────────────────────────┐
│ Container (容器) │ ← overflow: auto
│ ┌───────────────────────────┐ │
│ │ Phantom (占位元素) │ │ ← 高度 = totalHeight,撑开滚动条
│ │ │ │
│ │ ┌─────────────────┐ │ │ ← 空白区域(不渲染)
│ │ │ Overscan (前) │ │ │ ← 缓冲区
│ │ ├─────────────────┤ │ │
│ │ │ Visible Items │ │ │ ← 用户可见部分
│ │ ├─────────────────┤ │ │
│ │ │ Overscan (后) │ │ │ ← 缓冲区
│ │ └─────────────────┘ │ │
│ │ │ │
│ └───────────────────────────┘ │
│ │
└──────────────────────────────────┘
- Container:固定高度的可滚动容器,
overflow-y: auto - Phantom Element:占位元素,高度等于所有项的总高度,撑开滚动条
- Visible Items:用户当前可视区域内的项
- Overscan(缓冲区):可见区域前后额外渲染的项,防止快速滚动时出现白屏
- Scroll Offset:当前滚动位置
2.3 定高 vs 不定高
| 特性 | 定高虚拟列表 | 不定高虚拟列表 |
|---|---|---|
| 实现难度 | ⭐⭐ | ⭐⭐⭐⭐⭐ |
| 计算复杂度 | O(1) | O(log n) |
| 滚动位置准确性 | 精确 | 估算 |
| 常见场景 | 简单的消息列表、日志 | 富文本列表、评论区 |
| 高度获取方式 | 固定值 | 预估 + 实际测量 + 缓存 |
3. 最小示例
3.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
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
132
133
134
135
136
<!DOCTYPE html>
<html>
<head>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { font-family: system-ui; }
#container {
width: 600px;
height: 500px;
overflow-y: auto;
border: 1px solid #ddd;
margin: 20px auto;
position: relative;
}
#phantom {
position: absolute;
top: 0;
left: 0;
right: 0;
z-index: -1;
pointer-events: none;
}
#visible-area {
position: relative;
}
.list-item {
display: flex;
align-items: center;
padding: 12px 16px;
border-bottom: 1px solid #eee;
height: 50px;
}
.list-item:hover {
background: #f5f5f5;
}
.item-index {
width: 60px;
color: #999;
font-size: 12px;
}
.item-content {
flex: 1;
}
</style>
</head>
<body>
<h2 style="text-align:center;margin-top:20px;">📋 定高虚拟列表 (100,000 条)</h2>
<div id="container">
<div id="phantom"></div>
<div id="visible-area"></div>
</div>
<div style="text-align:center;margin-top:10px;">
<span id="stats"></span>
</div>
<script>
// ────── 配置 ──────
const ITEM_HEIGHT = 50; // 每项固定高度
const CONTAINER_HEIGHT = 500; // 容器高度
const OVERSCAN = 5; // 缓冲区数量
const TOTAL_ITEMS = 100000; // 总数据量
// ────── DOM 引用 ──────
const container = document.getElementById('container');
const phantom = document.getElementById('phantom');
const visibleArea = document.getElementById('visible-area');
const stats = document.getElementById('stats');
// ────── 生成数据 ──────
function generateData(count) {
return Array.from({ length: count }, (_, i) => ({
id: i,
title: `Item #${i}`,
description: `这是第 ${i} 条数据的描述内容,用于展示虚拟列表效果。`
}));
}
const data = generateData(TOTAL_ITEMS);
// ────── 核心渲染函数 ──────
function render() {
const scrollTop = container.scrollTop;
// 1. 计算可见范围的起始和结束索引
const visibleStartIndex = Math.floor(scrollTop / ITEM_HEIGHT);
const visibleEndIndex = Math.min(
visibleStartIndex + Math.ceil(CONTAINER_HEIGHT / ITEM_HEIGHT),
TOTAL_ITEMS
);
// 2. 加上缓冲区
const startIndex = Math.max(0, visibleStartIndex - OVERSCAN);
const endIndex = Math.min(TOTAL_ITEMS, visibleEndIndex + OVERSCAN);
// 3. 更新占位元素高度(撑开滚动条)
phantom.style.height = `${TOTAL_ITEMS * ITEM_HEIGHT}px`;
// 4. 计算实际渲染部分的偏移
const offsetY = startIndex * ITEM_HEIGHT;
// 5. 批量生成 DOM
let html = '';
for (let i = startIndex; i < endIndex; i++) {
html += `
<div class="list-item" style="position:absolute;top:${offsetY + (i - startIndex) * ITEM_HEIGHT}px;left:0;right:0;">
<span class="item-index">#${i}</span>
<span class="item-content">${data[i].description}</span>
</div>
`;
}
visibleArea.innerHTML = html;
// 6. 统计信息
stats.textContent = `渲染项: ${endIndex - startIndex} / ${TOTAL_ITEMS} | 偏移: ${startIndex}`;
}
// ────── 绑定滚动事件 ──────
container.addEventListener('scroll', () => {
requestAnimationFrame(render);
}, { passive: true });
// ────── 初始渲染 ──────
render();
</script>
</body>
</html>
3.2 核心渲染逻辑
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
// 虚拟列表渲染的核心函数(无框架版本)
class VirtualList {
constructor(container, options) {
this.container = container;
this.itemHeight = options.itemHeight;
this.overscan = options.overscan || 5;
this.totalItems = options.totalItems;
this.renderItem = options.renderItem; // (index) => HTML string
this.phantom = document.createElement('div');
this.visibleArea = document.createElement('div');
container.style.position = 'relative';
container.appendChild(this.phantom);
container.appendChild(this.visibleArea);
this.container.addEventListener('scroll', () => {
requestAnimationFrame(() => this.update());
}, { passive: true });
this.update();
}
update() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
// 核心计算:可见范围的索引
// startIndex = Math.floor(scrollTop / itemHeight)
// endIndex = startIndex + Math.ceil(containerHeight / itemHeight)
//
// 加缓冲区:
// startIndex = Math.max(0, startIndex - overscan)
// endIndex = Math.min(totalItems, endIndex + overscan)
const startIndex = Math.max(0,
Math.floor(scrollTop / this.itemHeight) - this.overscan);
const endIndex = Math.min(this.totalItems,
Math.ceil((scrollTop + containerHeight) / this.itemHeight) + this.overscan);
// 更新占位元素
this.phantom.style.height = `${this.totalItems * this.itemHeight}px`;
// 计算偏移
const offsetY = startIndex * this.itemHeight;
this.visibleArea.style.transform = `translateY(${offsetY}px)`;
// 渲染可视项
let html = '';
for (let i = startIndex; i < endIndex; i++) {
html += this.renderItem(i);
}
this.visibleArea.innerHTML = html;
// 返回统计信息
return {
rendered: endIndex - startIndex,
total: this.totalItems,
startIndex,
endIndex
};
}
}
4. 核心知识点拆解
4.1 缓冲区策略的深度分析
缓冲区(Overscan)是虚拟列表流畅滚动的关键。
为什么需要缓冲区?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
快速滚动时的时序问题:
时间线:
帧1: 用户开始快速向下滚动
视口在索引 0-20
渲染:索引 0-30(含缓冲区)
帧2: 用户继续滚动(可能一次滚动几十行)
视口在索引 40-60
但是!帧2 的渲染还没有完成
如果此时视口在 40-60
而你的渲染范围是 40-60(无缓冲区)
那么在帧1到帧2之间
视口看到的区域内还没有 DOM 节点 → 白屏!
缓冲区确保即使滚动速度很快,下一帧渲染到来之前,新旧视口之间始终有 DOM 覆盖。
缓冲区大小的选择
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
缓冲区大小与性能的平衡:
overscan = 0(无缓冲区):
✅ 最少的 DOM 节点
❌ 快速滚动时频繁白屏
❌ 滚动抖动明显
overscan = 5(小缓冲区):
✅ DOM 节点适中(约 15-20)
✅ 正常滚动不白屏
⚠️ 极快滚动可能白屏
overscan = 20(大缓冲区):
✅ 几乎永不白屏
⚠️ DOM 节点较多(约 50+)
❌ 初次渲染较慢
overscan = 50(超大缓冲区):
❌ 失去了虚拟列表的意义
❌ 接近全量渲染
经验公式:
1
2
3
overscan = Math.ceil(containerHeight / itemHeight) * 0.5
// 通常是可见区域行数的一半
// 例如:可见 10 行 → overscan = 5
4.2 不定高虚拟列表的复杂挑战
不定高是虚拟列表最难的关卡。
挑战一:无法预知高度
1
2
3
4
5
6
7
定高列表:
totalHeight = itemHeight × totalItems ← 精确
startIndex = scrollTop / itemHeight ← 精确
不定高列表:
totalHeight = ??? ← 未知(需要预估 + 跟踪)
startIndex = ??? ← 未知(需要二分查找累积高度)
挑战二:高度变化引发连锁反应
1
2
3
4
5
6
7
8
9
初始状态:
Item 0 (h=50) → Item 1 (h=80) → Item 2 (h=60) → ...
当 Item 1 加载完成,实际高度变为 200px:
Item 0 (h=50) → Item 1 (h=200) → Item 2 (h=60) → ...
⊙ Item 2 的位置变化了!
⊙ Item 2 之后所有 item 的位置都变化了!
⊙ 用户当前在滚动位置 x,滚动会跳动!
不定高列表的完整实现
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
// virtual-list-variable-height.ts
// 不定高虚拟列表核心实现
interface ItemMeasurement {
height: number; // 实际测量高度
top: number; // 距离顶部的累积高度
estimated: boolean; // 是否为估算值
}
class VariableHeightVirtualList {
private measurements: Map<number, ItemMeasurement> = new Map();
private estimatedHeight: number = 0;
private defaultItemHeight: number = 50; // 默认预估高度
private totalMeasuredHeight: number = 0;
// 行数
private totalItems: number;
constructor(totalItems: number) {
this.totalItems = totalItems;
this.initializeEstimates();
}
// 初始化所有项的预估高度
private initializeEstimates() {
this.measurements.clear();
let top = 0;
for (let i = 0; i < this.totalItems; i++) {
this.measurements.set(i, {
height: this.defaultItemHeight,
top,
estimated: true
});
top += this.defaultItemHeight;
}
this.totalMeasuredHeight = top;
}
// 更新指定项的实际高度
setActualHeight(index: number, actualHeight: number) {
const current = this.measurements.get(index);
if (!current || current.height === actualHeight) return;
const delta = actualHeight - current.height;
// 更新后续所有项的累积高度
for (let i = index; i < this.totalItems; i++) {
const m = this.measurements.get(i)!;
if (i === index) {
m.height = actualHeight;
m.estimated = false;
}
// 后续项的位置前移/后移 delta
m.top = m.top + (i > index ? delta : 0);
}
this.totalMeasuredHeight += delta;
}
// 二分查找:给定 scrollTop,找到对应的起始索引
findStartIndex(scrollTop: number): number {
// 使用二分查找
let low = 0;
let high = this.totalItems - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const item = this.measurements.get(mid)!;
if (item.top <= scrollTop &&
(mid === this.totalItems - 1 ||
this.measurements.get(mid + 1)!.top > scrollTop)) {
return mid;
}
if (item.top > scrollTop) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return 0;
}
// 获取渲染范围
getVisibleRange(scrollTop: number, containerHeight: number, overscan: number) {
const startIndex = Math.max(0, this.findStartIndex(scrollTop) - overscan);
// 从 startIndex 开始累加高度,直到超出容器高度 + overscan
let visibleCount = 0;
let accumulated = 0;
const targetHeight = containerHeight + overscan * this.defaultItemHeight;
for (let i = startIndex; i < this.totalItems; i++) {
accumulated += this.measurements.get(i)!.height;
visibleCount++;
if (accumulated > targetHeight) break;
}
return {
startIndex,
endIndex: Math.min(this.totalItems, startIndex + visibleCount),
offsetY: this.measurements.get(startIndex)?.top || 0
};
}
// 获取总高度
get totalHeight(): number {
return this.totalMeasuredHeight;
}
}
4.3 不定高列表的位置修正(滚动锚定)
当 item 高度变化时,用户看到的滚动位置会跳动。解决这个问题的技术叫滚动锚定(Scroll Anchoring)。
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
// 滚动锚定实现
class ScrollAnchoringList {
constructor(container) {
this.container = container;
this.anchorItem = null;
this.anchorOffset = 0;
this.lastScrollTop = 0;
}
// 用户滚动时记录锚点
onScroll() {
const scrollTop = this.container.scrollTop;
const viewportTop = scrollTop;
const viewportBottom = scrollTop + this.container.clientHeight;
// 找到视口中最稳定的锚定元素
// 策略:选择第一个完全可见的 item
const visibleItems = this.getVisibleItems();
for (const item of visibleItems) {
if (item.top >= viewportTop && item.bottom <= viewportBottom) {
this.anchorItem = item.index;
this.anchorOffset = viewportTop - item.top;
break;
}
}
this.lastScrollTop = scrollTop;
}
// 高度变化后调整滚动位置
onHeightChanged(changedIndex) {
if (this.anchorItem === null) return;
// 如果高度变化的项在锚点之前,需要重新计算滚动位置
if (changedIndex <= this.anchorItem) {
const anchorTop = this.getItemTop(this.anchorItem);
const newScrollTop = anchorTop + this.anchorOffset;
// 微调滚动位置,保持用户看到的内容不变
this.container.scrollTop = newScrollTop;
}
}
}
4.4 滚动性能优化技巧
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
// 1. 使用 transform 替代 top 来控制偏移
// ❌ 使用 top(触发重排)
visibleArea.style.top = `${offsetY}px`;
// ✅ 使用 transform(仅触发合成,GPU 加速)
visibleArea.style.transform = `translateY(${offsetY}px)`;
// 2. 使用 will-change 提示浏览器
visibleArea.style.willChange = 'transform';
// 3. 滚动事件节流
// ❌ 直接绑定 scroll,频率太高
container.addEventListener('scroll', updateItems);
// ✅ 使用 RAF 节流
let rafId = null;
container.addEventListener('scroll', () => {
if (rafId) return;
rafId = requestAnimationFrame(() => {
updateItems();
rafId = null;
});
}, { passive: true });
// 4. 使用 DocumentFragment 批量更新 DOM
// ❌ 逐个插入 DOM
items.forEach(item => visibleArea.appendChild(item));
// ✅ 使用 DocumentFragment 一次性插入
const fragment = document.createDocumentFragment();
items.forEach(item => fragment.appendChild(item));
visibleArea.appendChild(fragment);
// 5. 缓存 DOM 引用,避免重复查询
// ❌ 每次都 querySelector
function updateItem(index) {
const el = document.querySelector(`[data-index="${index}"]`);
}
// ✅ 使用 Map 缓存 DOM 引用
const itemCache = new Map();
function updateItem(index) {
const el = itemCache.get(index);
}
5. 实战案例
案例一:React 实现通用虚拟列表组件
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
// VirtualList.tsx
import React, { useRef, useCallback, useState, useEffect, useMemo } from 'react';
interface VirtualListProps<T> {
/** 数据源 */
items: T[];
/** 容器高度 */
containerHeight: number;
/** 单项高度(定高使用)或预估高度(不定高使用) */
itemHeight: number;
/** 缓冲区大小 */
overscan?: number;
/** 是否为不定高列表 */
variableHeight?: boolean;
/** 渲染每一项的回调 */
renderItem: (item: T, index: number) => React.ReactNode;
/** 不定高时获取实际高度的方法 */
onMeasureHeight?: (index: number, height: number) => void;
/** 滚动事件 */
onScroll?: (scrollTop: number) => void;
}
interface ScrollState {
scrollTop: number;
visibleStartIndex: number;
visibleEndIndex: number;
offsetY: number;
renderedItems: Array<{ index: number; data: any; style: React.CSSProperties }>;
}
function VirtualList<T>({
items,
containerHeight,
itemHeight,
overscan = 5,
variableHeight = false,
renderItem,
onMeasureHeight,
onScroll,
}: VirtualListProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const [scrollTop, setScrollTop] = useState(0);
// 不定高:累积高度数组
const [itemMeasurements, setItemMeasurements] = useState<number[]>(() =>
items.map(() => itemHeight)
);
// 总高度
const totalHeight = useMemo(() => {
if (variableHeight) {
return itemMeasurements.reduce((sum, h) => sum + h, 0);
}
return items.length * itemHeight;
}, [items.length, itemHeight, itemMeasurements, variableHeight]);
// 给定 scrollTop,找到起始索引(定高直接算,不定高二分查找)
const findStartIndex = useCallback((scrollTop: number): number => {
if (!variableHeight) {
return Math.floor(scrollTop / itemHeight);
}
// 不定高使用二分查找
let low = 0;
let high = items.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midTop = getCumulativeHeight(mid);
const midBottom = midTop + itemMeasurements[mid];
if (scrollTop >= midTop && scrollTop < midBottom) {
return mid;
}
if (midTop > scrollTop) {
high = mid - 1;
} else {
low = mid + 1;
}
}
return 0;
}, [items.length, itemHeight, itemMeasurements, variableHeight]);
// 获取第 n 项的累积高度
const getCumulativeHeight = useCallback((index: number): number => {
if (index <= 0) return 0;
return itemMeasurements.slice(0, index).reduce((sum, h) => sum + h, 0);
}, [itemMeasurements]);
// 计算可见范围
const visibleRange = useMemo(() => {
const startIdx = Math.max(0, findStartIndex(scrollTop) - overscan);
const visibleCount = Math.ceil(containerHeight / itemHeight) + overscan * 2;
const endIdx = Math.min(items.length, startIdx + visibleCount);
const offsetY = getCumulativeHeight(startIdx);
return { startIndex: startIdx, endIndex: endIdx, offsetY };
}, [scrollTop, items.length, containerHeight, itemHeight, overscan, findStartIndex, getCumulativeHeight]);
// 处理滚动
const handleScroll = useCallback((e: React.UIEvent<HTMLDivElement>) => {
const target = e.target as HTMLDivElement;
setScrollTop(target.scrollTop);
onScroll?.(target.scrollTop);
}, [onScroll]);
// 不定高:item 渲染后测量实际高度
const itemRefCallback = useCallback((index: number, node: HTMLElement | null) => {
if (!node || !variableHeight || !onMeasureHeight) return;
const actualHeight = node.getBoundingClientRect().height;
if (Math.abs(actualHeight - itemMeasurements[index]) > 1) {
onMeasureHeight(index, actualHeight);
}
}, [variableHeight, itemMeasurements, onMeasureHeight]);
return (
<div
ref={containerRef}
style={{
height: containerHeight,
overflowY: 'auto',
position: 'relative',
willChange: 'transform',
}}
onScroll={handleScroll}
>
{/* 占位元素:撑开滚动条 */}
<div style={{ height: totalHeight, pointerEvents: 'none' }} />
{/* 可见区域 */}
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
transform: `translateY(${visibleRange.offsetY}px)`,
willChange: 'transform',
}}
>
{items.slice(visibleRange.startIndex, visibleRange.endIndex)
.map((item, virtualIndex) => {
const realIndex = visibleRange.startIndex + virtualIndex;
return renderItem(item, realIndex);
})
}
</div>
</div>
);
}
export default VirtualList;
// 使用示例
function ChatMessageList() {
const messages = useMemo(() =>
Array.from({ length: 10000 }, (_, i) => ({
id: i,
user: `用户${i % 100}`,
content: `第 ${i} 条消息: ${'内容 '.repeat((i % 10) + 1)}`,
timestamp: new Date(Date.now() - i * 60000).toLocaleString()
})), []
);
const [measurements, setMeasurements] = useState<number[]>([]);
const handleMeasureHeight = useCallback((index: number, height: number) => {
setMeasurements(prev => {
const next = [...prev];
next[index] = height;
return next;
});
}, []);
return (
<VirtualList
items={messages}
containerHeight={600}
itemHeight={60}
overscan={10}
variableHeight={true}
onMeasureHeight={handleMeasureHeight}
renderItem={(msg) => (
<div key={msg.id} style={{
padding: '12px 16px',
borderBottom: '1px solid #eee',
}}>
<div style={{ fontWeight: 'bold', fontSize: 14, marginBottom: 4 }}>
{msg.user}
<span style={{ fontWeight: 'normal', color: '#999', marginLeft: 8, fontSize: 12 }}>
{msg.timestamp}
</span>
</div>
<div style={{ fontSize: 14, lineHeight: 1.5 }}>
{msg.content}
</div>
</div>
)}
/>
);
}
案例二:包含动态高度缓存和位置锚定的完整虚拟列表
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
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
// advanced-virtual-list.ts
// 工业级虚拟列表实现:不定高 + 高度缓存 + 滚动锚定 + 视口重计算
interface ListItem {
key: string | number;
[key: string]: any;
}
interface HeightCacheEntry {
height: number;
estimated: boolean;
}
class AdvancedVirtualList {
private container: HTMLElement;
private phantom: HTMLElement;
private visibleArea: HTMLElement;
private items: ListItem[] = [];
private heightCache: HeightCacheEntry[] = [];
private cumulativeHeights: number[] = [];
private defaultHeight: number;
private overscan: number;
private lastScrollTop: number = 0;
// 滚动锚定状态
private anchorIndex: number | null = null;
private anchorOffset: number = 0;
private isUpdating: boolean = false;
constructor(
container: HTMLElement,
options: {
defaultHeight?: number;
overscan?: number;
} = {}
) {
this.container = container;
this.defaultHeight = options.defaultHeight || 50;
this.overscan = options.overscan || 5;
// 创建 DOM 结构
container.style.position = 'relative';
container.style.overflowY = 'auto';
container.style.willChange = 'transform';
this.phantom = document.createElement('div');
this.phantom.style.pointerEvents = 'none';
this.visibleArea = document.createElement('div');
this.visibleArea.style.position = 'absolute';
this.visibleArea.style.top = '0';
this.visibleArea.style.left = '0';
this.visibleArea.style.right = '0';
this.visibleArea.style.willChange = 'transform';
container.appendChild(this.phantom);
container.appendChild(this.visibleArea);
// 绑定事件
this.container.addEventListener('scroll', this.handleScroll, { passive: true });
}
// 设置数据源
setItems(items: ListItem[]) {
this.items = items;
this.resetHeightCache();
this.render();
}
// 重置高度缓存
private resetHeightCache() {
this.heightCache = this.items.map(() => ({
height: this.defaultHeight,
estimated: true
}));
this.recaculateCumulativeHeights();
}
// 重新计算累积高度
private recaculateCumulativeHeights() {
this.cumulativeHeights = [];
let sum = 0;
for (const entry of this.heightCache) {
this.cumulativeHeights.push(sum);
sum += entry.height;
}
}
// 获取总高度
get totalHeight(): number {
const last = this.cumulativeHeights[this.cumulativeHeights.length - 1];
return last + (this.heightCache[this.heightCache.length - 1]?.height || 0);
}
// 更新某个 item 的实际高度
updateHeight(index: number, actualHeight: number) {
const cached = this.heightCache[index];
if (!cached || cached.height === actualHeight) return;
// 记录锚点
this.captureAnchor();
const oldHeight = cached.height;
cached.height = actualHeight;
cached.estimated = false;
// 需要重新计算后续所有累积高度
for (let i = index + 1; i < this.cumulativeHeights.length; i++) {
this.cumulativeHeights[i] += actualHeight - oldHeight;
}
// 恢复锚点
this.restoreAnchor();
this.render();
}
// 捕获当前滚动锚点
private captureAnchor() {
if (this.isUpdating) return;
const scrollTop = this.container.scrollTop;
const viewportBottom = scrollTop + this.container.clientHeight;
// 寻找视口中完全可见且最稳定的元素
for (let i = 0; i < this.items.length; i++) {
const itemTop = this.cumulativeHeights[i];
const itemBottom = itemTop + this.heightCache[i].height;
if (itemTop >= scrollTop && itemBottom <= viewportBottom) {
this.anchorIndex = i;
this.anchorOffset = itemTop - scrollTop;
break;
}
}
}
// 恢复锚点位置
private restoreAnchor() {
if (this.anchorIndex === null) return;
const newItemTop = this.cumulativeHeights[this.anchorIndex];
const newScrollTop = Math.max(0, newItemTop - this.anchorOffset);
this.isUpdating = true;
this.container.scrollTop = newScrollTop;
this.lastScrollTop = newScrollTop;
this.isUpdating = false;
this.anchorIndex = null;
}
// 滚动处理
private handleScroll = () => {
if (this.isUpdating) return;
const newScrollTop = this.container.scrollTop;
this.lastScrollTop = newScrollTop;
// 使用 RAF 节流渲染
if (!this._rafId) {
this._rafId = requestAnimationFrame(() => {
this.render();
this._rafId = null;
});
}
};
private _rafId: number | null = null;
// 核心渲染
private render() {
const scrollTop = this.container.scrollTop;
const containerHeight = this.container.clientHeight;
// 更新占位元素
this.phantom.style.height = `${this.totalHeight}px`;
// 二分查找起始索引
let startIndex = 0;
{
let low = 0;
let high = this.items.length - 1;
while (low <= high) {
const mid = Math.floor((low + high) / 2);
const midTop = this.cumulativeHeights[mid];
const midBottom = midTop + this.heightCache[mid].height;
if (scrollTop >= midTop && scrollTop <= midBottom) {
startIndex = mid;
break;
}
if (midTop > scrollTop) {
high = mid - 1;
} else {
low = mid + 1;
}
}
}
// 计算渲染范围(含缓冲区)
startIndex = Math.max(0, startIndex - this.overscan);
let endIndex = startIndex;
let accumulatedHeight = 0;
const targetHeight = containerHeight + this.overscan * this.defaultHeight * 2;
while (endIndex < this.items.length && accumulatedHeight < targetHeight) {
accumulatedHeight += this.heightCache[endIndex].height;
endIndex++;
}
// 计算偏移
const offsetY = this.cumulativeHeights[startIndex];
// 渲染
let html = '';
for (let i = startIndex; i < endIndex; i++) {
const itemHeight = this.heightCache[i].height;
html += this.renderItem(this.items[i], i);
}
this.visibleArea.style.transform = `translateY(${offsetY}px)`;
this.visibleArea.innerHTML = html;
}
// 重写此方法以自定义渲染
renderItem(item: ListItem, index: number): string {
return `<div data-index="${index}" class="virtual-item"
style="height:${this.heightCache[index].height}px;">${item.key}</div>`;
}
// 销毁
destroy() {
if (this._rafId) {
cancelAnimationFrame(this._rafId);
}
this.container.removeEventListener('scroll', this.handleScroll);
}
}
6. 底层原理
6.1 浏览器布局引擎如何处理虚拟列表
虚拟列表依赖于浏览器的滚动 + 绝对定位行为:
1
2
3
4
5
6
7
浏览器布局引擎处理虚拟列表时:
1. 容器(overflow: auto)创建了一个 Scrollable Overflow
2. 占位元素(高度=totalHeight)确定了 scrollHeight
3. 浏览器为容器提供 scrollTop/scrollHeight/clientHeight
4. 开发者监听 scroll 事件,根据 scrollTop 计算需要渲染哪些项
5. 将计算出的项用 absolute/fixed 定位在相应的偏移位置
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/* 浏览器渲染虚拟列表的关键 CSS */
.container {
overflow-y: auto; /* 创建 BFC 和滚动容器 */
position: relative; /* 为 absolute 定位的子元素提供参照 */
height: 500px; /* 固定高度,超出可滚动 */
}
.phantom {
pointer-events: none; /* 不拦截事件,让后面的交互元素可以正常使用 */
}
.visible-area {
position: absolute; /* 脱离文档流,不参与影响其他元素布局 */
top: 0;
left: 0;
right: 0;
}
为什么不直接使用 padding-top 代替偏移?
1
2
3
4
5
6
7
// ❌ 使用 padding-top
visibleArea.style.paddingTop = `${offsetY}px`;
// 更新 padding 会触发重排(Recalculate Style + Layout)
// ✅ 使用 transform
visibleArea.style.transform = `translateY(${offsetY}px)`;
// transform 只触发合成(Composite),不触发重排
6.2 浏览器滚动机制
浏览器滚动分为两种:
1
2
3
4
5
6
7
8
9
10
1. 主线程滚动(Main Thread Scrolling):
scroll event 在主线程处理
→ 可以阻止默认行为(preventDefault)
→ 需要 JS 参与(如虚拟列表)
2. 合成器线程滚动(Compositor Thread Scrolling):
scroll 在合成器线程处理
→ 无法阻止默认行为
→ 不依赖主线程(流畅)
→ 只对 overflow:scroll 的元素有效
虚拟列表必须使用主线程滚动,因为每次滚动后需要重新计算渲染范围并更新 DOM。为此,我们在绑定 scroll 事件时应该使用 { passive: true },告诉浏览器我们不调用 preventDefault,这样浏览器可以优化滚动性能:
1
2
3
4
// ✅ passive: true 允许浏览器知道不会阻止默认行为
// 浏览器可以提前做一些优化
container.addEventListener('scroll', handler, { passive: true });
// 如果 handler 中调用了 preventDefault,会被忽略(控制台会报 warning)
6.3 为什么 transform 比 top 性能更好?
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
使用 top 时(触发布局):
┌──────────────┐
│ 样式重新计算 │ ← 需要计算 .visible-area 的位置
├──────────────┤
│ 布局 │ ← 需要重新计算 .visible-area 及其子元素的几何属性
├──────────────┤
│ 绘制 │
├──────────────┤
│ 合成层 │
└──────────────┘
总共:6-8ms(60fps下占 40-50% 预算)
使用 transform 时(仅触发合成):
┌──────────────┐
│ 合成层 │ ← 只需要 GPU 重新合成
└──────────────┘
总共:0.5-1ms(几乎不影响帧预算)
这就是为什么所有工业级虚拟列表都使用 transform: translateY() 来控制可见区域的偏移。
7. 高频面试题解析
面试题 1:虚拟列表的”缓冲区”(Overscan)是做什么的?缓冲区过大或过小会有什么问题?
答案:
缓冲区的作用:消除快速滚动时的白屏(Blank Flash)现象。
问题分析:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
无缓冲区的白屏场景:
帧1:scrollTop = 0
可见范围:[0, 15]
渲染:[0, 15]
用户快速滚动到 scrollTop = 500(移动 10 项)
帧2:scrollTop = 500
可见范围:[10, 25]
但是!从帧1到帧2之间:
- 帧1 结束时:视口看到 [0, 15]
- 帧2 开始时:scrollTop = 500,此时视口内的项应该是 [10, 25]
而此时 DOM 中只有 [0, 15]!!!
浏览器先擦除了 [0-9](因为 new scrollTop 让它们移出了视口)
但新项 [16-25] 的 DOM 还未生成
→ 视口中的 [10-15] 区域的 DOM 被擦除
→ 但 [16-25] 还未准备好
→ 用户看到白屏!(通常持续 1-2 帧 = 16-32ms)
缓冲区过小(overscan = 1-2):
- 正常速度滚动没问题
- 高速滚动时仍然会出现短暂白屏
- 适用于:滚动速度被限制的列表(如 IScroll)
缓冲区过大(overscan = 20-30):
- 几乎所有滚动速度都不会白屏
- 但渲染了太多的不可见项
- 适用于:每项 DOM 结构简单、但需要非常流畅的列表
经验值:overscan = containerHeight / itemHeight * 0.5 左右,通常 3-10 项。
面试题 2:不定高虚拟列表中,item 的实际高度是怎么获取的?为什么需要位置修正?
答案:
高度获取方式:
不定高列表中,只有在 DOM 渲染到浏览器中后,才能通过以下方式获取实际高度:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 方法一:getBoundingClientRect (推荐)
function getActualHeight(element) {
return element.getBoundingClientRect().height;
}
// 方法二:offsetHeight
function getActualHeight(element) {
return element.offsetHeight; // 整数,四舍五入
}
// 方法三:getComputedStyle
function getActualHeight(element) {
return parseFloat(getComputedStyle(element).height);
}
最佳的获取时机是在 item 渲染完成后(微任务或下一帧):
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
// React 中使用 useEffect 或 useLayoutEffect
function Item({ index, onMeasure }) {
const ref = useRef(null);
useLayoutEffect(() => {
if (ref.current) {
const height = ref.current.getBoundingClientRect().height;
onMeasure(index, height);
}
});
return <div ref={ref}>...内容...</div>;
}
// 或者使用 ResizeObserver(推荐,可以在尺寸变化时自动通知)
function MeasurableItem({ index, onMeasure }) {
const ref = useRef(null);
useEffect(() => {
if (!ref.current) return;
const observer = new ResizeObserver(entries => {
for (const entry of entries) {
onMeasure(index, entry.contentRect.height);
}
});
observer.observe(ref.current);
return () => observer.disconnect();
}, [index]);
return <div ref={ref}>...内容...</div>;
}
为什么需要位置修正?
这就是”滚动锚定”问题。
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
初始时(所有 item 预估高度 50px):
Item 0: top=0, height=50 ← 预估
Item 1: top=50, height=50 ← 预估
Item 2: top=100, height=50 ← 预估
用户滚到 top=100,看到 Item 2。
然后 Item 2 加载完成,实际高度变成 150px:
Item 0: top=0, height=50
Item 1: top=50, height=50
Item 2: top=100, height=150 ← 实际!
⊙ 用户在 top=100 处,正常应该看到 Item 2 的顶部
⊙ 但是 Item 2 的高度没变,只是内容变高了...不对
实际上:
Item 2 的高度变为 150px 后:
Item 0: top=0, height=50
Item 1: top=50, height=50
Item 2: top=100, height=150
Item 3: top=250, height=50 ← Item 3 的位置变了!
Item 4: top=300, height=50 ← Item 4 也变了!
用户看到的内容产生了"跳动":
之前看到 Item 2 的内容,现在在相同的 scrollTop 下
Item 2 的可见区域变小了(因为更多内容在下方 push 了视口外)
→ 需要通过锚定机制修正 scrollTop
面试题 3:虚拟列表如果支持百万级数据,除了虚拟渲染还需要做哪些额外的优化?
答案:
百万级数据的虚拟列表不能只做 DOM 虚拟化,还需要做以下 5 个方面的优化:
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
// 不一次性加载所有数据
// 使用数据分片 + 懒加载
class DataProvider {
constructor(fetchFn, totalItems) {
this.fetchFn = fetchFn;
this.totalItems = totalItems;
this.cache = new Map();
this.pending = new Map();
}
async getSlice(start, end) {
const cached = [];
const needed = [];
for (let i = start; i < end; i++) {
if (this.cache.has(i)) {
cached.push({ index: i, data: this.cache.get(i) });
} else {
needed.push(i);
}
}
if (needed.length > 0) {
const fetched = await this.fetchFn(needed[0], needed[needed.length - 1]);
fetched.forEach((item, idx) => {
this.cache.set(needed[idx], item);
});
}
return cached.concat(
needed.map(idx => ({ index: idx, data: this.cache.get(idx) }))
).sort((a, b) => a.index - b.index);
}
}
2. 对象池(Object Pool)
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
// 复用 DOM 节点,避免反复创建/销毁
class DOMPool {
constructor(createFn) {
this.pool = [];
this.active = new Set();
this.createFn = createFn;
}
acquire() {
let el;
if (this.pool.length > 0) {
el = this.pool.pop();
} else {
el = this.createFn();
}
this.active.add(el);
return el;
}
release(el) {
this.active.delete(el);
// 重置状态
el.innerHTML = '';
el.style.cssText = '';
this.pool.push(el);
}
releaseAll() {
this.active.forEach(el => this.release(el));
}
}
3. 滚动防抖
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// 滚动结束后的懒更新(非可视区的预加载)
let scrollTimer = null;
container.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
// 实时更新(核心渲染)
updateVisibleItems();
// 滚动停止 200ms 后,做预加载等非关键工作
scrollTimer = setTimeout(() => {
preloadOffscreenItems();
updateTotalHeightIfNeeded();
}, 200);
}, { passive: true });
4. 首屏/末屏占位
1
2
3
4
5
6
// 对尚未加载到的项先显示骨架屏占位
function renderPlaceholder() {
return `<div class="placeholder-item" style="height: ${defaultHeight}px">
<div class="skeleton-line"></div>
</div>`;
}
5. 虚拟化分组和树形结构
百万级数据通常不是扁平列表。需要考虑:
- 分组/分类标题:渲染 sticky 标题
- 树形折叠:展开/折叠子树
- 性能分析:通过
performance.mark()和measure()追踪每次渲染的性能
综合方案决策:
1
2
3
4
数据量 1 万以下 → 基础虚拟列表(定高即可)
数据量 1 万-10 万 → 不定高虚拟列表 + 高度缓存 + 对象池
数据量 10 万-100 万 → 以上 + 数据分片加载 + 预加载
数据量 100 万以上 → 以上 + Web Worker 处理 + wasm 排序
8. 总结与扩展
核心要点回顾
- 定高虚拟列表:O(1) 的索引计算,适合每项高度固定的场景
- 不定高虚拟列表:二分查找 + 累积高度缓存 + 滚动锚定,难点在动态高度修正
- 缓冲区策略:overscan 是流畅滚动和 DOM 开销的平衡点,推荐可见项数的一半
- 性能优化关键:使用
transform替代top/requestAnimationFrame节流 /DocumentFragment批量更新 - 数据层面:百万级数据需要数据分片、对象池、骨架屏等多层优化
值得继续深挖的方向
- 虚拟化表格(Virtual Table):固定列 + 水平虚拟 + 垂直虚拟
- 虚拟化网格(Virtual Grid):瀑布流布局的虚拟化
- 虚拟化树形(Virtual Tree):可折叠树的渲染优化
- useVirtual hook:React hooks 风格的虚拟列表封装
- TanStack Virtual:主流无框架虚拟列表库的源码分析
思考题
- 虚拟列表中,如果用户使用「滚动到顶部」功能(点击一个按钮瞬间跳到顶部),如何避免滚动时的大面积白屏?
- 在一个包含 sticky 表头的虚拟表格中,当表头固定在顶部时,如何在虚拟滚动中正确处理表头的位置?
- 如果用户的电脑开启了「缩小动画效果」(Windows 辅助功能设置),虚拟列表的滚动动画应该如何适配?
参考资源: