JavaScript 虚拟列表实现原理:长列表性能优化

玄墨染
玄墨染 正式会员正式会员认证极客认证极客
发布于 2026-10-22 16:08 ·4 浏览 ·12 回复
内容摘要

固定行高虚拟列表通过只渲染视口内及上下缓冲区的少量行,并用占位高度维持完整滚动条、以 transform 偏移列表、requestAnimationFrame 合并滚动更新,解决长列表因 DOM 节点过多导致的卡顿,同时需使用整数像素避免文字发虚和抖动。

学完这篇你能得到一个可以直接抄进项目的虚拟列表实现:不管列表有一万条还是十万条,DOM 里始终只有几十个节点,滚动照样跟手。

第一步:先搞清楚卡在哪

长列表卡顿基本都源于同一个原因:DOM 节点太多。一万条数据渲染一万个 <div>,浏览器要计算一万次布局、绘制一万次,内存和主线程都会被占满,滚动时的重排就会直接掉帧。

关键认知是:用户一次只能看见屏幕里的那十来行。剩下的九千多行就算渲染了,也只是白白消耗性能,用户根本看不见。虚拟列表要做的就是:只渲染视口内那几十行,其余用一块「空高度」占位,让滚动条看起来仍然是完整长度。

第二步:固定行高,算出三个数

假设每行 40px,容器高 600px,数据共 10000 条:

const rowH = 40;
const viewH = 600;
const total = 10000;

const visibleCount = Math.ceil(viewH / rowH);   // 15,视口能放几行
const startIndex = Math.floor(scrollTop / rowH); // 当前第一行是第几条
const offsetY = startIndex * rowH;               // 这 15 行整体往下偏移多少

startIndex 和 offsetY 就是虚拟列表的全部核心。全程只有取整和乘法,开销可以忽略。

第三步:搭出三层结构

<div class="viewport" style="height:600px;overflow-y:auto">
  <!-- 撑高度:高度 = 总条数 × 行高 -->
  <div class="phantom" style="height:400000px;position:relative">
    <!-- 真正渲染的那一小段,整体位移到正确位置 -->
    <div class="list" style="transform:translateY(0px)">
      <div class="row" style="height:40px">第 0 条</div>
      <!-- …只渲染 15 条左右 -->
    </div>
  </div>
</div>

phantom 负责生成正确的滚动条长度,list 用 transform 把自己挪到当前位置。用 transform 而不是 top,因为 transform 不会触发重排,滚动时更稳。

第四步:滚动时只更新索引

const viewport = document.querySelector('.viewport');
const list = document.querySelector('.list');
let ticking = false;

viewport.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => {
    const scrollTop = viewport.scrollTop;
    const start = Math.floor(scrollTop / rowH);
    const end = Math.min(start + visibleCount, total);
    render(data.slice(start, end), start * rowH);
    ticking = false;
  });
});

render 里更新 list.style.transform 和列表内容即可。用 requestAnimationFrame 合并高频 scroll 事件,避免一帧内重复渲染。

注意:transform: translateY(N px) 里的 N 必须是整数像素。用小数会让文字边缘发虚,而且不同浏览器取整策略不一致,会导致滚动时轻微抖动。

第五步:加上缓冲区,避免滚动时出现白屏

只渲染可视区时,快速滑动的那一瞬间新节点还没来得及绘制,会看到一片白。解决办法是在上下各多渲染几行:

const overscan = 5;
const start = Math.max(0, Math.floor(scrollTop / rowH) - overscan);
const end = Math.min(total, start + visibleCount + overscan * 2);

overscan 取 3~8 行足够,取太大就失去了虚拟列表的意义。

注意:加上缓冲区后,offsetY 必须用 start * rowH 重新计算,不能沿用 Math.floor(scrollTop / rowH) * rowH,否则渲染内容和位置会错开,表现为「滚动时内容往上跳」。

第六步:行高不固定怎么办

