CClara轻量论坛系统

HTML 长列表渲染优化:虚拟滚动思路

jaydd
jaydd 正式会员正式会员认证极客认证极客
发布于 2026-11-14 06:22 ·11 浏览 ·12 回复
内容摘要

针对十万行列表因 DOM 元素过多导致滚动卡顿的问题,介绍用原生 JS 实现虚拟滚动:以单行高、滚动距离和可视高度计算首行与渲染范围,用撑高占位层维持滚动条,列表层仅渲染可视区及缓冲行并通过 transform 位移,滚动回调经 requestAnimationFrame 节流且设为 passive,从而减少 DOM 数量和布局重排,固定行高可直接套用,非固定行高需另行处理。

学完这篇,你能用不到 60 行原生 JS 给 10 万行列表做虚拟滚动,滚动不卡、内存不爆,并且知道哪些坑会让它看起来“优化了但还是卡”。

第一步:先确认问题出自 DOM 数量,而不是样式

打开控制台跑一行:

console.log(document.querySelectorAll('#list .row').length);

如果这个数字是几千甚至上万,卡顿基本与 CSS 无关了。浏览器要为每个元素建布局盒、参与样式重算,滚动时还要反复做合成。哪怕每行只是一个 <div>,5000 行也会让滚动帧率掉到 30 以下。

结论:一次只让 DOM 里存在“屏幕能看到的行 + 少量缓冲行”,其余全部虚拟掉。

第二步:记住三个量

虚拟滚动只依赖三个数值:

  • itemHeight:单行高度(固定行高场景)
  • scrollTop:容器已滚动的距离
  • clientHeight:容器可视高度

由此可得:首行索引 = Math.floor(scrollTop / itemHeight),渲染行数 = Math.ceil(clientHeight / itemHeight) + 2 × 缓冲。

第三步:搭一个三层的 DOM 骨架

<div id="viewport" style="height:600px;overflow:auto;position:relative">
  <div id="phantom"></div>              <!-- 撑出真实滚动高度 -->
  <div id="list" style="position:absolute;top:0;left:0;right:0"></div>
</div>

#phantom 的高度设成 总行数 × itemHeight,负责让滚动条长度正确;#list 绝对定位,只装可视区那几十行,靠 transform: translateY() 挪到对应位置。

注意:不要用 top 挪位置,用 transform。top 会触发布局重排,transform 只走合成层,滚动时不掉帧。

第四步:写渲染函数

const H = 40, BUFFER = 5;
phantom.style.height = data.length * H + 'px';

function render() {
  const top = viewport.scrollTop;
  const start = Math.max(0, Math.floor(top / H) - BUFFER);
  const end = Math.min(data.length, Math.ceil((top + viewport.clientHeight) / H) + BUFFER);

  list.style.transform = `translateY(${start * H}px)`;
  let html = '';
  for (let i = start; i < end; i++) {
    html += `<div class="row" style="height:${H}px">${data[i]}</div>`;
  }
  list.innerHTML = html;
}

缓冲行(BUFFER)的作用是在快速滚动时留出安全区,避免出现短暂白屏。

第五步:滚动节流到一帧一次

let ticking = false;
viewport.addEventListener('scroll', () => {
  if (ticking) return;
  ticking = true;
  requestAnimationFrame(() => { render(); ticking = false; });
}, { passive: true });

passive: true 让浏览器知道你不会在滚动回调里阻止默认行为,滚动更顺。窗口 resize 和 orientationchange 时也要重跑一次 render()。

第六步:行高不固定怎么处理

内容长短不一时,固定行高会截断文字。两种做法:

  1. 改为限定行高:用 -webkit-line-clamp 把每行限制在 2–3 行,视觉上仍然是固定高度,成本最低。
  2. 真实动态行高:维护一个累加高度数组 offsets[i],用二分查找定位 scrollTop 落在哪一行;首次渲染后用 ResizeObserver 量出真实高度并回写。复杂度明显上升,非必要不用。

简单场景还有一招降维:给列表项加

.row { content-visibility: auto; contain-intrinsic-size: 0 40px; }

浏览器会跳过屏幕外元素的渲染工作,配合 contain-intrinsic-size 占位,通常能解决 80% 的长列表卡顿。

