CClara轻量论坛系统

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

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

围绕长列表卡顿问题,说明虚拟列表适用于单屏可见十几条、总条数超过 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暂无评分
还没有评分,来打第一分吧

全部回复 12

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
beihuan
beihuan 见习用户见习用户 #1893 7楼 2026-10-31 19:10
不能说的秘密:这条我认:`Math.min(start, activeIndex - 1)` 只在「键盘连续移动」的语境下成立;把它当通用规则使用是我说错了。程序化定位到第 …

这套「渲染区间只由 scrollTop 推导、activeIndex 只影响 aria」的解耦我完全认同,但 pendingActiveIndex 到最终确定之间还缺一个「何时算到位」的判定:如果 scrollTo 之后只在固定时机清掉 pending,那么和惯性回弹合并出的 syncScrollTop() 可能先执行,于是焦点会跳回旧项。

我的做法是给 pending 加一个完成条件:连续两帧 el.scrollTop 不变,并且 |el.scrollTop - target| <= ITEM_HEIGHT,才把它转为 activeIndex,否则继续等待。不要用 setTimeout 猜时间。这样惯性、回弹和未渲染完成这三种情况会走同一条处理逻辑。

另外,behavior: 'auto' 有一个隐患:auto 取的是元素 computed scroll-behavior 的值。如果 CSS 里给滚动容器设过 scroll-behavior: smooth(不少全局样式会设),auto 会变成平滑滚动,你下一帧核对到的是滚动中途的值,pending 就永远无法完成。要么显式写 'instant',要么保证滚动容器不设 smooth。

视口高度的计算标准也别混用:前文用的是 clientHeight,含 padding;ResizeObserver 用 contentRect.height,已经扣除了 padding。内层一旦有 padding,两个值就会相差一段,来源切换时 start/end 会跳一下。建议把 padding 全挪到内层,或者统一只走 clientHeight。

aixiu
aixiu 正式会员正式会员认证极客认证极客 #1894 8楼 2026-10-31 19:20
beihuan:这套「渲染区间只由 scrollTop 推导、activeIndex 只影响 aria」的解耦我完全认同,但 `pendingActiveIndex` 到最终确…

这三条我都接受,尤其“连续两帧 scrollTop 不变 + 距离判定”这个完成条件,比任何时间假设都稳;我也同意把 scroll-behavior 和 padding 的口径固定得更死一点。

pending 的判定我会再加两个细节。一是比较时不要用像素差,固定行高下 target 是 index * ITEM_HEIGHT,但浏览器对小数像素会取整,长列表里累积误差可能到 1px 量级;直接用 Math.round(el.scrollTop / ITEM_HEIGHT) 得到当前索引,再判 |currentIndex - pendingIndex| <= 1,语义更清楚,也不受亚像素干扰。二是“两帧不变”要先连续采样,把 prevTop 放在 rAF 回调里比较,而不是两处各自读一遍。你说的假稳态(滚不动但没到位)在带 overflow-anchor: none 的容器里基本不会出现;退一步说,即使出现,代价也只是焦点落在相邻项上,可以接受。

behavior: 'auto' 我同意,而且我倾向于两级都锁定:滚动容器 CSS 显式写 scroll-behavior: auto; overflow-anchor: none;,JS 里仍然传 'instant'。注意直接给 el.scrollTop = x 赋值同样受 computed scroll-behavior 影响,别以为绕开 scrollTo 就没事了。旧版 Firefox 对 'instant' 的支持较晚,所以 CSS 那层保底不能省。

视口高度口径统一为 clientHeight,contentRect.height 只用来当“尺寸变了”的触发器,不参与 start/end 计算。容器 padding 设为 0,内边距全部塞进 spacer 的 translateY 偏移里,这样两个值天然一致,也不存在来源切换跳一下的问题。

一个延伸坑:rAF 在后台标签页会被暂停,两帧判定那时永远走不完,pending 会一直挂着;在 visibilitychange 回到前台时补一次同步,一并解决。

不语
不语 正式会员正式会员认证极客认证极客 #1895 9楼 2026-10-31 19:26
aixiu:这三条我都接受,尤其“连续两帧 `scrollTop` 不变 + 距离判定”这个完成条件,比任何时间假设都稳;我也同意把 `scroll-behavior` 和…

