
一句话:虚拟滚动的核心是「只渲染可视区域内的几十个元素」,用「占位元素撑起总高度」+「
translateY偏移渲染层」——公式是「startIndex = floor(scrollTop / rowHeight)、visibleCount = ceil(containerHeight / rowHeight)」。
一、原理与三个公式
问题:10000 条数据的列表 → 10000 个 DOM 节点 → 滚动掉帧、内存暴涨
解法:只渲染「可视区域 + 前后缓冲(overscan)」内的元素
用一个「高度 = 总行数 × 行高」的占位元素撑起滚动条
视觉效果:
┌─────────────────────────────┐ ← 容器(可见区域)
│ [item 997] │ ← 实际渲染的只有这几十行
│ [item 998] │
│ [item 999] │
│ [item 1000] │
└─────────────────────────────┘
占位元素在背后撑起「10000 × 40px」的总高度
渲染层用 transform: translateY(startIndex * rowHeight) 移到正确位置
⭐ 三个核心公式:
① startIndex = Math.floor(scrollTop / rowHeight)
→ 当前滚动位置对应的「第一个可见项」
② visibleCount = Math.ceil(containerHeight / rowHeight)
→ 视口内最多能显示多少行
③ offsetY = startIndex × rowHeight
→ 渲染层需要「整体下移」多少像素(用 transform 实现)
再配一个 overscan(如 3~5 行):
startIndex = max(0, floor(scrollTop / rowHeight) - overscan)
endIndex = min(total - 1, ceil((scrollTop + containerHeight) / rowHeight) + overscan)
二、完整实现(固定行高)
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
export interface VirtualListProps<T> {
/** 数据源 */
items: T[];
/** 行高(固定行高时必传) */
itemHeight: number;
/** 可视区域高度(也可以由容器自适应) */
height?: number | string;
/** 上下各多渲染几行(减少「快速滚动白屏」) */
overscan?: number;
/** 渲染单行(⭐ 必须把 style 应用到「最外层元素」上) */
renderItem: (item: T, index: number) => React.ReactNode;
/** key(默认用 index,业务上建议传「稳定唯一 ID」) */
getKey?: (item: T, index: number) => React.Key;
/** 容器样式 */
className?: string;
style?: React.CSSProperties;
/** 滚动到指定索引(通过 ref 暴露能力) */
onScroll?: (e: React.UIEvent<HTMLDivElement>) => void;
}
export function VirtualList<T>({
items,
itemHeight,
height = '100%',
overscan = 3,
renderItem,
getKey,
className,
style,
onScroll,
}: VirtualListProps<T>) {
const containerRef = useRef<HTMLDivElement>(null);
const rafRef = useRef<number | null>(null);
const [scrollTop, setScrollTop] = useState(0);
const [containerHeight, setContainerHeight] = useState(0);
// ① 监听容器高度(容器尺寸变化时要重算可见数量)
useLayoutEffect(() => {
const el = containerRef.current;
if (!el) return;
const update = () => setContainerHeight(el.clientHeight);
update();
const ro = new ResizeObserver(update); // ⭐ 容器尺寸变化
ro.observe(el);
return () => ro.disconnect();
}, []);
// ② 滚动监听(用 rAF 节流,避免高频 setState)
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const top = e.currentTarget.scrollTop;
if (rafRef.current !== null) return;
rafRef.current = requestAnimationFrame(() => { // ⭐ 每帧最多更新一次
rafRef.current = null;
setScrollTop(top);
});
onScroll?.(e);
},
[onScroll]
);
// ③ 卸载时取消未执行的 rAF
useEffect(() => () => {
if (rafRef.current !== null) cancelAnimationFrame(rafRef.current);
}, []);
// ④ 计算可视范围
const { startIndex, endIndex, offsetY, totalHeight, visibleItems } = useMemo(() => {
const total = items.length;
const total_ = total * itemHeight;
if (total === 0 || containerHeight === 0) {
return { startIndex: 0, endIndex: -1, offsetY: 0, totalHeight: total_, visibleItems: [] as Array<{ item: T; index: number }> };
}
const start = Math.max(0, Math.floor(scrollTop / itemHeight) - overscan);
const visible = Math.ceil(containerHeight / itemHeight) + overscan * 2;
const end = Math.min(total - 1, start + visible);
const visibleItems = items.slice(start, end + 1).map((item, i) => ({ item, index: start + i }));
return {
startIndex: start,
endIndex: end,
offsetY: start * itemHeight,
totalHeight: total_,
visibleItems,
};
}, [items, itemHeight, scrollTop, containerHeight, overscan]);
return (
<div
ref={containerRef}
className={className}
onScroll={handleScroll}
style={{ overflow: 'auto', position: 'relative', height, ...style }}
>
{/* ⭐ 占位元素:撑起「总高度」,保证滚动条长度正确 */}
<div style={{ height: totalHeight, position: 'relative' }}>
{/* ⭐ 渲染层:整体下移到正确位置 */}
<div
style={{
transform: `translateY(${offsetY}px)`,
// ⚠️ 用 transform 而不是 top/absolute(避免触发 layout)
willChange: 'transform',
}}
>
{visibleItems.map(({ item, index }) => (
<div
key={getKey ? getKey(item, index) : index}
style={{ height: itemHeight, boxSizing: 'border-box' }}
>
{renderItem(item, index)}
</div>
))}
</div>
</div>
</div>
);
}
// ✅ 使用
function UserList({ users }: { users: User[] }) {
return (
<VirtualList
items={users}
itemHeight={56}
height={600}
overscan={5}
getKey={(u) => u.id}
renderItem={(user) => (
<div className="user-row">
<img src={user.avatar} alt="" width={40} height={40} />
<span>{user.name}</span>
</div>
)}
/>
);
}
⭐ 五个关键点:
① 【占位元素】高度 = `items.length * itemHeight`
→ 否则滚动条只有「几十行」的长度(用户以为没数据)
② 【translateY 偏移】而不是 `position: absolute; top: offsetY`
→ transform 不触发 layout(性能更好),也更少「重排」
③ 【overscan】多渲染几行
→ 避免「快速滚动时看到空白」(渲染跟不上滚动)
④ 【resize 监听】容器高度变化要重算
→ 否则「窗口 resize 后渲染的行数不变」
⑤ 【rAF 节流】滚动事件
→ `scroll` 每帧可能触发多次,直接 setState 会「每帧多次渲染」
三、暴露「滚动到指定索引」的能力
// ✅ 用 useImperativeHandle 暴露命令式方法(见 2.9)
export interface VirtualListHandle {
scrollToIndex: (index: number, behavior?: ScrollBehavior) => void;
scrollToTop: (behavior?: ScrollBehavior) => void;
}
export const VirtualListWithRef = forwardRef(function VirtualListWithRef<T>(
{ items, itemHeight, ...props }: VirtualListProps<T>,
ref: React.Ref<VirtualListHandle>
) {
const containerRef = useRef<HTMLDivElement>(null);
useImperativeHandle(ref, () => ({
scrollToIndex(index, behavior = 'auto') {
const el = containerRef.current;
if (!el) return;
el.scrollTo({ top: index * itemHeight, behavior });
},
scrollToTop(behavior = 'auto') {
containerRef.current?.scrollTo({ top: 0, behavior });
},
}), [itemHeight]);
return <VirtualList ref={containerRef as never} items={items} itemHeight={itemHeight} {...props} />;
});
// ✅ 使用:跳到第 5000 项
const listRef = useRef<VirtualListHandle>(null);
<VirtualListWithRef ref={listRef} items={users} itemHeight={56} renderItem={...} />;
<button onClick={() => listRef.current?.scrollToIndex(5000, 'smooth')}>跳到第 5000 项</button>;
四、不等高行的方案(思路)
固定行高的实现很简单;「不等高」需要解决三件事:
① 【高度测量】渲染每一项时测出真实高度(ResizeObserver)
② 【位置计算】维护「高度前缀和」数组,用「二分查找」定位 startIndex
prefixSum[i] = 前 i 项的高度之和
→ startIndex = 第一个满足 prefixSum[startIndex + 1] > scrollTop 的索引
③ 【滚动锚定】当「上方项的高度变化」时,要「补偿 scrollTop」
否则用户会看到「内容跳动」
⚠️ 复杂度明显上升,生产上建议用成熟库:
· react-virtuoso(不等高、反向滚动、分组,体验最好)
· @tanstack/react-virtual(headless,灵活)
· react-window(固定/变量行高,轻量)
// ✅ 「不等高」的简化实现思路(示意,不生产可用)
function useVariableHeightVirtualList(itemCount: number, estimateHeight: number, scrollTop: number) {
// ① 每项的实际高度(初始用估算值)
const heightsRef = useRef<number[]>(Array(itemCount).fill(estimateHeight));
// ② 前缀和(每次高度变化时重建)—— 实际项目里要增量更新
const prefixSums = useMemo(() => {
const sums = new Array(itemCount).fill(0);
for (let i = 1; i < itemCount; i++) {
sums[i] = sums[i - 1]! + heightsRef.current[i - 1]!;
}
return sums;
}, [itemCount]);
// ③ 二分查找「scrollTop 落在哪一项」
const findStartIndex = (offset: number) => {
let lo = 0;
let hi = itemCount - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (prefixSums[mid]! <= offset) lo = mid;
else hi = mid - 1;
}
return lo;
};
// ④ 测量到新高度时更新 heightsRef,并在「上方项变化」时补偿滚动位置
const measure = (index: number, height: number) => {
if (heightsRef.current[index] === height) return;
const delta = height - heightsRef.current[index]!;
heightsRef.current[index] = height;
const start = findStartIndex(scrollTop);
if (index < start) {
// ⭐ 上方项高度变了 → 补偿 scrollTop(保持视觉位置)
document.scrollingElement!.scrollTop += delta;
}
};
return { findStartIndex, prefixSums, measure };
}
五、与「无限滚动」结合
// ✅ 虚拟滚动 + 无限滚动:只渲染可视区,滚动到底部时加载下一页
function InfiniteVirtualList() {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['items'],
queryFn: ({ pageParam }) => api.getItems({ cursor: pageParam }),
initialPageParam: undefined as string | undefined,
getNextPageParam: (last) => last.nextCursor ?? undefined,
});
const items = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
// 滚动到「接近底部」时加载下一页
const handleScroll = useCallback(
(e: React.UIEvent<HTMLDivElement>) => {
const { scrollTop, scrollHeight, clientHeight } = e.currentTarget;
if (scrollHeight - scrollTop - clientHeight < 200 && hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
},
[hasNextPage, isFetchingNextPage, fetchNextPage]
);
return (
<>
<VirtualList
items={items}
itemHeight={56}
height="70vh"
onScroll={handleScroll}
getKey={(it) => it.id}
renderItem={(it) => <Row item={it} />}
/>
{isFetchingNextPage && <Spinner />}
{!hasNextPage && items.length > 0 && <p>没有更多了</p>}
</>
);
}
六、常见坑
// ① 忘记「占位元素」→ 滚动条只有几十行的高度
// ② 用 `top: offsetY` 而不是 `transform`(触发 layout,性能更差)
// ③ 忘记 overscan → 快速滚动时「白屏」
// ④ overscan 太大 → 白渲染(浪费)
// ⑤ 滚动监听没用 rAF 节流 → 每帧多次 setState
// ⑥ 在 onScroll 里「读布局属性」(offsetHeight/clientHeight)→ 强制同步布局
// ✅ 提前在 layout effect / ResizeObserver 里读好,onScroll 里只用已知值
const { scrollTop } = e.currentTarget; // ✅ 读 scrollTop 本身没问题
const h = containerRef.current!.clientHeight; // ⚠️ 每次滚动都读会触发 layout
// ⑦ 忘记监听「容器尺寸变化」(resize 后渲染行数不变)
// ⑧ 行高与实际不符(内容溢出或留白)
// ✅ itemHeight 必须与「渲染出的行」实际高度一致(用 box-sizing + 固定高度)
// ⑨ 用 index 作为 key(虚拟列表里 index 会「复用」,但没有稳定身份)
// ⚠️ 虚拟列表里用 index 作 key 一般「可用」(因为窗口是移动的),
// 但如果有「行内状态」(输入框/展开态),必须用稳定 ID
// ⑩ 在 renderItem 里「忘记把 style 应用给最外层元素」→ 行高不受控
// ⑪ 不等高场景下「高度频繁变化」→ 前缀和重建 + 滚动锚定抖动
// ⑫ 虚拟列表里「无法 Ctrl+F 搜索未渲染内容」(这是固有代价)
// ⑬ 「无障碍」问题(屏幕阅读器读不到未渲染内容)→ 加 aria-* 或用「分页」兜底
// ⑭ 用「滚动容器的 scrollTop」但容器其实是「内层 div 在滚」→ 读取错误
// ⑮ 在「容器高度为 0」时(如折叠状态)计算 → 渲染 0 行(要处理这个边界)
// ⑯ 忘记「卸载时取消 rAF / disconnect Observer」
// ⑰ 用「虚拟滚动」却把「所有数据一次性请求回来」(网络与内存仍是大头)
// ⑱ 认为「虚拟滚动能解决一切」(如果「单个 cell 渲染很重」,仍需优化 cell)
// ③ 与 ④ 的取舍:overscan 建议值
// · 行高低(40px 左右)→ overscan 3~5 足够
// · 行高高(200px 以上)→ overscan 1~2 就够
// · 快速滚动体验要求高 → 可适当增大(但别超过「可见行数」的一半)
七、面试延伸
- 「虚拟滚动的原理是什么?」
核心是「只渲染可视区域内的元素,用占位元素撑起总高度」。三个关键计算:①startIndex = Math.floor(scrollTop / itemHeight)(当前滚动位置对应的第一个可见项);②visibleCount = Math.ceil(containerHeight / itemHeight)(视口内最多显示多少行);③offsetY = startIndex × itemHeight(渲染层需要整体下移多少像素)。渲染时:占位元素高度设为items.length × itemHeight(保证滚动条长度正确),实际渲染的那几十行放在一个用transform: translateY(offsetY)偏移的容器里。这样 DOM 节点从「1 万个」降到「几十个」。 -
「为什么要用
transform而不是top/absolute?」
因为transform只触发「合成(composite)」,而top/left会触发「布局(layout)+ 绘制(paint)」。在「每帧都在改变偏移量」的滚动场景里,这个差异非常明显:①transform由合成线程处理,通常能在 GPU 上完成,不会阻塞主线程;②top需要重新计算布局(可能引发整棵子树的布局重算)。所以虚拟列表的偏移「标准做法是transform: translateY(...)」,并可以配will-change: transform提示浏览器(但别滥用——每个元素都加会导致合成层过多、内存暴涨)。 -
「
overscan是干什么的?」
它的作用是「在可视区域上下各多渲染几行」,用来避免「快速滚动时出现空白」——因为「滚动 → 触发scroll事件 →setState→ 重新渲染」是异步的,如果只渲染「严格可视的行」,滚动稍快一点用户就会看到空白区域。代价是「多渲染了一些行」(白渲染),所以取值要平衡:行高低(40px 左右)用 3~5 行;行高高(200px+)用 1~2 行。经验值是「不超过可见行数的一半」。 -
「滚动监听为什么要节流?」
因为scroll事件触发极其频繁(一次滚动可能触发上百次),如果在回调里直接setState,就会「每帧多次渲染」→ 直接拖垮性能。标准做法是「用requestAnimationFrame节流」:用一个rafRef标记「本帧是否已安排更新」,如果已安排就直接返回(保证每帧最多一次setState)。此外在onScroll里不要读布局属性(clientHeight/offsetHeight等)——那会触发「强制同步布局」,让滚动变成卡顿的元凶(应该提前在ResizeObserver/useLayoutEffect里读好)。 -
「怎么处理容器尺寸变化?」
用ResizeObserver监听容器(而不是监听window.resize)——因为:① 容器的尺寸可能因为「侧边栏展开/布局变化」而变(不只是窗口 resize);②ResizeObserver由浏览器在「元素尺寸变化」时通知,不需要在scroll/resize回调里读布局属性。在回调里更新containerHeightstate,从而重新计算「可见行数」。记得在清理函数里disconnect()。 -
「不等高的行怎么实现虚拟滚动?」
三件事:① 高度测量——用ResizeObserver测量每个渲染过的项的实际高度并缓存(初始用「估算值」);② 位置计算——维护「高度前缀和」数组(prefixSum[i]= 前i项的高度和),用二分查找定位「scrollTop落在哪一项」;③ 滚动锚定——当「位于当前视口上方的项」高度发生变化时,要补偿scrollTop(scrollTop += delta),否则用户会看到「内容跳动」。复杂度明显上升,生产上建议用react-virtuoso/@tanstack/react-virtual。 -
「虚拟滚动有什么代价?」
四个:① 搜索与可访问性——Ctrl+F找不到「未渲染的内容」,屏幕阅读器也读不到(需要额外处理,如提供aria-*或「分页」兜底);② 实现复杂度——尤其是「不等高、动态高度、双向加载」;③ 滚动位置恢复——需要自己记录/恢复(因为 DOM 是窗口化的);④ 「单个 cell 很重」时仍然慢——虚拟滚动只减少「节点数量」,不减少「每个节点的渲染成本」(如果一行内有复杂图表,仍要优化)。此外「锚定滚动/平滑跳转」的实现也更麻烦。 -
「虚拟滚动和
content-visibility: auto有什么区别?」
两者都减少「屏幕外的渲染工作」,但机制不同:① 虚拟滚动——不把这些元素放进 DOM(用占位撑高度),因此「DOM 数量」大幅减少(内存与样式计算都受益);②content-visibility: auto——元素仍在 DOM 里,但浏览器跳过「屏幕外元素」的布局与绘制(省绘制成本,但 DOM 节点数不变、内存不变)。所以:节点数量极大(上万)→ 用虚拟滚动;节点数量中等(几百到一两千)但每项很复杂 → 用content-visibility更简单(注意配contain-intrinsic-size避免滚动条跳动)。
一句话速记
虚拟滚动 = 「只渲染可视区 + overscan 的几十行」,公式是「
startIndex = floor(scrollTop / itemHeight)、visibleCount = ceil(containerHeight / itemHeight)、offsetY = startIndex × itemHeight」;用「占位元素(高度 = 总数 × 行高)撑滚动条」+「transform: translateY(offsetY)偏移渲染层」(用 transform 不用 top,避免 layout);overscan防快速滚动白屏(行高低用 3~5、行高高用 1~2);scroll用rAF节流(每帧最多一次 setState),且不要在onScroll里读布局属性;用ResizeObserver监听容器尺寸;行高必须与渲染结果一致(box-sizing+ 固定高度);不等高要用「高度缓存 + 前缀和 + 二分查找 + 滚动锚定」(生产建议用react-virtuoso/@tanstack/react-virtual);虚拟滚动「不解决『单个 cell 渲染重』的问题」,也有「Ctrl+F搜不到、a11y 差」的固有代价。



最新评论
读过书不知道欧·亨利的人少。教科书上选文有
这小生活不错呀
不错,必须顶一下!
看着你还在坚持,很好
看来忙了也没时间更新博客了
NIce。学习了。。。。
网站不错!!!!
简洁实用,好文章!