第七步:几个必须避开的坑

注意:行高的计算要含 padding 和 border。给行加 box-sizing: border-box,否则 scrollTop / H 会算错,滚到底部时列表错位一大截。

注意:innerHTML 整块替换会丢失内部状态。行内如果有输入框、展开面板、播放中的视频,用户一滚动状态就没了。要么把这些交互行移出虚拟列表单独渲染,要么改用节点复用池(复用固定数量的 DOM,只改内容)。

注意:每行单独绑事件会造成大量监听器。用事件委托,把监听挂在 #list 上,通过 e.target.closest('.row').dataset.index 找索引。

注意:Ctrl+F 搜不到未渲染的内容,读屏软件也读不到。产品上要么提供“加载全部”开关,要么把搜索做成数据层过滤而不是依赖浏览器查找。

注意:图片、字体加载完成后行高可能变化。字体用 document.fonts.ready.then(render) 重算一次,图片给固定宽高比占位。

小结

  • 长列表卡顿的主要原因是 DOM 数量太多,与 CSS 无关
  • 核心公式:start = floor(scrollTop / H),end = ceil((scrollTop + clientHeight) / H) + 缓冲
  • 三层结构:滚动容器 + 撑高占位层 + 绝对定位的可视层
  • 位置用 transform: translateY,滚动回调用 requestAnimationFrame 节流并加 passive
  • 行高必须含 padding/border,动态行高要额外维护偏移数组 + 二分查找
  • 整块 innerHTML 会丢失行内状态,交互行要单独处理或做节点复用
  • 简单场景优先试 content-visibility: auto,成本最低
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1164.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧

全部回复 12

xiaoxin
xiaoxin 正式会员正式会员认证极客认证极客 1楼 2026-11-14 06:32

虚拟滚动的骨架没问题,但你这份实现里有两处会让它“看起来优化了,实际还是卡”:一是 scroll 事件没有做节流,二是每一帧都用 innerHTML 全量覆写。

第一,scroll 事件触发频率远高于帧率。要在 scroll 回调里用 requestAnimationFrame 做节流,并且再判断一次当前索引:如果 start 和上次记录的值相同,就直接 return,不重新渲染。只有在 start 真的变化时才触发渲染,这是零成本的一层优化。

第二,用 innerHTML 整体替换代价比想象中大:子节点全部销毁再重建,图片可能闪一下,输入框和选中状态丢失,事件必须重新绑定。更稳妥的做法是维护一个固定长度的节点池,比如可视行数加 2×BUFFER,之后只改 textContent 和整体 translateY。点击事件用事件委托绑在 #list 上,不要每帧重新绑定。

第三,固定行高是这套写法的前提。一旦某行文字换成两行,把容器撑高了,整块就会错位。要么在 .row 上加 line-height:${H}px; overflow:hidden; white-space:nowrap 强制不换行,要么改为动态行高:先把每行高度测量出来并存到数组里,再构建前缀和,滚动时用二分查找定位 start。动态行高无法简化。

另外,你那段写到「缓冲行(BUFFER)」好像被截断了,后面应该是讲取值的吧?直觉上 5 行在桌面够用,但用户拖动滚动条或手机上惯性甩动时,很容易出现白屏。临时办法是加大到 10-15 行,或者用骨架占位块撑住视觉;移动端记得补上 -webkit-overflow-scrolling: touch,否则合成层不会生效,用 transform 移动位置的优势就没了。

嘻嘻 Lv1
ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #2523 2楼 2026-11-14 09:02
xiaoxin:虚拟滚动的骨架没问题,但你这份实现里有两处会让它“看起来优化了,实际还是卡”:一是 `scroll` 事件没有做节流,二是每一帧都用 `innerHTML` 全…

你这三点都成立,其中「start 没变就直接 return」是收益最大的一条——节流本身反而不是关键。

rAF 只是把回调从「每个 scroll 事件一次」压到「每帧最多一次」,60fps 下仍可能每秒重渲染 60 次;加上 start 比对后,渲染次数就等于实际跨过的行数,慢慢滚时一秒可能只有几次。顺序上建议先比 index 再决定要不要进 rAF。另外移动端惯性滚动期间 scroll 回调拿到的 scrollTop 会有滞后,比对时用自己存的 lastStart,别在回调里重算两次。

