虚拟列表完整实现深度解析:从定高到动态高度的工程实践
虚拟列表只渲染视口内可见的几十个 DOM 节点,让十万条数据也能秒开,核心是动态算 startIndex、endIndex 并用 padding 占满滚动高度。 面试常考定高版本原理、不定高 item 的预估加修正策略,以及滚动监听与缓冲区的实现。
一句话概括
虚拟列表只渲染视口内可见的几十个 DOM 节点,让 10 万条数据也能秒开——核心技术是动态计算 startIndex/endIndex、用 padding-top/padding-bottom 占满滚动高度,以及处理不定高 item 的预估+修正策略。
核心知识点
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
// 核心公式:
// startIndex = Math.floor(scrollTop / itemHeight)
// endIndex = Math.ceil((scrollTop + containerHeight) / itemHeight)
// visibleItems = data.slice(startIndex, endIndex + buffer)
// offsetY = startIndex * itemHeight → 用 padding-top 占位
function FixedVirtualList({ items, itemHeight = 50, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
const buffer = 5; // 上下各多渲染 5 个,避免快速滚动时白屏
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight) - buffer);
const endIndex = Math.min(
items.length,
Math.ceil((scrollTop + containerHeight) / itemHeight) + buffer
);
const visibleItems = items.slice(startIndex, endIndex);
const totalHeight = items.length * itemHeight;
const offsetY = startIndex * itemHeight;
return (
<div style={{ height: containerHeight, overflow: 'auto' }}
onScroll={e => setScrollTop(e.target.scrollTop)}>
<div style={{ height: totalHeight, position: 'relative' }}>
{/* paddingTop 向上撑开,让可见区域定位在正确位置 */}
<div style={{ paddingTop: offsetY }}>
{visibleItems.map((item, i) => (
<div key={startIndex + i} style={{ height: itemHeight }}>
{item}
</div>
))}
</div>
</div>
</div>
);
}
10 万条数据,定高虚拟列表只渲染约(600/50 + 10)= 22 个 DOM 节点,而全量渲染会创建 10 万个 DOM → 浏览器直接卡死。
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
64
65
66
67
// 真实场景:每条消息/评论高度不一,无法用固定 itemHeight
function DynamicVirtualList({ items, estimateHeight = 80, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
const [positions, setPositions] = useState([]); // 每个 item 的实际位置
const itemRefs = useRef({});
const buffer = 3;
// 二分查找:根据 scrollTop 找到起始索引
const findStartIndex = (scrollTop) => {
let lo = 0, hi = positions.length - 1;
while (lo < hi) {
const mid = Math.floor((lo + hi) / 2);
if (positions[mid].bottom < scrollTop) lo = mid + 1;
else hi = mid;
}
return lo;
};
// 首次用预估高度初始化位置缓存
useEffect(() => {
setPositions(items.map((_, i) => ({
index: i, top: i * estimateHeight, bottom: (i + 1) * estimateHeight, height: estimateHeight
})));
}, [items.length]);
// 渲染后测量实际高度并修正缓存
useEffect(() => {
let changed = false;
const newPositions = [...positions];
Object.entries(itemRefs.current).forEach(([key, el]) => {
const idx = parseInt(key);
const realHeight = el.offsetHeight;
if (realHeight !== newPositions[idx].height) {
const diff = realHeight - newPositions[idx].height;
newPositions[idx].height = realHeight;
newPositions[idx].bottom += diff;
// 修正后续所有 item 的位置
for (let j = idx + 1; j < newPositions.length; j++) {
newPositions[j].top += diff;
newPositions[j].bottom += diff;
}
changed = true;
}
});
if (changed) setPositions(newPositions);
}, [visibleItems]);
const startIndex = Math.max(0, findStartIndex(scrollTop) - buffer);
const endPos = positions.find(p => p.bottom >= scrollTop + containerHeight) || positions[positions.length - 1];
const endIndex = Math.min(items.length, (endPos?.index || items.length - 1) + buffer + 1);
const visibleItems = items.slice(startIndex, endIndex);
const totalHeight = positions.length > 0 ? positions[positions.length - 1].bottom : items.length * estimateHeight;
const offsetY = positions[startIndex]?.top || 0;
return (
<div style={{ height: containerHeight, overflow: 'auto' }} onScroll={e => setScrollTop(e.target.scrollTop)}>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ paddingTop: offsetY }}>
{visibleItems.map((item, i) => {
const idx = startIndex + i;
return <div key={idx} ref={el => itemRefs.current[idx] = el}>{item}</div>;
})}
</div>
</div>
</div>
);
}
核心难点:不定高场景下滚动位置的计算依赖实际渲染后测量的高度,但高度测量又依赖 DOM 存在——这是一个”鸡生蛋”问题。解法是用预估高度先占位,渲染后用 getBoundingClientRect 测量真实高度,修正位置缓存。
3. 主流虚拟列表库对比
1
2
3
4
5
6
7
8
9
10
# react-window(轻量,定高场景首选)
npm i react-window
# <FixedSizeList height={600} itemCount={100000} itemSize={50}>{Row}</FixedSizeList>
# react-virtuoso(不定高自动处理,零配置,2026 推荐)
npm i react-virtuoso
# <Virtuoso data={items} itemContent={(i, item) => <Row item={item} />} />
# TanStack Virtual(框架无关,header/group 支持好)
npm i @tanstack/react-virtual
选择建议:定高 → react-window,不定高 + 复杂布局 → react-virtuoso,需要框架无关 + 分组/粘性头部 → @tanstack/react-virtual。
4. 虚拟列表的常见坑
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 坑1:搜索/筛选后滚动位置异常
// ✅ 数据变化后重置 scrollTop
useEffect(() => { containerRef.current.scrollTop = 0; }, [items]);
// 坑2:快速滚动白屏
// ✅ 增加 buffer(上下各多渲染 3-5 个 item)+ 用 transform 代替 paddingTop 减少重排
// 坑3:图片加载撑开高度导致位置偏移
// ✅ 图片设置固定宽高 + aspect-ratio,或加载后触发重新测量
// 坑4:onScroll 节流不够导致高频 setState
// ✅ 用 useRef 存 scrollTop,rAF 中批量更新
const scrollTopRef = useRef(0);
const [, forceUpdate] = useReducer(x => x + 1, 0);
useEffect(() => {
let raf;
const loop = () => { forceUpdate(); raf = requestAnimationFrame(loop); };
raf = requestAnimationFrame(loop);
return () => cancelAnimationFrame(raf);
}, []);
5. 虚拟列表 vs 分页:选哪个?
| 维度 | 虚拟列表 | 分页 |
|---|---|---|
| 体验 | 丝滑滚动,无感知切换 | 需要点”下一页”或下拉加载 |
| 适用 | 聊天、日志、股票行情 | 搜索引擎、商品列表 |
| SEO | ❌ 不好(DOM 不存在) | ✅ 好(每页独立 URL) |
| 实现 | 复杂(需处理不定高) | 简单 |
| 内存 | 小(DOM 节点数恒定) | 随翻页累积(不断 append) |
结论:用户体验优先用虚拟列表,SEO 优先用分页(或 SSR + 虚拟列表混合)。
其实你每天都在用
- 微信聊天列表:几千条消息丝滑滚动——就是虚拟列表,只渲染屏幕上的 ~20 条,滚走了的 DOM 直接复用
- 微博/Twitter 时间线:往下滑内容不断出现,往上滑之前的还在——虚拟滚动 + 数据回收
- Excel Online:几万行数据在浏览器里流畅操作——虚拟列表 + Canvas 渲染
- VS Code 编辑器:打开 10 万行的代码文件,滚动时只有视口内行被渲染
常见误解(FAQ)
❌ 误区:display: none 能让不可见的 item 不占渲染开销
display: none 的元素不参与布局和绘制,但它们仍然在 DOM 树中,仍然占用内存。10 万个 display: none 的 div 照样让浏览器 OOM。虚拟列表根本没有创建这些 DOM 节点,这才是省内存的根本。
❌ 误区:虚拟列表不需要处理键盘导航和无障碍
虚拟列表只渲染可见 item,Tab 键导航和屏幕阅读器会丢失不可见元素。需要实现 WAI-ARIA 的 aria-rowcount、aria-rowindex、aria-posinset 等属性,让屏幕阅读器知道有 10 万项而不是 20 项。
❌ 误区:虚拟列表和无限滚动(Infinite Scroll)是一回事
无限滚动是”加载更多数据并追加到列表”——数据量不断增长,最终还是会卡(DOM 节点累积)。虚拟列表是”数据全在内存,但只渲染可见的”——DOM 节点数恒定。生产环境经常两者结合:虚拟列表负责渲染性能,无限滚动负责数据分页加载。
一句话总结
虚拟列表的本质是”欺骗”——用 20 个 DOM 节点配合 paddingTop 骗过用户的眼睛,让他们以为所有数据都已渲染。