React 长列表虚拟滚动实战:手写可视区渲染,10 万条数据不卡顿
后台管理系统里经常遇到这种需求:一次性拉回来几万条日志、几万个 SKU,产品说"不想分页,就要滚动加载全部"。你老老实实 list.map() 渲染出来,浏览器直接卡死——10 万个 DOM 节点,首屏白屏两秒,滚动时掉帧到个位数。
问题的根源很简单:用户视口里只能看到十几行,但你把 10 万个节点全塞进了 DOM。 虚拟滚动(virtual scrolling)的思路就是——只渲染可视区内的那几十条,滚动时动态替换。这篇我们不套第三方库,手写一个定高虚拟列表,把原理彻底讲透。
朴素写法:先看看它怎么卡的
function PlainList({ items }) {
return (
<div style={{ height: 600, overflow: 'auto' }}>
{items.map((item) => (
<div key={item.id} style={{ height: 40 }}>
{item.text}
</div>
))}
</div>
);
}
// items 有 100000 条时,React 要 diff 十万个 fiber 节点,
// 首次挂载卡顿,之后每次滚动触发的重排也拖垮帧率。
打开 DevTools 的 Performance 面板录一段,你会看到 Scripting 和 Rendering 两条长得吓人。DOM 节点数直接爆到十万级。
核心思路:算出"该渲染哪几条"
定高虚拟列表的数学非常直白。假设每行高度固定 itemHeight,容器高度 containerHeight,当前滚动距离 scrollTop:
- 总高度 = items.length * itemHeight,用一个撑高的占位 div 保证滚动条长度正确。
- 可视区第一条的索引 = Math.floor(scrollTop / itemHeight)。
- 可视区能放下的行数 = Math.ceil(containerHeight / itemHeight)。
- 把这一段切出来,用 transform: translateY 推到正确的位置。
关键点:不要用 margin-top 或改变元素高度来定位,用 transform——transform 不触发重排(reflow),只走合成层,滚动才跟手。
正确写法:一个可用的 VirtualList
import { useState, useRef, useMemo } from 'react';
function VirtualList({ items, itemHeight = 40, containerHeight = 600 }) {
const [scrollTop, setScrollTop] = useState(0);
const containerRef = useRef(null);
// 多渲染几条做缓冲,避免快速滚动时上下边缘露白
const overscan = 3;
const { startIndex, endIndex, offsetY } = useMemo(() => {
const visibleCount = Math.ceil(containerHeight / itemHeight);
const start = Math.floor(scrollTop / itemHeight);
// 上下各多切 overscan 条,并夹住边界
const startWithBuffer = Math.max(0, start – overscan);
const endWithBuffer = Math.min(
items.length,
start + visibleCount + overscan
);
return {
startIndex: startWithBuffer,
endIndex: endWithBuffer,
// 偏移量必须按"实际渲染的第一条"算,否则内容会错位
offsetY: startWithBuffer * itemHeight,
};
}, [scrollTop, itemHeight, containerHeight, items.length]);
const visibleItems = items.slice(startIndex, endIndex);
const totalHeight = items.length * itemHeight;
return (
<div
ref={containerRef}
style={{ height: containerHeight, overflow: 'auto', position: 'relative' }}
onScroll={(e) => setScrollTop(e.currentTarget.scrollTop)}
>
{/* 占位层:撑出真实滚动高度,让滚动条比例正确 */}
<div style={{ height: totalHeight }}>
{/* 内容层:用 transform 推到可视区,不触发重排 */}
<div style={{ transform: `translateY(${offsetY}px)` }}>
{visibleItems.map((item) => (
<div key={item.id} style={{ height: itemHeight }}>
{item.text}
</div>
))}
</div>
</div>
</div>
);
}
用 10 万条数据测:DOM 节点常驻只有二十几个(可视 + 缓冲),首屏瞬开,滚动稳定 60fps。
const items = Array.from({ length: 100000 }, (_, i) => ({
id: i,
text: `第 ${i} 行数据`,
}));
<VirtualList items={items} />;
坑一:offsetY 一定要按缓冲后的起点算
新手最容易写错的一行:offsetY = start * itemHeight,用的是没加 overscan 的 start。结果就是往上滚动时内容整体错位 overscan * itemHeight 像素,列表"抖一下"。记住——你 slice 从哪条开始,translateY 就得推到那条的位置,两者必须用同一个索引。
坑二:onScroll 高频触发要不要节流
onScroll 每帧可能触发多次,直觉上想 throttle。但这里恰恰不要节流:setScrollTop 触发的重渲染已经被 React 批处理,而且我们只改了少量 DOM。真正该做的是把滚动状态更新交给浏览器的渲染节奏——如果你发现掉帧,用 requestAnimationFrame 包一层,而不是用定时器节流(定时器会让内容滞后于滚动条,露白更明显)。
const rafId = useRef(null);
const handleScroll = (e) => {
const top = e.currentTarget.scrollTop;
if (rafId.current) return; // 一帧内只更新一次
rafId.current = requestAnimationFrame(() => {
setScrollTop(top);
rafId.current = null;
});
};
坑三:不定高怎么办
上面的方案要求每行等高。真实业务里很多是不定高(比如带图片、可折叠的评论)。定高方案直接失效,因为你算不出 scrollTop 对应第几条。这时候的通用做法是动态测量 + 位置缓存:先用估算高度渲染,挂载后用 ResizeObserver 量出真实高度,存进一个 Map<index, height>,再据此累加算偏移。工程量陡增,这也是为什么生产环境不定高场景直接上 @tanstack/react-virtual 或 react-window ——它们把测量、缓存、滚动锚定都处理好了。定高场景手写完全够用,不定高才值得引库。
小结
- 长列表卡顿的本质是 DOM 节点太多,虚拟滚动只渲染可视区 + 缓冲的几十条。
- 定高列表三件套:撑高的占位层(保证滚动条比例)、translateY 定位的内容层(不触发重排)、按滚动距离算出的切片区间。
- offsetY 必须和 slice 用同一个起点索引,否则内容错位。
- 滚动更新别用定时器节流,要么直接 setState 让 React 批处理,要么用 requestAnimationFrame 对齐渲染帧。
- 一句话记忆:用户只看得到一屏,你就只渲染一屏——剩下的用高度撑着、用 transform 骗过去。