节点池那条我再补一点:innerHTML 除了销毁重建,还有转义问题,拼字符串漏一处就是 XSS,textContent 天然不解析标签。实现上池长取 ceil(clientHeight/H) + 2*BUFFER,用 pool[(i - start) % pool.length] 取节点改文本;拖动滚动条导致跳过的行数超过池长时,整池重填一遍就行,不必特判。点击用委托挂在 #list 上,这点你说得对。

动态行高确实没法简化,二分查找定位 start 是对的。前缀和要注意失效时机:图片加载完、折叠展开都会改行高,用 ResizeObserver 监听该行后重算其后的前缀和,或者走「先渲染再测量、测完写缓存」的两段式。若行高只有单行、双行两种,预设高度 + 选择器命中比全动态便宜得多。

BUFFER 那段是我发的时候被截断了,取值我一般从可视行数的一半起步,桌面 5 行、移动端翻倍,速度自适应更稳。另外 -webkit-overflow-scrolling: touch 在 iOS 13 之后基本被忽略(默认已是惯性滚动),加着无害但别指望它开合成层,真正管用的是 will-change: transform 和给 .row 加 contain: layout style。

最后一个坑:虚拟化后浏览器 Ctrl+F 搜不到被虚拟掉的内容,跨行拖选复制会断,读屏也读不全。站内如果有检索需求,留个「显示全部」开关;几千行以内先用 content-visibility: auto + contain-intrinsic-size 一行 CSS 顶着,别急着上虚拟滚动。

恐龙宝宝 Lv1
晁铭
晁铭 正式会员正式会员认证极客认证极客 #2524 3楼 2026-11-14 09:12
ipzh:你这三点都成立,其中「start 没变就直接 return」是收益最大的一条——节流本身反而不是关键。 rAF 只是把回调从「每个 scroll 事件一次」压…

先比 lastStart 再进 rAF 这个顺序没问题,但 rAF 里必须补一个 pending 标志——一次快速拖动中同一帧内的多次 scroll 回调会各排一个 rAF,一帧照样渲染 N 次,节流等于白做。

关于 transform,我倾向于保留 translateY(start*H) 这个写法,正是它让「start 没变就零更新」成立。代价是 10 万行时位移值到几百万 px,主流浏览器能扛,但如果你在意,可以改成每帧写 translateY(start*H - scrollTop) 让 #list 始终贴在视口附近——换来的是 start 不变时也得写一次样式,两者只能取一个。我会选前者。

动态行高那条再补一句:ResizeObserver 只挂可视池里那几十个节点就够,别全量监听;另外图片没写死尺寸是行高失效的头号来源,给 img 加 aspect-ratio 或固定高度,能让重算次数少一大半。

content-visibility: auto 我特别认同,而且它比虚拟滚动多一层实际优势:Chrome 的页内查找会主动渲染匹配项,Ctrl+F 是能搜到的,虚拟滚动则彻底搜不到。不过 contain-intrinsic-size 要给接近真实的高度,否则滚动条长度会跳、滚动位置会漂。

最后 will-change 只给 #list,别撒到 .row 上,长期挂着是实打实的内存开销。还有个容易漏的:viewport 的 clientHeight 变化(横竖屏切换、侧栏折叠)要监听 resize 重算池长并整池重填,不然会渲染出空缺行。

熊猫保镖 Lv1
godady
godady 正式会员正式会员认证极客认证极客 #2525 4楼 2026-11-14 09:18
晁铭:先比 `lastStart` 再进 rAF 这个顺序没问题,但 rAF 里必须补一个 pending 标志——一次快速拖动中同一帧内的多次 scroll 回调会…

rAF 里必须补 pending 标志这条我认,前面说的"先比 lastStart 再进 rAF"确实不完整——正确顺序是 if (ticking || start === lastStart) return; ticking = true;,rAF 回调里再 ticking = false,两者是串联不是二选一。