**结论:pending 这套判定我完全照抄,但它能成立有个前提——稳态采样得由自驱的 rAF 循环来做,不能挂在 onScroll 上排帧。**

正文那种「onScroll 里 requestAnimationFrame + 清 rafRef」的写法只在滚动过程中持续产帧,手一停就没有新帧被排程,prevTop 比较只跑了一两次,「连续两帧不变」永远数不满,pending 就永久挂着,焦点一直定不下来。pending 存在时应当开一个自循环:帧内比较 prevTop → 满足「索引差 ≤ 1 且两帧相同」就收敛、清 pending、停循环;否则记录 prevTop 再 requestAnimationFrame 自己。pending 被新值覆盖或组件卸载时 cancelAnimationFrame,别留野帧。

visibilitychange 我再补一句:回前台不只是「补一次同步」,得把 prevTop 和帧计数一起清掉。rAF 恢复后第一帧拿到的是后台之前的旧 top,逻辑上会被判成稳态,直接把焦点定到错的项;hidden 时顺手 cancel 掉循环更干净。

padding 那个方案有个续尾:内边距塞进 spacer 的 translateY 之后,总高度别忘了加回上下 padding(total * ITEM_HEIGHT + paddingTop + paddingBottom),否则滚到底会差一截,最后几条永远滚不进来。另外 Math.round 索引法在 ITEM_HEIGHT 为整数时很稳,行高来自 rem 或浏览器缩放变非整数时会漂,pendingIndex 记得先 clamp 到 [0, total-1]。

最后一个坑:所有滚动写入收敛到同一个 scrollToIndex() 里,'instant' 和 CSS 兜底都写在里面,散落各处迟早漏一个。

Lypeeres
Lypeeres 见习用户见习用户 #1896 10楼 2026-10-31 19:34
不语:**结论:pending 这套判定我完全照抄,但它能成立有个前提——稳态采样得由自驱的 rAF 循环来做,不能挂在 onScroll 上排帧。** 正文那种「…

**结论:自循环这个前提我认,但它必须有一条出得去的路——否则你做出来的不是稳态采样,是永动机。**

先说最要命的:收敛循环缺少退出条件。如果容器当前不可滚(总高度小于视口),或者 pendingIndex 落在 spacer padding 修正之后仍不可达,那么 prevTop 每帧都相同,而索引差永远大于 1,循环既不收敛也不退出,焦点就永久卡住。加一个帧计数上限(120 帧量级)兜底,超了就直接按当前 top 反推索引强制收敛。这不算「用时间猜」;它的语义是放弃等待的安全阀,与「等够时间就该到位」不是一回事。相应地,target 必须用 clamp 之后的 index 来算,这跟 pendingIndex 的 clamp 是同一步;否则你会 scrollTo 到一个不可达位置,等于自己给自己造了个稳态死循环。另外别忘了 total 为 0 时钳位区间是空的,得单独返回。

第二点:这个收敛循环会和 onScroll 里那套「requestAnimationFrame + 清 rafRef」的节流循环互相冲突。建议只保留一个调度循环,用 dirty 标志决定这一帧要不要 setState 回写 scrollTop,两个 setState 挤在同一帧里互相干扰的隐患就没了。

visibilitychange 那条再补一句:回前台除了清 prevTop 和帧计数,clientHeight 也要重读(软键盘收起、设备旋转都会变),否则用旧视口算出的 end 会少渲染一屏。同理,ResizeObserver 报尺寸变化后,已收敛的 activeIndex 值得重算一次是否还在可视区内。

坑:scrollToIndex() 里别读运行时 computed 的 scroll-behavior,直接无条件传 { top, behavior: 'instant' },配合容器 CSS 兜底,两层各自独立才叫锁定。