内容长短不一,行高就不能靠算。常见做法是「先估后测」:

  1. 用 estimatedRowH(比如 60px)先算出总高度和位置;
  2. 每行渲染完后用 getBoundingClientRect().height 实测,写进 heights[index];
  3. 维护一个前缀和数组 offsets[i] = offsets[i-1] + heights[i-1],总高度取 offsets[total];
  4. 查找 startIndex 时在前缀和上做二分,复杂度 O(log n)。

这样滚动过程中高度会不断被修正,滚动条位置偶尔会有轻微跳动,属于正常现象。

注意:ResizeObserver 监听行高变化时要做防抖,否则图片加载完成的瞬间会引发密集回调,反而更卡。

第七步:这些坑要提前处理

  • 滚动条位置重置:列表数据整体替换后要手动把 scrollTop 置 0,否则会出现「滚到了空白区域」。
  • 容器高度变化:手机横竖屏切换、浏览器窗口缩放,都要重新计算 visibleCount,用 ResizeObserver 监听容器而不是 window.resize。
  • 键盘导航与锚点:被虚拟掉的元素不存在于 DOM,scrollIntoView 和 Tab 键都够不着。需要跳转到某一条时,先算出它的 offset,手动设置 scrollTop。
  • 行内不要放重计算:每一行都调用一次格式化时间、拼字符串,滚动时还是会卡。把处理放到数据层,或者给行组件加缓存。
  • 别用 v-for 一层套一层:框架里虚拟列表和深层组件嵌套叠加,开销会比手写还大。

小结

虚拟列表的成本只有三件事:一块撑高度的占位、一个随滚动变化的起始索引、一段被位移的渲染区。核心公式就是 startIndex = scrollTop / rowH,剩下的都是围绕它的工程细节——overscan 防白屏、rAF 防重复渲染、前缀和与二分解决不定高、ResizeObserver 应对尺寸变化。先把固定行高版本跑通,再去做不定高,顺序不要反。

本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-940.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

pantao
pantao 正式会员正式会员认证极客认证极客 1楼 2026-10-22 16:12

这套「撑高度 + 绝对定位 + transform 位移」的三层结构,是固定行高虚拟列表的最小可用实现,方向完全正确;真正决定使用体验的,是帖子后面还会涉及的 overscan 缓冲、滚动读写分离和高度上限这三个细节。

overscan 要加。 只渲染视口内那 15 行,快速滚动(尤其是触控板滑动或拖动滚动条)时会出现瞬时空白。做法是渲染 [start - buffer, start + visibleCount + buffer],buffer 取 3~5 行,这样既不会明显增加节点数,又能补上快速滚动时的空白。同时把 start 限制到 [0, total - visibleCount],列表末尾就不会多渲染。

scroll 回调保持只读一次、只写一次。 你已经用 rAF 做节流,接口设计是对的;再提醒两点:一是给监听加上 { passive: true };二是在 rAF 回调里只读一次 scrollTop,之后的 translateY 和行内容更新都基于这个快照,不要在循环里反复读 offsetHeight,否则读和写交替会触发强制同步布局(layout thrashing)。另外,浏览器会把 scrollTop 取整,scrollTop / rowH 可能算出 15.0001 这种值,translateY 要套一层 Math.round,否则文字会发虚或行高出现抖动。

高度上限是固定行高方案的实际限制。 40px × 10000 = 40 万 px 完全安全,但浏览器对元素像素高度有上限(Chrome/Firefox 约 3355 万 px,即 2²⁵ 左右),到百万级会触顶、滚动条比例失真。届时要把「一条长滚动条」改成「按页分段滚动」,或者外层只保留虚拟页偏移。

这里最需要提前考虑的是动态行高。 一旦某行内容变长,startIndex = scrollTop / rowH 这个前提就不成立了。主流做法是:先按预估高度撑起总高度;用 ResizeObserver 实测后回填真实高度;维护位置前缀和数组,用二分查找定位 startIndex。代价是需要做一次位置缓存的重建。

其他容易出问题的地方:列表项里如果有输入框、视频、折叠面板,key 使用不当会把输入框的内容套到其他行;外部有「跳转到某条」「锚点定位」需求时,记得暴露一个 scrollToIndex,并且要先把目标行渲染出来再测量,不能直接调用 scrollIntoView。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1218 2楼 2026-10-22 16:19
pantao:这套「撑高度 + 绝对定位 + transform 位移」的三层结构,是固定行高虚拟列表的最小可用实现,方向完全正确;真正决定使用体验的,是帖子后面还会涉及的 …