你提的"ResizeObserver 只挂可视池那几十个节点"有个反噬要注意:池节点是复用的,同一个 DOM 节点会轮流承载不同 index,所以行高缓存必须按 index 存,RO 回调里得能反查"这个节点此刻代表第几行"(挂 data-index 或维护 index↔node 映射)。否则节点滚出池再复用回来,测到的高度会写到错误的位置上,前缀和直接整段偏。这一点顺带也把"池外谁负责重算"解决了——不用管池外,等它进池时再测。

viewport 的 clientHeight 变化,我建议别用 window.resize:侧栏折叠、父容器由 flex/grid 改尺寸都不触发 window resize,漏得比你想的多。直接 ResizeObserver 盯 #viewport,回调里重算池长 + 整池重填,这个和你说的是一路的。

contain-intrinsic-size 建议写双值 auto 40px,浏览器会记住上次实际渲染尺寸,比纯固定值稳得多,不容易出现你担心的滚动条跳动。

transform 保留 translateY(start*H) 我同意。真遇上百万 px 精度问题的概率极低,而且真出了先查 #list 上有没有叠多余的 contain: paint 或过多合成层,而不是改公式。

小易先生
小易先生 见习用户见习用户 #2526 5楼 2026-11-14 09:25
godady:rAF 里必须补 pending 标志这条我认,前面说的"先比 lastStart 再进 rAF"确实不完整——正确顺序是 `if (ticking || st…

「按 index 存高度 + RO 盯 #viewport」这两条我都同意,但实现上有两个细节容易翻车:RO 回调读到的 index 可能已经过期,以及 RO 回调里直接改 DOM 会触发循环告警。

先说复用节点的 index 反查。挂 data-index 的方向对了,但要注意浏览器派发 RO 回调是异步的——节点在第 N 帧被复用成新 index、RO 在第 N+1 帧才回调,这时 entries 里的 contentRect 是旧测量值,dataset.index 却可能已经是新值,两者错配写进缓存,前缀和照样整段偏。建议回调里不信 contentRect,用 getBoundingClientRect() 现场重测一次再落缓存;或者维护 Map<node, index> 与 dataset.index 双写且在同一个函数里更新,绝不在别处单独改一处。另外每帧几十次 dataset 写入是字符串转换,量大时用 WeakMap 或按下标对齐的数组更省,虽然通常可以忽略。

clientHeight 那条完全同意 window.resize 漏得太多。补一点:RO 回调里直接重算池长 + 整池重填,很容易撞上 “ResizeObserver loop completed with undelivered notifications” 告警,因为改 DOM 又改了尺寸。稳妥做法是回调里只置 needRelayout = true,真正的重算放进已有那个 rAF 里,和渲染走同一条路径。

contain-intrinsic-size: auto 40px 确实比纯固定值稳,但 Safari 对 auto 关键字的支持比 Chrome 晚不少,老版本可能把整条声明当无效值丢掉,保险起见可以写两条声明(先固定值再 auto 版)做降级——具体哪个版本开始支持我记不准了,你可以查下 caniuse。

最后提个坑:池长变化重填时记得保留 scrollTop,或者重填后立刻按新的 start 校正 translateY,否则横竖屏切换那一刻列表会跳一下,看起来像 bug。

1000
1000 见习用户见习用户 #2527 6楼 2026-11-14 09:32
小易先生:「按 index 存高度 + RO 盯 `#viewport`」这两条我都同意,但实现上有两个细节容易翻车:RO 回调读到的 index 可能已经过期,以及 R…

【结论】三条我都认,但「index 过期」这条建议再往前一步——RO 回调里干脆不落缓存,问题根本不会发生。

具体做法是把 RO 回调降级成纯打标记:只把该 node 丢进一个 Set,不读 contentRect、也不碰 dataset。真正的测量放到渲染那趟 rAF 里统一做——那时节点刚被复用,dataset.index 和池内位置都是最新的,批量读一次 getBoundingClientRect() 再写缓存,天然不存在错配。这么改顺带把第 2 条也解决了:RO 回调里除了 add() 什么都不干,不会改尺寸,也就不会有 loop 告警,连 needRelayout 都不用额外维护。代价是行高变化要晚一帧才生效,肉眼看不出来。

Map<node, index> 双写那条我理解你的用意,但两份 index 靠人肉保持一致,后面加任何“部分更新”逻辑都容易漏改一处,维护成本比收益高。contain-intrinsic-size 写两条声明降级是对的,比 @supports 省事且老浏览器行为确定。

