虚拟列表:渲染 1 万条数据毫无压力
虚拟列表:渲染 1 万条数据毫无压力
为什么要用虚拟列表
当列表数据量很大时(比如 1 万条、10 万条),如果直接把所有数据都渲染成 DOM 节点,会出现明显的性能问题:
- DOM 节点过多:浏览器需要创建和维护海量节点,内存占用高。
- 渲染卡顿:首次渲染时间长,滚动时掉帧。
- 滚动不流畅:大量节点参与重排重绘,FPS 下降。
但实际上,用户在同一时刻能看到的列表项只有几十条(可视区域大小)。虚拟列表的核心思想就是:只渲染可视区域内的那几十条,其余的用空白占位撑开滚动条。
这样无论数据总量是 1 万还是 10 万,实际渲染的 DOM 节点数量始终是几十个,性能几乎不受数据量影响。
核心思路
虚拟列表需要解决三个问题:
- 当前滚动到了哪里 → 通过
scrollTop获取。 - 应该渲染哪些数据 → 根据
scrollTop和每项高度,计算出可见区的起始和结束索引。 - 渲染的内容怎么定位到正确位置 → 用
translateY把渲染的节点偏移到它本来应该在的位置。
同时需要一个总高度等于 数据总量 × 单项高度 的占位容器,用来撑出真实的滚动条。
完整代码
import { useState, useMemo } from "react";
function VirtualList({ data, itemHeight = 50, visibleCount = 20, buffer = 5 }) {
const [scrollTop, setScrollTop] = useState(0);
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight - buffer));
const endIndex = Math.min(
data.length,
Math.ceil(scrollTop / itemHeight + visibleCount + buffer),
);
const visibleItems = data.slice(startIndex, endIndex);
const offsetY = startIndex * itemHeight;
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};
return (
<div
style={{ height: visibleCount * itemHeight, overflowY: "auto" }}
onScroll={handleScroll}
>
<div style={{ height: data.length * itemHeight, position: "relative" }}>
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((index, i) => (
<div
key={startIndex + i}
style={{ height: itemHeight, boxSizing: "border-box" }}
>
{index.content}
</div>
))}
</div>
</div>
</div>
);
}
// 使用:渲染 1 万条数据毫无压力
export default function VirtualListDemo() {
const data = useMemo(
() =>
Array.from({ length: 10000 }, (_, i) => ({
id: i,
content: `第 ${i + 1} 条`,
})),
[],
);
return (
<VirtualList data={data} itemHeight={50} visibleCount={20} buffer={5} />
);
}参数说明
| 参数 | 说明 | 默认值 |
|---|---|---|
data | 完整的列表数据 | - |
itemHeight | 每一项的固定高度(px) | 50 |
visibleCount | 可视区域最多显示多少项 | 20 |
buffer | 上下额外多渲染的缓冲项数 | 5 |
代码逐行解析
状态:scrollTop
const [scrollTop, setScrollTop] = useState(0);记录外层容器的滚动距离,这是整个虚拟列表的唯一状态。每次滚动都会更新它,从而触发重新计算可见区域。
计算起始索引
const startIndex = Math.max(0, Math.floor(scrollTop / itemHeight - buffer));scrollTop / itemHeight:滚动距离除以单项高度,得到当前可视区域顶部对应的数据索引。- buffer:往上多渲染buffer条,避免快速滚动时出现空白。Math.max(0, ...):防止索引为负数。
计算结束索引
const endIndex = Math.min(
data.length,
Math.ceil(scrollTop / itemHeight + visibleCount + buffer),
);scrollTop / itemHeight + visibleCount:可视区域底部对应的索引。+ buffer:往下也多渲染buffer条作为缓冲。Math.min(data.length, ...):防止索引超出数据总量。
切出可见数据
const visibleItems = data.slice(startIndex, endIndex);从完整数据中切出本次需要渲染的那一段,只有这一小段会真正变成 DOM。
计算偏移量
const offsetY = startIndex * itemHeight;渲染的第一个元素本来应该位于 startIndex * itemHeight 这个位置。用 translateY 把整块渲染内容向下偏移这么多,就能让它们出现在正确的滚动位置上。
滚动事件
const handleScroll = (e) => {
setScrollTop(e.target.scrollTop);
};监听外层容器的滚动事件,把最新的 scrollTop 写入状态,触发重新渲染。
外层容器:可视窗口
<div
style={{ height: visibleCount * itemHeight, overflowY: "auto" }}
onScroll={handleScroll}
>height:固定高度,等于可视条数 × 单项高度,这就是用户能看到的窗口大小。overflowY: "auto":内容超出时出现滚动条。
中层容器:撑开滚动条
<div style={{ height: data.length * itemHeight, position: "relative" }}>height:等于数据总量 × 单项高度,让滚动条的总长度和真实数据量一致。- 这个容器本身不渲染内容,只是占位,让用户能滚动到对应位置。
内层容器:偏移定位
<div style={{ transform: `translateY(${offsetY}px)` }}>用 transform 把实际渲染的内容偏移到正确位置。用 transform 而不是 top,是因为 transform 不会触发重排,性能更好。
渲染列表项
{visibleItems.map((index, i) => (
<div
key={startIndex + i}
style={{ height: itemHeight, boxSizing: "border-box" }}
>
{index.content}
</div>
))}key={startIndex + i}:用全局索引作为 key,保证滚动时 React 能正确复用节点。boxSizing: "border-box":保证每项实际占用的就是itemHeight,不会因为 padding/border 撑大。
滚动过程示例
假设 itemHeight = 50,visibleCount = 20,buffer = 5,数据总量 10000:
初始状态(scrollTop = 0):
startIndex = max(0, floor(0/50 - 5)) = max(0, -5) = 0
endIndex = min(10000, ceil(0/50 + 20 + 5)) = 25
offsetY = 0 * 50 = 0
渲染:第 0 ~ 24 条,共 25 条
向下滚动 500px(scrollTop = 500):
startIndex = max(0, floor(500/50 - 5)) = max(0, 5) = 5
endIndex = min(10000, ceil(500/50 + 20 + 5)) = 35
offsetY = 5 * 50 = 250
渲染:第 5 ~ 34 条,共 30 条
内容向下偏移 250px,对齐到正确位置
向下滚动 5000px(scrollTop = 5000):
startIndex = max(0, floor(5000/50 - 5)) = 95
endIndex = min(10000, ceil(5000/50 + 20 + 5)) = 125
offsetY = 95 * 50 = 4750
渲染:第 95 ~ 124 条无论滚动到哪里,实际渲染的节点数始终是 30 个左右。
为什么需要 buffer
快速滚动时,React 的渲染速度可能跟不上滚动速度,导致可见区域边缘出现短暂空白。buffer 的作用是上下各多渲染几条,相当于一个缓冲带,让用户在快速滚动时也看不到空白。
三层 DOM 结构的作用
外层(可视窗口) → 固定高度,出现滚动条,监听滚动
└ 中层(占位容器) → 总高度 = 数据量 × itemHeight,撑出滚动条长度
└ 内层(偏移容器)→ translateY 偏移,里面是真正渲染的列表项| 层级 | 作用 | 关键样式 |
|---|---|---|
| 外层 | 限制可视区域大小,提供滚动 | height、overflowY |
| 中层 | 撑出真实滚动条长度 | height: 总高度 |
| 内层 | 把渲染内容定位到正确位置 | transform: translateY |
优缺点
| 优点 | 缺点 |
|---|---|
| 渲染节点数恒定,性能稳定 | 只支持等高列表项 |
| 1 万、10 万条数据都能流畅滚动 | 滚动事件触发频繁,需要节流/RAF 优化 |
| 实现简单,易于理解 | 不支持动态高度(需要额外测量) |
| 滚动条长度与真实数据一致 | 快速滚动仍可能有短暂空白 |
可改进的方向
1. 滚动事件优化
当前每次滚动都直接 setScrollTop,可能触发非常多次渲染。可以用 requestAnimationFrame 节流:
const handleScroll = (e) => {
if (rafId.current) return;
rafId.current = requestAnimationFrame(() => {
setScrollTop(e.target.scrollTop);
rafId.current = null;
});
};2. 支持动态高度
如果每项高度不一致,就不能直接用 scrollTop / itemHeight 计算索引。常见做法是:
- 维护一个
positions数组,记录每项的top和height。 - 滚动时用二分查找定位到
startIndex。 - 渲染后测量真实高度,回写
positions。
3. 滚动条闪烁
快速滚动时由于 React 渲染延迟,可能出现白屏。可以增大 buffer,或用 ResizeObserver + 预渲染来缓解。
复杂度分析
- 时间复杂度:每次滚动
O(1)计算 +O(visibleCount)渲染,与数据总量无关。 - 空间复杂度:
O(visibleCount),只渲染可见部分 + buffer。 - DOM 节点数:恒定为
visibleCount + 2 * buffer左右。
这就是虚拟列表能在万级数据下保持流畅的根本原因:它把渲染规模从 O(n) 降到了 O(1)。