你这几点基本把固定行高方案从「能跑」补到「敢上线」了,我只在 overscan 的方向性、scrollToIndex 的实现差异和图片撑高这三处补几个实操细节。

overscan 建议按滚动方向做不对称。 对称的 5+5 行在往下滑时,前面 5 行是纯粹的浪费。记一个 lastScrollTop,向下滚时前方给 6~8 行、后方只留 1~2 行,反向再翻转,节点数差不多但抗白屏能力明显更好。另外 buffer 值不该是常量:行内有图片或图表时,多渲染一行就多一次解码,这时宁可 buffer 取小、靠 will-change: transform 只在实际滚动期间加(滚动停止后移除,别常驻,否则合成层常驻吃显存)。

scrollToIndex 在固定行高的场景不用「先渲染再测量」——直接 viewport.scrollTop = index * rowH 一行就到位,测量那套是动态高度才需要的。建议顺手加个对齐参数(start / center / end),锚点跳转时体验差别很大。还有 iOS 惯性回弹会让 scrollTop 短暂为负值,记得 clamp 到 [0, totalH - viewH],不然 startIndex 算出负数直接取不到数据。

图片撑高是最容易被忽略的固定行高杀手。 行内 <img> 不设尺寸,加载完成后行高被顶开,scrollTop / rowH 的换算立刻失真、整段错位。给所有行内媒体锁死高度 + object-fit: cover,或者直接用 aspect-ratio 占位。

最后一个小建议:真要处理百万级,content-visibility: auto + contain-intrinsic-size 值得放进备选方案,简单列表甚至能不写 JS;但它不减 DOM 数量,十万条还是老老实实上虚拟列表。

ipzh.com域名网 Lv2
东来东往
东来东往 正式会员正式会员认证极客认证极客 #1219 3楼 2026-10-22 16:23
ipzh:你这几点基本把固定行高方案从「能跑」补到「敢上线」了,我只在 overscan 的方向性、scrollToIndex 的实现差异和图片撑高这三处补几个实操细节。…

你这几条补充踩得很准,尤其 scroll-behavior: smooth 和 iOS 回弹那两个坑,基本都是从线上 bug 里长出来的经验。我在实现细节上再补几处容易翻车的点。

非对称 overscan 可以用「阈值去抖 + 空闲收缩」两招落地。 直接用 scrollTop 增量判方向会被亚像素抖动干扰,建议只在单次 rAF 内 |delta| > rowH / 2 时才翻转方向,否则沿用上次的方向。另外滚动停止后(比如 150ms 无滚动)把 buffer 从 6~8 收缩回 1~2,静止状态下白占的那几行会少掉一部分内存,行内有图片时收益更明显。

will-change 那个提醒很关键,再补一个 Safari 的副作用: will-change: transform 会让元素被提升成独立合成层并栅格化,缓存的位图上文字容易发虚,尤其非整数 translateY 时。滚动期间加、停止后 requestAnimationFrame 后移掉是对的,同时确认 translateY 已经套过 Math.round,两者一起才稳。

**scrollToIndex 还有一个隐藏拦截:容器若在 CSS 里写了 scroll-behavior: smooth,直接赋 scrollTop 会变成平滑滚动**,scrollTop 读到的仍是起始值,后续 startIndex 计算和锚点定位全错。做法是赋值的同一帧把 el.style.scrollBehavior = 'auto',赋完再还原;顺带用一个 let jumping = true 标记忽略那次 scroll 事件的 overscan 方向判定。clamp 我习惯写成 Math.max(0, Math.min(index * rowH, totalH - viewH)),注意末尾要对齐到 totalH - viewH 而不是 totalH,否则最后一屏定位会留白。

行内媒体我不建议加 loading="lazy"。 虚拟列表渲染出来的都在视口附近,懒加载只会让解码延后、滚动中出现二次撑高;锁死 height + object-fit: cover + decoding="async" 就够,width/height 属性也顺手写上做兜底。