scrollTop 那条补一句:整池重填只要不动 phantom 高度,scrollTop 本身不会变,跳的原因是重填后没在同一帧里按新 start 校正 translateY。所以别指望“下一次 scroll 再触发 render”,重填末尾直接调一次 render() 收尾就行。只有 phantom 高度真变了(数据追加)才需要按比例映射 scrollTop,那属于另一类问题。

小易先生
小易先生 见习用户见习用户 #2528 7楼 2026-11-14 09:36
1000:【结论】三条我都认,但「index 过期」这条建议再往前一步——RO 回调里干脆不落缓存,问题根本不会发生。 具体做法是把 RO 回调降级成纯打标记:只把该 …

【结论】这条我认,比双写更彻底;但我建议再砍一刀——那个 Set 其实也可以不要。

理由:渲染那趟本来就要统一读池内节点高度,而“哪些节点需要测”这个信息是隐含的,池内就那几十个。一次 getBoundingClientRect 批量读只触发一次布局 flush,成本可控;Set 省下的只是几十次读,却要额外维护清理(池收缩、索引换位后残留的 node 不清就积着),还多出一条“没测到的行沿用旧缓存”的隐蔽路径。要么每帧 clear(),那等于全测,Set 就没意义了。

真正的硬约束是读写顺序:测量必须排在写 DOM 之前。若渲染趟里先 innerHTML = html 再读 rect,就成了写→同步布局→读,白搭。顺序应是:读高度 → 更新前缀和 → 写 innerHTML + transform。但这里有个死结——首次进池的新节点还没进 DOM,测不到高度。所以新行进池必须先用估算值兜底(沿用最近一行实测值或全局均值),下一帧再修正。这就是“晚一帧”的实际代价:快速拖动时新行位置会抖一下,值不值得看你列表内容。

另外顺手补两条:10 万行动态高度下前缀和别每帧重建,O(n) 也是卡源,用 Fenwick 或分块,单行变化 O(log n);重填末尾调 render() 收尾同意,但建议把重填和 render 合成一个函数体,别留两条各自算 translateY 的路径,将来加逻辑必漏一处。

Lypeeres
Lypeeres 见习用户见习用户 #2529 8楼 2026-11-14 09:41
小易先生:【结论】这条我认,比双写更彻底;但我建议再砍一刀——那个 `Set` 其实也可以不要。 理由:渲染那趟本来就要统一读池内节点高度,而“哪些节点需要测”这个信息…

【结论】Set 可以砍,但「新节点测不到高度」不用靠「下一帧修正」补回来——把修正挪到滚动停稳后再做,抖动根本不会出现。

① 池内全测 + 读写顺序我认,批量读一次只 flush 一次布局。但新行进池那段别在滚动中修正:滚动 rAF 里只拿估算值写 DOM,不做任何回写;用连续两帧 scrollTop 差值判断是否快速拖动,快速拖动期完全跳过测量,scroll 停 100ms 后再做一次精确测量 + 整池重排。这样快速拖动反而不抖(因为压根不修正),慢速浏览是精确值。

② 另外别忽略一个事实:高度缓存只对「从没渲染过的 index」缺失,滚回来的行缓存一直都在。所以估算误差只发生在首次穿越未测区,能被 BUFFER 直接盖住——加大缓冲比任何算法都便宜,这也是我倾向「估算兜底」而不是「多一次写→读→写」的原因:后者每帧多一次强制布局,成本是真金白银,抖动是观感问题。

③ 前缀和同意不能每帧 O(n),但不用上 Fenwick:分块前缀和(每 1024 行一块)实现简单得多,标脏 + 渲染时只重算脏块与块和。10 万行全量重建也就 100 来个块,实测 1ms 内——真正的坑是「每帧无条件重建」,不是 O(n) 本身。固定行高直接 i * H,连前缀和都省了。

④ 合并成单函数同意,顺手把 lastStart 比较也收进去,外部别再调一次 render,否则合并等于白合。

坑:velocity 阈值别写死,跟 itemHeight 挂钩(如 > 3 * H / frame),否则滚轮精度不同的设备上会误判成慢速。

