虚拟滚动与大数据渲染
概述
现代 Web 应用经常需要展示大量的数据列表。想象一下电商网站的商品列表、社交媒体的信息流、或者数据分析平台的数据表格——这些场景可能需要同时展示数千甚至数万条数据。如果在页面加载时创建所有这些 DOM 节点,浏览器的内存占用会急剧上升,初始渲染时间会变得难以接受,滚动时也会出现明显的卡顿。
虚拟滚动(Virtual Scrolling,也称为 Windowing)技术是解决这一问题的标准方案。它的核心思想简单而优雅:只渲染用户当前能看到的内容。通过维护一个「窗口」来覆盖可视区域,并只创建这个窗口内的 DOM 节点,虚拟滚动可以在 DOM 节点数量从数万个减少到几十个的同时,保持用户看到的数据完整性和滚动体验的流畅性。
本节将从虚拟滚动的基本原理出发,详细讲解其实现机制,然后介绍 React 生态中主流的虚拟化库,包括 react-window、react-virtuoso 和 @tanstack/react-virtual。我们会探讨动态高度、无限滚动、分组列表等复杂场景的处理方法,最后分析虚拟化技术的局限性,帮助你在合适的场景中选择最优方案。
目标
- 深入理解虚拟滚动的核心原理和实现机制
- 掌握 react-window、react-virtuoso、@tanstack/react-virtual 等主流库的使用方法和适用场景
- 学会处理动态高度、无限滚动、分组列表等复杂场景
- 了解虚拟化的局限性以及在什么情况下应该选择其他方案
知识体系
1. 虚拟滚动的核心原理
理解虚拟滚动的关键在于认识到:用户滚动页面时,他看到的内容只占列表的一小部分。以一个可视区域高度为 600px、每项高度为 50px 的列表为例,屏幕上同时可见的项目最多只有 12 个。即使列表包含 10,000 个项目,用户一次也只能看到这 12 个。
传统渲染方式会创建全部 10,000 个 DOM 节点,每个节点占用内存,渲染它们需要时间,滚动时浏览器还需要管理这大量图层的绘制和合成。而虚拟滚动只创建可见范围加上少量缓冲区(overscan)的 DOM 节点,比如 20 个左右。当用户滚动时,通过计算新的可见范围,销毁超出范围的节点,创建进入范围的节点,并更新剩余节点的位置。
传统渲染(10000 项):
┌─────────────────────┐
│ Item 1 │ ← 全部创建 DOM
│ Item 2 │
│ ... │
│ Item 10000 │
└─────────────────────┘
DOM 节点数:10000 内存:高 首次渲染:慢
虚拟滚动(10000 项):
┌─────────────────────┐ ← overscan(预渲染缓冲区)
│ Item 51 │
│ Item 52 │ ← 可视区域
│ ... │
│ Item 65 │
└─────────────────────┘ ← overscan
DOM 节点数:~20 内存:低 首次渲染:快
实现虚拟滚动的核心技术包括几个方面。首先是滚动位置到数据索引的映射:通过 scrollTop / itemHeight 可以计算出当前滚动位置对应的起始索引,然后结合可视区域高度计算出可见项的数量。其次是总高度的模拟:为了让浏览器正确显示滚动条,需要在列表容器内部创建一个撑起总高度的占位元素,它的高度等于所有项的总高度,但实际上不渲染任何内容。最后是可见项的绝对定位:可见项通过 transform: translateY(offset) 定位到正确的位置,这样在滚动时只需要更新 transform 值,而不需要重新布局。
2. 基础虚拟滚动实现
理解虚拟滚动的最直接方式是看一个简化版的实现。这个实现展示了所有核心概念,而不依赖任何外部库。
// 简化版虚拟滚动实现
function VirtualList({ items, itemHeight, containerHeight }) {
const [scrollTop, setScrollTop] = useState(0);
// 计算总高度来模拟完整列表
const totalHeight = items.length * itemHeight;
// 计算当前可见范围的起始索引
const startIndex = Math.floor(scrollTop / itemHeight);
// 计算可见项的数量(多渲染几个作为缓冲区)
const visibleCount = Math.ceil(containerHeight / itemHeight);
const overscan = 5; // 上下各额外渲染的项数
// 确定实际渲染的范围
const renderStart = Math.max(0, startIndex - overscan);
const renderEnd = Math.min(items.length, startIndex + visibleCount + overscan);
// 提取需要渲染的数据
const visibleItems = items.slice(renderStart, renderEnd);
// 计算渲染起始位置的偏移量
const offsetY = renderStart * itemHeight;
return (
<div
style={{ height: containerHeight, overflow: 'auto' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
{/* 撑起完整高度的容器 */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* 偏移到正确位置的可见项 */}
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item, i) => (
<div key={renderStart + i} style={{ height: itemHeight }}>
{item.name}
</div>
))}
</div>
</div>
</div>
);
}
这个实现展示了虚拟滚动的基本模式。overscan 参数是一个重要的优化:它定义了可视区域外额外渲染的项目数。设置适当的 overscan(通常是 3-10 个)可以防止用户在快速滚动时看到空白区域。当滚动速度超过渲染速度时,overscan 区域提供了一个缓冲区,让新项有足够的时间被创建和渲染。
在实际应用中,还需要考虑键盘导航、屏幕阅读器支持、滚动条行为等技术细节。这些都会增加实现的复杂性,这也是为什么通常推荐使用成熟的虚拟化库而不是从头实现。
3. react-window 的使用
react-window 是 React 生态中最经典的虚拟滚动库,以其简洁的 API 和高效的实现著称。它主要提供两个组件:FixedSizeList 用于固定高度的列表,VariableSizeList 用于高度可变的列表。
固定高度列表
当列表项高度一致时,FixedSizeList 是最高效的选择,因为它不需要在滚动时重新测量每项的高度。
import { FixedSizeList as List } from 'react-window';
function VirtualizedList({ items }) {
const Row = ({ index, style }) => (
<div style={style} className="list-row">
<span>{items[index].name}</span>
<span>{items[index].email}</span>
</div>
);
return (
<List
height={600}
itemCount={items.length}
itemSize={50}
width="100%"
overscanCount={10}
>
{Row}
</List>
);
}
style 参数是 react-window 最重要的概念之一。每个行组件必须将传递给它的 style 应用到自己的根元素上。这个 style 包含了元素的 position: absolute、top 和 height 属性,由 react-window 计算后传入。如果你忘记应用 style 或者修改了它,布局就会完全错乱。
overscanCount 控制可视区域外额外渲染的项数。增加这个值可以减少快速滚动时出现空白区域的几率,但会增加 DOM 节点数量和内存占用。
可变高度列表
当列表项高度不固定时(例如社交媒体动态、展开的评论等),需要使用 VariableSizeList。这种模式下,react-window 依赖你提供的函数来获取每项的高度。
import { VariableSizeList as List } from 'react-window';
function DynamicList({ items }) {
const listRef = useRef(null);
// 根据内容计算每项的高度
const getItemSize = (index) => {
const item = items[index];
const baseHeight = 50;
const lineHeight = 20;
const lines = Math.ceil(item.content.length / 80);
return baseHeight + lines * lineHeight;
};
const Row = ({ index, style }) => (
<div style={style} className="list-row">
<h4>{items[index].title}</h4>
<p>{items[index].content}</p>
</div>
);
// 当数据变化时重置高度缓存
useEffect(() => {
listRef.current?.resetAfterIndex(0);
}, [items]);
return (
<List
ref={listRef}
height={600}
itemCount={items.length}
itemSize={getItemSize}
width="100%"
overscanCount={5}
>
{Row}
</List>
);
}
可变高度列表的主要挑战在于高度测量和缓存。react-window 不会在初始渲染时测量所有项的高度——它只在项进入可视区域时测量一次,然后缓存结果。当列表数据变化时,需要调用 resetAfterIndex(0) 来清除缓存,否则可能出现布局错误。
另一个需要注意的是,getItemSize 函数必须返回确定性的结果。如果相同索引在不同调用时返回不同高度,缓存就会失效并导致布局错误。
虚拟化网格
对于同时需要行和列的场景(如数据表格或图片网格),react-window 提供了 FixedSizeGrid 组件。
import { FixedSizeGrid as Grid } from 'react-window';
function VirtualizedGrid({ data, columnCount }) {
const Cell = ({ columnIndex, rowIndex, style }) => {
const item = data[rowIndex * columnCount + columnIndex];
if (!item) return null;
return (
<div style={style} className="grid-cell">
<img src={item.thumbnail} alt={item.title} loading="lazy" />
<span>{item.title}</span>
</div>
);
};
return (
<Grid
columnCount={columnCount}
columnWidth={200}
height={600}
rowCount={Math.ceil(data.length / columnCount)}
rowHeight={250}
width={800}
overscanColumnCount={2}
overscanRowCount={3}
>
{Cell}
</Grid>
);
}
网格的 Cell 组件接收 columnIndex、rowIndex 和 style 参数。和列表一样,style 必须应用到根元素。overscanColumnCount 和 overscanRowCount 分别控制列和行方向上的 overscan 数量。
4. react-virtuoso 的使用
react-virtuoso 是一个更现代的虚拟滚动库,与 react-window 相比,它最大的优势是内置了动态高度测量。你不需要手动计算每项的高度,react-virtuoso 会自动测量并缓存。这种设计大大简化了开发体验,特别是在处理复杂、动态内容时。
基础用法
import { Virtuoso } from 'react-virtuoso';
function AutoHeightList({ items }) {
return (
<Virtuoso
style={{ height: '600px' }}
data={items}
itemContent={(index, item) => (
<div className="list-item">
<h4>{item.title}</h4>
<p>{item.description}</p>
</div>
)}
overscan={200}
/>
);
}
注意这里使用的是 data 属性传入完整数据数组,而不是像 react-window 那样通过索引访问。这是一种更声明式的 API 设计。itemContent 接收索引和对应项的数据,返回要渲染的内容。
overscan 在 react-virtuoso 中以像素为单位而不是项数,这提供了更精确的控制。200 像素的 overscan 意味着在可视区域上下各额外渲染 200 像素的内容。
无限滚动
无限滚动是很多应用的核心功能。react-virtuoso 提供了 endReached 回调来简化实现。
import { Virtuoso } from 'react-virtuoso';
function InfiniteList() {
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(false);
const loadMore = useCallback(async () => {
if (loading) return;
setLoading(true);
const newItems = await fetchItems(items.length, 50);
setItems((prev) => [...prev, ...newItems]);
setLoading(false);
}, [items.length, loading]);
return (
<Virtuoso
style={{ height: '600px' }}
data={items}
endReached={loadMore}
overscan={200}
itemContent={(index, item) => (
<div className="list-item">
<span>{item.name}</span>
</div>
)}
components={{
Footer: () =>
loading ? <div className="loading">Loading...</div> : null,
}}
/>
);
}
endReached 回调在用户滚动到列表末尾时触发,用于加载更多数据。react-virtuoso 不会自动管理数据追加,你需要自己在回调中更新状态。Footer 组件用于渲染加载状态,当 endReached 回调执行期间可以显示 loading 指示器。
分组列表
对于需要分组显示的数据(如通讯录的字母分组),react-virtuoso 提供了 GroupedVirtuoso 组件。
import { GroupedVirtuoso } from 'react-virtuoso';
function GroupedList({ groups }) {
// 将分组展平以便虚拟滚动处理
const groupCounts = groups.map((g) => g.items.length);
const allItems = groups.flatMap((g) => g.items);
return (
<GroupedVirtuoso
style={{ height: '600px' }}
groupCounts={groupCounts}
groupContent={(index) => (
<div className="group-header">{groups[index].label}</div>
)}
itemContent={(index) => (
<div className="group-item">{allItems[index].name}</div>
)}
/>
);
}
分组列表的难点在于同时管理组标题和组内项。react-virtuoso 通过 groupCounts 来区分哪些索引是组标题,哪些是实际项。groupContent 渲染组标题,itemContent 渲染组内每一项。
5. @tanstack/react-virtual 的使用
@tanstack/react-virtual(原 react-virtual)是 TanStack 生态系统的一部分,提供了更底层但更灵活的虚拟化实现。它的设计理念是提供 primitives 而不是封装好的组件,让你可以更自由地构建符合自己需求的虚拟化 UI。
基础用法
import { useVirtualizer } from '@tanstack/react-virtual';
function TanStackVirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 10,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{
height: `${virtualizer.getTotalSize()}px`,
width: '100%',
position: 'relative',
}}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
{items[virtualRow.index].name}
</div>
))}
</div>
</div>
);
}
与 react-window 和 react-virtuoso 不同,@tanstack/react-virtual 使用 Hooks 模式 并暴露了更底层的状态。你可以看到 virtualizer.getVirtualItems() 返回的虚拟项包含 key、index、start、size 等属性,这给了你完全控制渲染方式的能力。
动态高度测量
import { useVirtualizer } from '@tanstack/react-virtual';
function DynamicHeightList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 80,
// 可选:提供测量函数来获取实际高度
measureElement: (element) => element.getBoundingClientRect().height,
});
return (
<div ref={parentRef} style={{ height: '600px', overflow: 'auto' }}>
<div
style={{ height: `${virtualizer.getTotalSize()}px`, position: 'relative' }}
>
{virtualizer.getVirtualItems().map((virtualRow) => (
<div
key={virtualRow.key}
ref={virtualizer.measureElement}
data-index={virtualRow.index}
style={{
position: 'absolute',
top: 0,
left: 0,
width: '100%',
transform: `translateY(${virtualRow.start}px)`,
}}
>
<div className="dynamic-item">
<h4>{items[virtualRow.index].title}</h4>
<p>{items[virtualRow.index].content}</p>
</div>
</div>
))}
</div>
</div>
);
}
注意这里需要给被测量的元素添加 ref={virtualizer.measureElement} 和 data-index={virtualRow.index} 属性。measureElement ref callback 会在元素渲染后测量其实际高度,并缓存结果供后续使用。
6. 虚拟化表格
数据表格是虚拟滚动的另一个重要应用场景。一个 10 万行 × 50 列的表格,如果渲染全部 DOM,浏览器会直接崩溃。虚拟化表格让你可以只渲染可见行的数据,同时保持所有列的可访问性。
import { useVirtualizer } from '@tanstack/react-virtual';
function VirtualTable({ data, columns }) {
const parentRef = useRef(null);
const rowVirtualizer = useVirtualizer({
count: data.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 40,
overscan: 20,
});
return (
<div ref={parentRef} style={{ height: '500px', overflow: 'auto' }}>
<table style={{ width: '100%' }}>
<thead style={{ position: 'sticky', top: 0, zIndex: 1 }}>
<tr>
{columns.map((col) => (
<th key={col.key}>{col.title}</th>
))}
</tr>
</thead>
<tbody style={{ height: `${rowVirtualizer.getTotalSize()}px`, position: 'relative' }}>
{rowVirtualizer.getVirtualItems().map((virtualRow) => {
const row = data[virtualRow.index];
return (
<tr
key={virtualRow.key}
style={{
position: 'absolute',
top: 0,
transform: `translateY(${virtualRow.start}px)`,
width: '100%',
display: 'table-row',
}}
>
{columns.map((col) => (
<td key={col.key}>{row[col.key]}</td>
))}
</tr>
);
})}
</tbody>
</table>
</div>
);
}
实现虚拟化表格有几个关键点需要注意。首先,表头需要 sticky 定位,这样当用户滚动数据行时,表头始终可见。其次,表行必须使用 position: absolute 并通过 transform 定位,这是虚拟滚动的标准实现方式。最后,每个 tr 需要 display: table-row 来保持表格的正常布局计算。
7. 虚拟化的局限性与适用场景
虚拟滚动是一种强大的技术,但它不是银弹。在某些场景下,虚拟化可能不是最佳选择,甚至会带来负面效果。
虚拟化适合的场景包括:数据量超过数百项的长列表(如商品列表、搜索结果);需要同时展示大量行的数据表格;社交媒体风格的信息流(无限滚动);需要流畅滚动体验的任何大数据集。这些场景的共同特点是数据量大但相对简单,虚拟化可以显著减少 DOM 节点数量和内存占用。
虚拟化可能不适合的场景包括:数据量较小(少于 50 项)时,虚拟化带来的复杂性超过了收益;需要 SEO 的内容,因为搜索引擎无法索引虚拟化渲染的内容;需要浏览器全文搜索(Ctrl+F)的场景,因为只有可见区域的内容在 DOM 中;打印场景,打印机只能获取可视区域的内容。
虚拟化适用场景:
✅ 长列表(>100 项)
✅ 大数据表格
✅ 无限滚动
✅ 聊天消息列表
虚拟化不适用场景:
❌ 少量数据(<50 项)— 增加复杂度无收益
❌ 需要 SEO 的内容 — 搜索引擎无法索引虚拟化内容
❌ 需要 Ctrl+F 搜索的场景 — 浏览器搜索只能找到已渲染的项
❌ 打印场景 — 打印时只能打印可见区域
选择虚拟化方案时,还需要考虑库的维护状态、API 稳定性、类型支持等因素。react-window 是最成熟的方案,但近年来维护不活跃;react-virtuoso 功能最全面且维护积极;@tanstack/react-virtual 提供了最大的灵活性,是 TanStack 生态的一部分。
实战练习
练习 1:虚拟列表实现
从零实现一个支持固定高度的虚拟滚动列表。渲染 100,000 条模拟数据,确保滚动流畅。使用 Chrome DevTools 的 Performance 面板录制滚动过程,确认帧率稳定在 60fps。尝试不同的 overscan 值,观察对内存占用和滚动体验的影响。
练习 2:无限滚动 Feed
使用 react-virtuoso 实现一个类似 Twitter 或微博的信息流页面。支持动态高度的微博内容、无限滚动加载、图片懒加载。实现「滚动到顶部加载历史消息」的功能,模拟双向无限滚动。
练习 3:虚拟化数据表格
实现一个支持 10 万行 × 50 列的虚拟化表格。支持列排序(点击表头切换升序/降序)、行选中(点击选中行并高亮)、固定列(在滚动时保持某些列始终可见)。使用 react-window 的 Grid 组件或 @tanstack/react-virtual 自定义实现。
延展阅读
- react-window 官方文档 — react-window 的完整 API 文档和使用指南
- react-virtuoso 文档 — react-virtuoso 的官方文档,包含丰富的示例
- @tanstack/react-virtual — TanStack 官方虚拟化库的文档和 API 参考
- Virtualize large lists with react-window — Google 官方的 react-window 使用指南
关键术语
| 术语 | 解释 |
|---|---|
| Virtual Scrolling | 虚拟滚动,只渲染可视区域内的元素以优化大量数据的渲染性能 |
| Windowing | 窗口化,虚拟滚动的另一种说法,强调「窗口」的概念 |
| Overscan | 可视区域外额外渲染的缓冲项数,防止快速滚动时出现空白 |
| Infinite Scroll | 无限滚动,滚动到底部时自动加载更多数据 |
| Dynamic Height | 动态高度,列表项高度不固定的场景,需要运行时测量 |
| measureElement | 动态测量 DOM 元素实际高度的方法,用于可变高度列表 |
| estimateSize | 在实际测量前提供的预估高度,帮助浏览器计算滚动条 |
| Virtualized Table | 虚拟化表格,通过虚拟滚动技术处理大数据量表格的渲染 |