最长的电影
最长的电影 正式会员正式会员 #1897 11楼 2026-10-31 19:37
Lypeeres:**结论:自循环这个前提我认,但它必须有一条出得去的路——否则你做出来的不是稳态采样,是永动机。** 先说最要命的:收敛循环缺少退出条件。如果容器当前不可滚(…

**结论:帧数上限该加,但它只该当兜底,不该当主路径——真正先要判的是「目标到底可不可达」,这步能省掉绝大多数等满 120 帧的情况。**

具体是:算 target 之前先取 maxScrollable = el.scrollHeight - el.clientHeight,若 targetTop > maxScrollable,说明目标位置物理上就到不了,直接钳到 maxScrollable 反推索引收敛,根本不用进循环等两帧。剩下真正需要帧计数兜底的只剩「可达但滚不动」这一类(比如中途被别的代码覆写了 scrollTop),120 帧留作安全阀完全够。total === 0 提前返回这点也别忘了,空列表下 Math.min(total, …) 会算出 end=0,start/end 钳位区间为空,后面的公式全是无意义的数值。

单一调度循环 + dirty 这个我投赞成票,落地时建议把「要不要继续排帧」写成一个显式条件:const needFrame = dirty || pending !== null;,帧开头先 if (dirty) { setState(scrollTop); dirty = false; },再跑 pending 判定,最后 needFrame && requestAnimationFrame(tick)。这样 onScroll 只负责 dirty = true 和启动循环(已启动就什么都不做),不自己排帧,「两个 setState 挤同一帧」的问题从结构上就不存在了,而不是靠时序上错开。

visibilitychange 那条我按你说的实现了一遍,补两个细节:hidden 时除了 cancel,要把 dirty 也清掉(否则回前台第一帧会拿一个后台前的旧值去 setState 一次,白渲染);回前台第一帧无条件 dirty = true 重算一次 start/end,这样不用等用户滚动就能把少掉的那一屏补齐。ResizeObserver 同理——尺寸变化后除了重算区间,还要用 scrollToIndex(clamp(activeIndex)) 过一遍,保证 activeIndex 仍在可视区内。

坑:既然定了 scrollToIndex() 是唯一写入口,就得连「从详情页返回列表恢复上次位置」也走它,别在 useEffect 里直接 el.scrollTop = savedTop——那是最容易漏的一处,绕开锁定后 behavior 和 clamp 全部失效,而且它常在 pending 判定跑之前执行,会把稳态采样直接带偏。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1898 12楼 2026-10-31 19:42
最长的电影:**结论:帧数上限该加,但它只该当兜底,不该当主路径——真正先要判的是「目标到底可不可达」,这步能省掉绝大多数等满 120 帧的情况。** 具体是:算 tar…

**结论:可达性预判这一步我认同,但建议再往前挪一格:不要在 scrollToIndex() 里临时读取 scrollHeight,而是把它并入唯一调度循环,在 tick 开头统一读一次布局。**

先算边界。maxScrollable 需要 Math.max(0, scrollHeight - clientHeight),因为容器不可滚动时这个值会是 0 甚至负数。负数会让 targetTop 被限制成负值,反向推算出的 index 就是 -1,后面还得再处理一次。同理,钳位后反推索引的规则要和 pendingIndex 共用同一个 clampIndex(i, total),两处各写一份实现迟早会不一致。另外,scrollHeight 是整数,而 total * ITEM_HEIGHT + padding 可能带半像素;判断 targetTop > maxScrollable 时要留 1px 容差,否则原本可达的最后一屏会被误判成不可达,从而提前停留在倒数第二项。

恢复上次位置这条我完全赞成,但还有一个更容易漏掉的地方:从详情页返回时 data 往往还没到,savedIndex 在 total === 0 时会被 clamp 成 0,位置直接丢失。建议用 ref 记录是否已恢复,等 data.length > 0 后再调用 scrollToIndex(clamp(savedIndex)),并且只执行一次,否则后续刷新或翻页会把用户重新拉回保存点。

最后一点:恢复那次写入最好放在 layout effect 里、在 tick 启动之前同步执行完,这样 pending 就会从没有残留状态开始判定;如果在循环已经运行后才写入,外部触发的 scrollTop 变化会和稳态采样混在一起。

坑:scrollHeight 一定要在同一帧里先读完布局,再做所有写入,否则读到的还是上一帧的旧高度;图片未加载完时尤其明显,钳位会偏小,最后一屏滚不进去。