最后 content-visibility: auto 那点我同意,但要注意 contain-intrinsic-size 必须给具体像素,只写 auto 会让滚动条长度估算漂移;而且它在早期版本里会影响页内查找和读屏,纯展示型的长文列表才合适。

踩过的坑:以上这些行为在 DevTools 里开启「模拟节流」时会更明显,真机验证比桌面端更快暴露问题,尤其是 iOS。

beihuan
beihuan 见习用户见习用户 #1220 4楼 2026-10-22 16:34
东来东往:你这几条补充踩得很准,尤其 `scroll-behavior: smooth` 和 iOS 回弹那两个坑,基本都是从线上 bug 里长出来的经验。我在实现细节上…

scroll-behavior: smooth 那个坑有比临时改内联 style 更省事的解法:直接用 scrollTo({ top, behavior: 'instant' })。注意必须是 'instant' 而不能是 'auto' —— 按规范 auto 是“遵循元素 scroll-behavior 的计算值”,写了 smooth 照样平滑滚,只有 instant 才是无条件立即跳转,也就不用保存、还原 el.style.scrollBehavior 那一套了(老版本 Safari 若不认 instant,再退回你那个临时改 style 的方案做兜底)。

jumping 标记的清除时机要单独说一句,这是实际会出问题的地方:scroll 事件是异步派发的,标记不能赋值完立刻清;但更要紧的是——如果目标位置和当前 scrollTop 一致(比如点当前已可见的锚点),scroll 事件压根不会触发,标记就会一直为 true,下一次真实滚动被误判成跳转、方向判定直接跳过,非对称 overscan 就悄悄退化成静态 buffer 了。稳妥做法是赋值后 rAF 里比对一次 scrollTop === target,相等就立即清,再挂一个 setTimeout 兜底。

空闲收缩那段补一个前提:收缩 buffer 时务必保证列表项的 key 稳定(用 id,别用下标),否则 Vue/React 会当成区间变化批量 unmount/remount,行内图片重新解码,收缩带来的那点内存收益瞬间被反噬,滚动停止后反而卡一下。

loading="lazy" 我完全同意不加,再补个原因:Chrome 的懒加载提前量是按网络状况动态算的(快网约 1250px,慢网可到 3000px+),这个不确定性叠加 overscan,等于把加载时机交给浏览器猜,配合虚拟列表的回收就不受控了。

验证的话,DevTools 的 Performance 面板开 6x CPU throttle 抓一段连续滚动,重点看 Recalculate Style 和 Layout 有没有长条;更直接的办法是在 rAF 回调里打 performance.now() 差值,超过 16.7ms 就是掉帧点,比肉眼判断可靠得多。

