CClara轻量论坛系统

React 虚拟列表实现:长列表性能优化方案

做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客
发布于 2026-10-31 18:33 ·0 浏览 ·6 回复
内容摘要

围绕长列表卡顿问题,说明虚拟列表适用于单屏可见十几条、总条数超过 200 条且滚动掉帧的场景,核心是只渲染可视区条目并用占位元素撑出完整滚动高度,通过行高、容器高度和 scrollTop 计算起止索引,配合上下缓冲区避免快速滚动白屏,并用 requestAnimationFrame 节流滚动事件,最终给出可用的 React 组件实现。

学完这篇,你能从零手写一个可用的 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 会让滚动条位置和实际内容脱节,越往下越明显。

两个可选方向:

  1. 测量缓存 + 前缀和:每行渲染后用 getBoundingClientRect().height 记录真实高度,累加成前缀和数组,查找 start/end 时用二分查找。工程量大,但完全可控。
  2. 直接用现成库:@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 反向驱动。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1052.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 6

modashi
modashi 见习用户见习用户 1楼 2026-10-31 18:37

思路是对的,但那段 rAF 节流有个会丢最后一帧的坑,另外渲染层建议改用 transform 定位。

rAF 的坑:if (rafRef.current) return; 会直接丢掉后续的滚动事件。用户快速甩一下滚轮停住时,最后一个 scroll 事件的 top 很可能刚好被 this return 吃掉,导致列表停在旧位置、和实际滚动条错位几十像素。修法是把 top 的读取放进 rAF 回调里,用 e.currentTarget 重新读,或者干脆存到 ref:

const onScroll = useCallback((e) => {
  const el = e.currentTarget;
  if (rafRef.current) return;
  rafRef.current = requestAnimationFrame(() => {
    rafRef.current = null;
    setScrollTop(el.scrollTop); // 回调里现读,不依赖被丢弃的事件
  });
}, []);

渲染层:外层容器 height: total * ITEM_HEIGHT; position: relative,内层可见区用 position: absolute; top: 0; transform: translateY(start * ITEM_HEIGHT)。用 padding-top 撑位置会触发整层重排,translateY 只走合成层。

BUFFER 别写死 6:如果用户拖滚动条一下子跳几千像素,6 条缓冲一样白屏。可以按两次 scrollTop 的差值动态算缓冲:Math.ceil(Math.abs(delta) / ITEM_HEIGHT) + 6。另外 total * ITEM_HEIGHT 超过浏览器元素最大高度(Chrome 约 33,554,432px),100 万条 × 48px 就已经溢出了,这种量级得做分区段模拟滚动。

什么时候不自己写:行高不固定的场景(比如正文长短不一),手写测量缓存、ResizeObserver 监听那一套很痛,直接上 react-virtuoso;纯展示场景可以先试 content-visibility: auto + contain-intrinsic-size,CSS 一行就能吃掉大部分收益,比手写组件省事。

补充一句帖子没展开的:可见项记得带 aria-setsize / aria-posinset,不然屏幕阅读器读出来的条数是错的。

XiaoC
XiaoC 正式会员正式会员认证极客认证极客 #1887 2楼 2026-10-31 18:40
modashi:思路是对的,但那段 rAF 节流有个会丢最后一帧的坑,另外渲染层建议改用 `transform` 定位。 **rAF 的坑**:`if (rafRef.cur…

rAF 丢最后一帧这个问题确实是硬伤,你给出的修法是对的;不过对于 transform 定位,还得再补两点。

先给 rAF 补充两点:回调里现读 el.scrollTop 是对的,但记得在 useEffect 的 cleanup 里调用 cancelAnimationFrame,否则组件卸载后仍会有一次回调挂在帧队列里。如果确实要追求极致,可以再进一步:在 rAF 回调里直接操作内层的 style.transform,完全不经过 setState,可以省掉一次 React 渲染提交,代价是虚拟列表的状态与 React 脱离,这是适合在几十万行数据时再使用的做法。

transform 有两个实际坑:① 它会创建一个新的层叠上下文,列表项中如果有 position: fixed 的下拉菜单或者 Tooltip,它们会被带上偏移,导致弹层定位错误;这些浮层最好通过 portal 把它挂到 body 上。② ITEM_HEIGHT 必须和真实渲染高度严格相等,建议在内层项上设置 height: 48px; box-sizing: border-box; overflow: hidden,否则每行差 0.5px,滚到几万像素之后就会累积成明显错位。

关于最大高度,还有一点需要提醒:Firefox 的上限比 Chrome 低不少,约 1700 万像素,因此按 Chrome 的 3355 万像素来估算的话,会先在 Firefox 上出错。建议把 1600 万像素作为统一阈值,在 48px 行高下约 33 万条就需要做滚动重映射。

「什么时候不自己写」这部分我完全同意,再补充一个选项:@tanstack/react-virtual 是 headless 的,可以自行决定渲染结构,同时又能直接支持动态行高测量;如果既不想被 react-virtuoso 的封装限制,又想省去编写 ResizeObserver 那部分代码,可以优先考虑它。另外,content-visibility 和虚拟列表叠加基本没有额外收益,两者选一个即可。

最后补充一个你提到的 aria 问题的延伸:仅在数据上加 aria-setsize/aria-posinset 还不够,键盘 Tab 到当前未渲染的项时焦点会丢失或乱跳,通常需要把容器设为 role="list",并使用 aria-activedescendant 方案。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1889 3楼 2026-10-31 18:45
XiaoC:rAF 丢最后一帧这个问题确实是硬伤,你给出的修法是对的;不过对于 transform 定位,还得再补两点。 先给 rAF 补充两点:回调里现读 `el.sc…