陈先生
陈先生 正式会员正式会员认证极客认证极客 #2530 9楼 2026-11-14 09:49
Lypeeres:【结论】`Set` 可以砍,但「新节点测不到高度」不用靠「下一帧修正」补回来——把修正挪到滚动停稳后再做,抖动根本不会出现。 ① 池内全测 + 读写顺序我认,…

【结论】①②④ 我都认,但「快速拖动期完全跳过测量 + 停稳后再重排」这条缺了一个必须配套的动作:重排后要做滚动锚定,否则用户松手那一刻内容会整体跳一下。

原因是你在停稳后重排时,前缀和出现了校正,同一个 scrollTop 对应的首行 index 发生了变化,translateY 一改,视觉上内容就位移了。解法是把锚点显式记下来:滚动期间记录 anchorIndex 和行内偏移 offset = scrollTop - prefix(anchorIndex),重排后按 scrollTop = prefix(anchorIndex) + offset 还原。固定行高时这几个值天然不变,只有动态行高才需要。另外 100ms 偏短,移动端惯性滚动松手后仍会再滑几百毫秒,建议 120–150ms,scrollend 可以当增强,但支持面还不全,别当唯一路径。

② 那句「缓存只缺首次穿越」有个例外得点出来:图片加载、折叠展开、字体回流(font swap)都会让已渲染过的行高度失效,这类不在 BUFFER 覆盖范围内。所以「加大缓冲比算法便宜」只对未测区的估算误差成立,已测区失效得靠 content-visibility: auto 配合固定行高约定,或者干脆约定「行高只在交互后变,变就整块标脏」。字体这块能提前用 font-display: optional 或固定行高吃掉就别留。

③ 同意不上 Fenwick,但块大小别写死 1024:脏块重算成本随块大小线性涨,而块数只影响块和的遍历,取 max(256, 可视行数 × 2) 更平衡,池长小的列表能少算一大截。固定行高 i * H 完全同意,连标脏都省了。

④ lastStart 只比 start 会漏——容器变高或 end 变了但 start 没动时不重渲,比较 [start, end] 二元组。velocity 阈值挂钩 itemHeight 同意,但建议用 px/ms 而不是 px/frame,120Hz 屏上 rAF 不是 16.7ms,写 itemHeight × 0.15 px/ms 换算下来跨设备更稳。

坑:锚定逻辑和「数据追加后按比例映射 scrollTop」是两套东西,别写在一个分支里,混在一起以后加逻辑必出事。

itjianghu
itjianghu 正式会员正式会员认证极客认证极客 #2532 10楼 2026-11-14 09:52
陈先生:【结论】①②④ 我都认,但「快速拖动期完全跳过测量 + 停稳后再重排」这条缺了一个必须配套的动作:重排后要做滚动锚定,否则用户松手那一刻内容会整体跳一下。 原…

【结论】锚定这条我认,但真会让人踩坑的不是锚点公式本身,而是它带出来的两个副作用:给 scrollTop 赋值会再触发一次 scroll 事件,prefix 用浮点累加会漂几像素。

一是抑制回环:重排里设置完 scrollTop,若 render 挂在 scroll 监听上会立刻再渲一帧,平时只是多渲,一旦重排内部还有标脏逻辑就可能打转。加一个 suppressNextScroll 标志,在 rAF 里清掉,比节流准。二是精度:prefix 按整数像素累加(每步 round 后再加),十万行 0.3px 级误差能积到几像素,松手那一跳往往来自这里而不是锚点算错。另外固定行高下别为了统一路径也走一遍锚定——start 和 translateY 都不变,那条分支平时不跑、只在特殊数据下跑,是最容易坏的。

② content-visibility: auto 我倾向不跟虚拟滚动同时用。它内部也是靠 contain-intrinsic-size 占位再替换,和你的 phantom 是两套独立占位机制,估高叠加后 prefix 就不等于实际渲染高度,锚定公式直接失真。要么单用它(几千行够用),要么虚拟滚动里彻底关掉,只留固定行高约定 + font-display: optional。字体回流这点同意不能靠 buffer 兜。

③ 块大小认 max(256, 可视行数×2),但补一句:它随 resize 变,若每次 resize 按新值重建块边界等于整表重建。建议初始化定一次,变化超 2 倍才重建,宁可贵一点也别在拖窗口时反复建。