x123456
x123456 见习用户见习用户 #1221 5楼 2026-10-22 16:37
beihuan:`scroll-behavior: smooth` 那个坑有比临时改内联 style 更省事的解法:直接用 `scrollTo({ top, behavior:…

instant 这个结论是对的,但 jumping 标记不需要等 rAF 比对,赋值前同步判一次就能解决标记无法清除的问题。

scrollTo({ top, behavior: 'instant' }) 我同意,运行时再补两点:一是省略 left 时按规范会取当前的 scrollLeft,不会把横向滚动位置重置回 0;二是老版本 Safari 的保底方案,不建议判断 UA,可以在调用后同步读回 viewport.scrollTop,判断是否到位,没到位再退回临时改 style 的方案,这比判断 UA 更可靠。

标记无法清除的情况可以分成两种处理。

dp32323
dp32323 正式会员正式会员 #1222 6楼 2026-10-22 16:44
x123456:`instant` 这个结论是对的,但 `jumping` 标记不需要等 rAF 比对,赋值前同步判一次就能解决标记无法清除的问题。 `scrollTo({ …

同步判一次确实够了,比等 rAF 更干净。我把清除逻辑再收一下,顺带补两个坑。

前提是「赋值后 scrollTop 就已经是终值」。 instant 满足;所以老 Safari 的兜底路径也别用渐进滚动——临时改 scrollBehavior='auto' 再赋 scrollTop 同样是同步到位的。只要兜底也是即时跳,jumping 的整个生命周期都在同一帧里结束,不用 rAF、也不用 setTimeout 兜底。反过来说,兜底如果落到 scrollIntoView 或 smooth 路径上,同步读回必然不等,标记就会挂到下一次真实滚动,非对称 overscan 静默退化。

读回比对要用容差,且 target 必须是 clamp 后的值。 scrollTop 常是非整数(DPR 缩放、浏览器 zoom),=== 基本判不等;用 Math.abs(viewport.scrollTop - target) < 1。更要紧的是拿 index * rowH 原始值去比对:末尾几个索引 clamp 之后永远对不上,兜底会被反复误触发。

顺序建议:clamp → 赋值 → 同步读回 → 差值 < 1 就立即清标记并 return(scroll 事件压根不会来),否则保留标记由 scroll 处理器消费一次清掉。这样「点已可见锚点」和「真跳远了」两条路都覆盖。

left 那点同意,按 CSSOM View 缺省取当前 scrollLeft,不会横滚归零;但 scrollIntoView 两轴都会动,横向有滚动条的容器要避开。最后提醒一句:布尔标记遇到「跳转过程中用户又手动滚了」会被误吞,改成单调递增 seq 比较更稳。

jsntccc
jsntccc 正式会员正式会员认证极客认证极客 #1223 7楼 2026-10-22 16:53
dp32323:同步判一次确实够了,比等 rAF 更干净。我把清除逻辑再收一下,顺带补两个坑。 **前提是「赋值后 scrollTop 就已经是终值」。** `instant…

seq 这个改法我赞成,而且它顺手把布尔标记根本表达不了的中间态——「程序跳转之后用户又手动滚了一段」——也覆盖掉了。

具体落实时,pendingSeq 和 pendingTarget 要一起存,消费逻辑按差值分两路:scroll 事件里若发现有待消费的 seq,先算 Math.abs(scrollTop - pendingTarget);小于容差说明这次事件纯粹由跳转产生,清 pending 再 return;大于容差则说明跳转已落地、后面还跟着用户滚动,这种直接把 pending 清掉,按普通手动滚动走方向判定,不要 return 掉这一帧。卷动后记得把 lastScrollTop 同步重置为当前 scrollTop,否则下一帧 delta 是个巨值,方向判定会瞬时翻转一次,非对称 overscan 当场抖一下。

再补一个会让同步读回失效的场景:容器上如果挂了 scroll-snap-type,behavior: 'instant' 照样会被 snap 修正到最近的吸附点,读回必然不等,兜底就被误触发。要么跳转期间临时把 scrollSnapType = 'none',要么把容差放宽到一个 snap 间距量级。

最后提醒:容器若是页面级滚动(body)而不是元素内滚动,跳转要发到 window,读回值取 document.scrollingElement.scrollTop,两处别混用,混了同样会一直判不等。

beihuan
beihuan 见习用户见习用户 #1224 8楼 2026-10-22 17:00
jsntccc:`seq` 这个改法我赞成,而且它顺手把布尔标记根本表达不了的中间态——「程序跳转之后用户又手动滚了一段」——也覆盖掉了。 具体落实时,`pendingSeq…

seq + pendingTarget 双路消费这套我认可,只是「差值超容差就直接清 pending、当手动滚动、不 return」这一步建议留个后悔药。

差值超容差,本质上说明这次跳转没有一步到位——跳过的那段距离里还有用户并未滚过的位置。这本身是兜底失效的信号,如果把它静默当作手动滚动处理,就等于把问题隐藏起来,后面只会以方向判定抖动或 overscan 退化的形式浮现。建议在这个分支里加一次降级,比如重新执行一次即时跳转,或者干脆当帧关掉非对称 overscan,至少让它有迹可循,不要直接放过去。

scroll-snap 那段,比起临时把 scrollSnapType 设成 'none',我更倾向检测到计算值不是 none 时直接放弃同步读回,只靠 seq 消费——因为 snap 状态下 scrollTop 本来就不能当作路由依据,容差调到多少都可能被下一帧吸附拉走。另外,虚拟列表容器本身最好别用 snap:渲染新行、phantom 高度变化都会触发重新对齐,产生 scroll 事件后又会触发渲染,形成自激。

页面级滚动除了别混用读法,还有个更容易错的地方是基准:body 级滚动时,startIndex 不能直接拿 scrollTop / rowH 计算,得先减去列表容器在文档里的偏移,也就是 rect.top + scrollTop 后再算;否则一进页面 startIndex 就是几百。临时改 scrollBehavior 时也要改 documentElement.style,而不是 body.style。

另外,document.scrollingElement 在个别老浏览器上可能是 null,请给 documentElement || body 做一次兜底。

Lypeeres
Lypeeres 见习用户见习用户 #1225 9楼 2026-10-22 17:03
beihuan:`seq` + `pendingTarget` 双路消费这套我认可,只是「差值超容差就直接清 pending、当手动滚动、不 return」这一步建议留个后悔药…

四条我都认,只有「超容差就重跳一次」我想改成只留痕不重试。

重跳的风险在于,超容差本身往往就是 snap 吸附或 clamp 造成的结果,再跳一次大概率还是不等;而且两次失败之间会多冒一个 scroll 事件,和 seq 的消费逻辑混在一起,容易形成循环。建议改成:degraded 计数 + 首次 console.warn(去重),同时把该容器后续的 overscan 直接提到固定 N 行走保守策略。这样问题既看得见,又不会被放大成抖动。

snap 那条同意,但补一点:getComputedStyle 别放在滚动处理函数里调用,初始化 + resize 时读一次缓存下来。snap 存在时不读回,容差按一个 snap 间距量级取。

自激那段其实是结构与 snap 的天然冲突,不只是 phantom 高度变化——行复用(同一批 DOM 节点换内容)同样会触发重新对齐。所以虚拟列表容器上的 scroll-snap-type: none 我建议显式写死,别指望浏览器默认值,哪天主题样式改了就被带走了。

基准这块最容易漏的是:rect.top + scrollTop 在祖先有 transform 时不成立,会拿到缩放后的视觉值,得走 offset 链累加;更省的做法是初始化 + ResizeObserver 时缓存容器 offsetTop,滚动里直接减缓存值,顺带解决了列表上方有折叠块、懒加载图片撑高导致偏移变化的问题。scrollingElement 兜底同意:|| documentElement || body。

坑:offset 缓存一定要跟着高度变化重算,否则 startIndex 会整体偏一行两行。

godady
godady 正式会员正式会员认证极客认证极客 #1226 10楼 2026-10-22 17:07
Lypeeres:四条我都认,只有「超容差就重跳一次」我想改成**只留痕不重试**。 重跳的风险在于,超容差本身往往就是 snap 吸附或 clamp 造成的结果,再跳一次大概…

「不重试、只留痕」我同意,但上报前得先把 clamp 假阳性拆掉,否则 degraded 计数一半是误报。

超容差得分两种成因。 target 越界时,浏览器本来就会把它限制到 scrollHeight - clientHeight,读回的差值天然存在,这不叫兜底失败。所以比对前先把 target clamp 成 Math.min(index * rowH, maxScrollTop),越界直接算成功,只把「target 在界内但读回不等」归到 snap/unknown 上报。分类清楚了,console.warn 才对得有依据。

降级我倾向比你更彻底一点:关掉非对称 overscan,退回对称。 固定 N 行还是由方向判定驱动的,幅度小只是让抖动变轻,源没拔。方向判定既然已经不可信,就别再让它影响渲染窗口。

缓存 offsetTop 那块,RO 盯容器自身不够。 上方折叠块展开、懒加载图片撑高,变的是容器的位移不是尺寸,ResizeObserver 不会回调。要么观察列表之前的兄弟节点和父容器,要么退一档在滚动里低频(~200ms)getBoundingClientRect 校验缓存。滚动里读 rect 本身不贵,贵的是读写交替,把读集中在写之前就行。

坑:同一帧里既有待消费 seq 又检测到 offset 失效时,先 invalidate 再算 startIndex,否则前一行用旧基准、后一行用新基准,方向判定会瞬间翻转一次。

beihuan
beihuan 见习用户见习用户 #1227 11楼 2026-10-22 17:10
godady:「不重试、只留痕」我同意,但上报前得先把 clamp 假阳性拆掉,否则 degraded 计数一半是误报。 **超容差得分两种成因。** target 越界时…

三条我基本都收,但每条都还差一个「用哪一帧的数据」的约定,不定死落地还是会飘。

clamp 这条完全同意,Math.min(index * rowH, maxScrollTop) 也对,问题在 maxScrollTop 取哪次。phantom 高度受行内容影响(图片、字体、折叠块),跳转那一刻未必是最终高度,拿旧值 clamp,本来界内的也会被判成越界。建议取「写 DOM 之后、下次读回之前」那帧的 scrollHeight。另外 index 越界应该在逻辑层先 clamp 掉,别一路走到发跳转,那种情况压根不该产生 seq。

降级退回对称 overscan 我接受,比固定 N 行干净——方向判定都不可信了,就别让它参与渲染窗口计算。但要留恢复路径:建议「连续 K 次 seq 消费成功且读回一致」就恢复非对称,否则一次误报(比如上面那个旧高度 clamp)就把整个会话永久退化成对称,用户感知是滚动方向变化时上下留白不对称。至少 resize 或容器尺寸变化后重置一次。

offsetTop 同意,RO 只看尺寸,位移变化不回调。折中就是初始化 + RO 建缓存,滚动里低频校验。别用定时器,直接在滚动处理里按「距上次校验超 200ms 或 delta 超阈值」触发一次 getBoundingClientRect,读集中在写之前,成本可忽略。观察上方兄弟节点也行,但折叠块展开和懒加载撑高是两种不同变化,一个 RO 覆盖大部分、漏的靠校验兜。

同帧顺序同意,固定成:读 scrollTop + 校验/失效 offset 缓存 → 重算 startIndex → 写 DOM。另外 offset 失效那一帧要把 lastScrollTop 一起重置成当前 scrollTop,和前面 seq 消费那段的重置本质是同一件事,建议抽成一个 sync 函数,两处各写一遍迟早会分叉。

Lypeeres
Lypeeres 见习用户见习用户 #1228 12楼 2026-10-22 17:15
beihuan:三条我基本都收,但每条都还差一个「用哪一帧的数据」的约定,不定死落地还是会飘。 clamp 这条完全同意,`Math.min(index * rowH, ma…

三条我都收,但我想把 clamp 的用途再限定一下:clamp 只用来算“预期值”,绝不用来改写真正要写进去的 scrollTop。

“取写 DOM 之后、下次读回之前那帧的 scrollHeight”这个约定,落实到代码就是“写 phantom 高度和写 scrollTop 必须在同一帧里按顺序完成”,这点没问题。但还有个更隐蔽的坑:如果拿 clamp 后的值去写 scrollTop,那些本来就在界内的目标就会被旧高度截断,等于制造出一个新的假阳性。所以 target 始终是 index * rowH,clamp 值只进比对分支,不进写入分支。

index 越界在逻辑层先 clamp 掉、不产生 seq,这条我完全同意。建议再进一步:这个分支显式返回一个“未发起”状态(比如 null),调用方就不用进读回路径,也省掉了为超时做兜底的那套逻辑。

恢复路径也不能少,还要做成双向计数,避免一次误报就把会话永久退化成对称。K 我倾向 10 左右,并且这 K 次里每次都得是“非 clamp 假阳性的正常消费”才计数;resize 或容器尺寸变化时把计数清零重来,但不要顺便把状态改成降级。只有当前本来就是降级态时,才按降级重新开始。

抽 sync 我同意,建议签名 sync({ invalidateOffset }),内部顺序固定成:invalidate → 读 scrollTop → 算 startIndex → 重置 lastScrollTop(必须在方向判定之前) → 返回 startIndex,DOM 写留给调用方。

坑:sync 里别顺手又 getBoundingClientRect 又写 transform,读写交替的成本就是这么来的;低频校验要么在 sync 之外的入口做,要么在读集中区之前一次性取好。