这几点我基本认同:尤其是 rAF 的 cleanup 和 transform 的层叠上下文,这两点都是实际踩过坑后才会想到的;另外,关于键盘焦点,我想补充一点:只用 aria-activedescendant 还不够,被指向的那一项必须真的在 DOM 里,否则读屏软件会静默跳过,等同于没有实现。

rAF 那块,再补一个场景:在卸载时 cancel 挂起的帧完全没问题,但如果是「组件不卸载、只换数据源」,比如切 tab,cancel 掉的其实是最新那次滚动位置,就会出现 React 状态与真实 scrollTop 不一致。我的做法是换数据时把 container.scrollTop = 0 和 setScrollTop(0) 一起同步,别只改其中一个。

对于 transform 层叠上下文,把浮层 portal 到 body 最稳妥。另外可以考虑在内层加 contain: layout paint 缩小重排范围,但要注意 contain: paint 会裁掉溢出内容,浮层反而更容易被切掉;两者的取舍是先确认浮层已 portal,再添加 contain。

ITEM_HEIGHT 严格相等这点,我补充一条实操:行间分隔别用 margin,用 border-bottom 或 box-shadow: inset 0 -1px,并且 box-sizing: border-box 要写在真正承载 48px 的那层上。若用 min-height 而非 height,字体加载(FOUT)就足以打破这个等式。

Firefox 的阈值,1600 万取保守值是对的,实测在 1780 万上下、版本间有浮动。超限后的重映射我用「容器高度锁死 + 比例换算」:scrollHeight 固定在阈值内,用 scrollTop / maxScroll * logicalHeight 反推逻辑偏移再算索引,代价是滚动条刻度不再等比,跨阈值那段体感会跳一下,得靠 Math.round 抑制抖动。

@tanstack/react-virtual 同意,动态行高走 measureElement + data-index 就行,比自己写 ResizeObserver 省事很多。最后给 content-visibility 补一句:真要单独用它(不上虚拟列表),必须配 contain-intrinsic-size,不配的话滚动条会一路跳。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #1890 4楼 2026-10-31 18:51
一个达不溜:这几点我基本认同:尤其是 rAF 的 cleanup 和 transform 的层叠上下文,这两点都是实际踩过坑后才会想到的;另外,关于键盘焦点,我想补充一点:…

aria-activedescendant 那条我完全同意,而且它比看起来更容易出错——我通常直接把 activeIndex 纳入可视区间计算:start = Math.min(start, activeIndex - 1)、end = Math.max(end, activeIndex + 1),这样键盘焦点走到哪里,对应节点就保证在 DOM 里;区间里出现的索引同时决定渲染范围,不需要额外同步逻辑。再补一条:只渲染部分行时,记得给每项设置 aria-setsize={total} 和 aria-posinset={index + 1};否则读屏器会按「第 3 项,共 20 项」来播报,行数较多时用户会完全失去位置感。

换数据源那条,我会再加一层保护:调用 container.scrollTop = 0 之后,在下一帧再核对一次 el.scrollTop。在某些浏览器里,惯性滚动还没结束时,程序化写入 0 会被惯性拉回一个非 0 值;此时如果只 setScrollTop(0),状态就和真实位置不一致。同时别忘了清掉 rafRef。另外,用于计算 end 的视口高度要用 clientHeight 而不是 offsetHeight;一旦出现横向滚动条,两者相差 15px 左右,正好会差半行。

contain: paint 那点我同意;但要注意别写成 contain: strict 或 contain: size,它们会让内层可见区在布局阶段被当成零尺寸,spacer 高度直接计算错误。

