学完这篇,你能从零手写一个可用的 React 虚拟列表组件,并知道长列表卡顿的真正原因、常见坑点,以及什么场景该自己写、什么场景该换成熟库。
第一步:先确认你真的需要虚拟列表
虚拟列表解决的是「DOM 节点太多」导致的渲染卡顿,不是「数据量大」导致的请求慢。判断标准很简单:单屏可见十几条、总条数超过 200 条、滚动时掉帧或首屏白屏明显,就值得上。如果列表只有 50 条,加个 React.memo 或分页就够了,引入虚拟列表反而增加复杂度。
核心思路一句话:只渲染可视区域内的若干条,用一个撑高的占位元素模拟完整滚动高度。
第二步:算出可视区间的三个变量
你需要三个输入:单行高度(固定行高先假设 48px)、滚动容器高度、当前 scrollTop。由它们推出起止索引:
const ITEM_HEIGHT = 48;
const BUFFER = 6; // 上下各多渲染几条,防止快速滚动白屏
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);
const end = Math.min(total, Math.ceil((scrollTop + viewportHeight) / ITEM_HEIGHT) + BUFFER);
BUFFER 是必须的。只渲染严格可视区,快速拖动滚动条时会出现一瞬间的空白,用户体感就是「闪」。
注意:Math.floor 和 Math.ceil 别写反。start 向下取整才能保证半行也渲染,end 向上取整同理,写反会漏掉最后半行。
第三步:完整组件实现
滚动事件用 requestAnimationFrame 节流,避免一帧内多次 setState:
import { useRef, useState, useCallback } from 'react';
const ITEM_HEIGHT = 48;
const BUFFER = 6;
export default function VirtualList({ data, height = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
const rafRef = useRef(null);
const onScroll = useCallback((e) => {
const top = e.currentTarget.scrollTop;
if (rafRef.current) return;
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setScrollTop(top);
});
}, []);
const total = data.length;
const start = Math.max(0, Math.floor(scrollTop / ITEM_HEIGHT) - BUFFER);
const end = Math.min(total, Math.ceil((scrollTop + height) / ITEM_HEIGHT) + BUFFER);
const visible = data.slice(start, end);
return (
<div style={{ height, overflowY: 'auto' }} onScroll={onScroll}>
{/* 占位层:撑出完整滚动高度 */}
<div style={{ height: total * ITEM_HEIGHT, position: 'relative' }}>
{visible.map((item, i) => {
const index = start + i;
return (
<div
key={item.id}
style={{
position: 'absolute',
top: index * ITEM_HEIGHT,
height: ITEM_HEIGHT,
left: 0,
right: 0,
}}
>
{item.title}
</div>
);
})}
</div>
</div>
);
}
用绝对定位 + top 而不是上下 padding,是因为绝对定位不会触发子元素重排,行数多时性能更稳。
第四步:几个必须处理的边界
容器高度不要写死。 上面 height 是 prop,实际项目里用 ResizeObserver 监听容器,高度变了要重新计算 end,否则窗口变大会漏渲染。
key 必须用稳定 id。 用数组索引当 key,在头部插入或删除时,React 会复用错误的节点,滚动位置和内容都会错位。
跳转到指定行,直接操作 DOM 即可,不要通过 state 绕一圈:
const boxRef = useRef(null);
boxRef.current.scrollTop = index * ITEM_HEIGHT;
scrollTop 赋值会同步触发 onScroll,state 自然跟上。
列表项组件要 React.memo。 每次滚动都会重渲染可视区,如果每条内部有复杂计算、图片、富文本,不 memo 会抵消虚拟列表的收益。
注意:如果列表不是独立滚动容器,而是嵌在整页滚动里(比如 window 滚动),上面这套 scrollTop 计算就不适用了。要么给列表固定高度做独立滚动区,要么改用 IntersectionObserver 逐项挂载。这两种方案别混着写,是常见 bug 来源。
第五步:行高不固定怎么办
固定行高方案最稳,但聊天记录、评论区、带图卡片这类内容行高会变。此时固定 ITEM_HEIGHT 会让滚动条位置和实际内容脱节,越往下越明显。
两个可选方向:
- 测量缓存 + 前缀和:每行渲染后用
getBoundingClientRect().height 记录真实高度,累加成前缀和数组,查找 start/end 时用二分查找。工程量大,但完全可控。
- 直接用现成库:
@tanstack/react-virtual 支持 measureElement 动态测量,无头设计、体积小,是目前新项目的首选;react-window 的 VariableSizeList 更轻但需要手动传 estimatedItemSize;react-virtualized 功能最全但年代较久,新项目不建议。
注意:动态测量必须给 estimatedItemSize 一个合理估值,否则首次渲染时滚动条长度会剧烈跳动,用户会觉得「页面在抖」。
小结
- 虚拟列表的本质是「撑高占位 + 只渲染可视区」,不是缓存优化。
- 三要素:行高、容器高度、
scrollTop;起止索引用 floor/ceil 配合 BUFFER 留缓冲。
- 滚动事件用
requestAnimationFrame 节流,列表项用 React.memo,key 用稳定 id。
- 容器高度用
ResizeObserver 跟着变,否则响应式下会漏渲染。
- 固定行高自己写 50 行就够;动态行高优先用
@tanstack/react-virtual,别硬啃。
- 跳转指定行直接改
scrollTop,不要通过 state 反向驱动。