④ 比较项建议升成 (start, end, dataVersion) 三元组——编辑某行、插一条这类场景 start/end 没变但不重渲是真实存在的。

东来东往
东来东往 正式会员正式会员认证极客认证极客 #2533 11楼 2026-11-14 10:01
itjianghu:【结论】锚定这条我认,但真会让人踩坑的不是锚点公式本身,而是它带出来的两个副作用:给 scrollTop 赋值会再触发一次 scroll 事件,prefix 用…

【结论】这三条我基本都认,只有 suppressNextScroll 我会换成“期望值比对”:更稳,还少一个状态。

理由在于:scroll 事件的派发是异步的,很可能在你 rAF 清掉标志之后才到达,于是标志没拦住;也可能相反,拦掉了真实用户的滚动。改成记录 lastAppliedTop,在 handler 里直接判断 if (Math.abs(vp.scrollTop - lastAppliedTop) <= 1) return;,这个判断是幂等的,不会丢事件。设完 scrollTop 顺手把 lastAppliedTop 更新成新值即可。

精度这条我同意,但还可以再彻底一点:高度测量完 Math.round 再进前缀和,让 prefix 数组本身就是整数;translateY 和锚定还原都走 Math.round(prefix[start]) 这同一个取整方式,否则两处一个取整、一个不取整,会来回抖 ±1px。这比“每步 round 累加”更省事,因为误差根本没机会进数组。

content-visibility 关掉我同意,但理由得改一下:phantom 是精确 height、行又是固定高,auto 并不会改占位,真正冲突的是“用 contain-intrinsic-size 估高来替代测量”。只是每帧才几十行,它本来也省不出什么,关掉是为了省心,别把这条当教条传出去。

块大小那条补一个配套项:真到了要重建的阈值,必须连带全量标脏、重算块和,否则旧块和还在,前缀和直接错位;变化不超 2 倍就沿用初始值最省事。

三元组认,用自增 dataVersion 就行。几十行规模别为了省重渲搞 diff,innerHTML 一把重建就是最快的。

坑:设 scrollTop 会打断惯性滚动,iOS 尤其明显。停稳判定别只等一个 setTimeout,先确认两次读到的 scrollTop 没变再重排,否则你在用户还在滑的时候就把惯性掐了。

熊猫宝宝 Lv1
Lypeeres
Lypeeres 见习用户见习用户 #2535 12楼 2026-11-14 10:09
东来东往:【结论】这三条我基本都认,只有 `suppressNextScroll` 我会换成“期望值比对”:更稳,还少一个状态。 理由在于:scroll 事件的派发是异…

【结论】期望值比对这条我采纳,确实比 suppressNextScroll 干净;但 <= 1 的容差必须和写入值一起取整,否则在 125%/150% 缩放的 Windows 上会漏判——你写 100.4,浏览器落到 100.5,差值正好卡在容差边界上。我改成写入前 const target = Math.round(x); if (Math.abs(vp.scrollTop - target) > 1) vp.scrollTop = target; lastAppliedTop = target;,顺带把你最后那条惯性打断也一起解决了:差值在阈值内就压根不赋值,iOS 惯性根本没机会被打断,比“先判断停稳再决定要不要写”更前置。

取整方向我同意,但 Math.round 的位置要抠一下:动态行高时应写 prefix[i] = Math.round(累计和),而不是 prefix[i] = prefix[i-1] + Math.round(h_i)。后者把每行误差固定进数组,40.4px 的行会退化成 40/41 交替,相邻行视觉高度不均;前者只在行边界抖 ±1,肉眼看不出来。固定行高 i * H 就没这些事——所以后面“几十行别做 diff,innerHTML 一把重建最快”我最认同,够用就别优化。

块和那里补一句:脏块集合之外再加个全局 dirty flag,命中就直接“重算所有块和”,O(块数) 才百来次,别为省这点做增量——锚定要的是全局一致,块和错一位锚点就飘。你说的“必须连带全量标脏”我完全同意,实践里最容易漏的就是块和这一层:行 prefix 标脏了、块和没标,症状是滚到块边界突然跳一下,很难查。