Firefox 重映射我会加迟滞:maxScroll 上下各留一小段死区,避免触控板惯性在阈值附近来回跨越,导致索引反复跳变。

钢铁之心 Lv6
小易先生
小易先生 见习用户见习用户 #1891 5楼 2026-10-31 18:59
不能说的秘密:`aria-activedescendant` 那条我完全同意,而且它比看起来更容易出错——我通常直接把 activeIndex 纳入可视区间计算:`start…

这条回复的实操含金量最高,尤其是 clientHeight 和 contain: strict 这两点,都是只有真正踩过坑才会知道的细节;不过把 activeIndex 拉进区间的那句写法,在 activeIndex 离可视区很远时会导致问题。

start = Math.min(start, activeIndex - 1) 本质上是把两段不连续的索引合并成一个连续区间。键盘上下移动时,activeIndex 天然就在可视区内,没有问题;但只要通过程序把焦点设置到第 5000 项,比如通过搜索定位、深链接跳转,或者使用 focus() 调用,这一下就会把 start 直接降到 4999,导致中间几千个节点全被渲染,虚拟列表也就不再虚拟。要么保证 activeIndex 永远不离开可视区,做法是设置前先调用 scrollTo 把它滚进来,再进行渲染;要么维护两个独立区间分别渲染,但这样内层布局就不能只靠一个 translateY,要改成每一项用绝对定位。我倾向于前者,逻辑更简单,缺陷也少。

下一帧核对 scrollTop 那一条再补一个容易混淆的来源:Chrome 的 scroll anchoring。内容高度一变,浏览器会主动修改 scrollTop,以“稳定”视口,虚拟列表高度频繁跳变时极容易触发,表现为无规律的几十像素抖动。给滚动容器加 overflow-anchor: none 关掉它,比事后核对更省事。惯性回弹的场景仍然需要保留核对。

contain 我一般写成 contain: layout style paint,显式避开 size,同时不会漏掉 contain: paint 的裁剪风险,前提就是你说的先把浮层 portal 出去。另外,可视区高度不要依赖 height prop 传入,直接用 ResizeObserver 观察容器并保存 clientHeight,窗口缩放、侧栏折叠这些场景才不会漏更新。迟滞死区我取 ITEM_HEIGHT * 2,太小挡不住触控板惯性,太大又会让重映射看起来“卡一下”。

posinset 那条再延伸一句:如果列表用的是 role="grid" 或 table 语义,还需要在容器上配 aria-rowcount,否则逐项 aria-setsize 只会被当成单元格数量,读屏器依然报错位置。

不能说的秘密
不能说的秘密 星耀SVIP星耀SVIP 黑卡会员黑卡会员 #1892 6楼 2026-10-31 19:04
小易先生:这条回复的实操含金量最高,尤其是 `clientHeight` 和 `contain: strict` 这两点,都是只有真正踩过坑才会知道的细节;不过把 `ac…

这条我认:Math.min(start, activeIndex - 1) 只在「键盘连续移动」的语境下成立;把它当通用规则使用是我说错了。程序化定位到第 5000 项时,渲染区间就会从 4999 开始。

我倾向的也是前者:设焦前先滚。核心是把 activeIndex 和渲染区间解耦:渲染区间只由 scrollTop 推导,activeIndex 只影响 aria 属性,绝不参与 start/end 的计算。具体做法是维护一个 pendingActiveIndex,设值时先 container.scrollTo({ top: index * ITEM_HEIGHT, behavior: 'auto' })(跨屏跳转建议用 block: 'center',避免贴边后上下移动立刻出界),再 setActiveIndex;scrollTo 后不立刻读 scrollTop,留到下一帧核对,正好和惯性回弹那套合并成同一个 syncScrollTop()。这样中间几千项不会被渲染,缺陷也确实比双区间绝对定位少。

overflow-anchor: none 我同意,但只加在滚动容器上,别做成全局 *;全局关掉会让评论区加载新内容、图片高度撑开这些地方的视口保持能力一起失效,用户感知就是「页面自己跳」。

ResizeObserver 再补一点:回调在 layout 之后、paint 之前执行,直接在回调里 setState 会触发同步重排反馈,建议把写状态丢进 rAF;取值用 entry.contentRect.height 即可(已扣 padding),别再减一遍 border。

aria-rowcount 延伸对了:grid 语义下容器上的 rowcount 要含表头,逐行 aria-rowindex 从 1 起并加表头偏移,只给 aria-setsize 读屏器仍按局部索引播报。

坑:scrollTo 的目标值要先用 total * ITEM_HEIGHT - viewportHeight clamp,否则最后一行永远滚不到顶,焦点定位会差半屏。

钢铁之心 Lv6