实验 / 一万条消息的变高虚拟列表 待审核
前缀和 · 二分 · 锚点还原

一万条消息的变高虚拟列表

一万条聊天记录,文本长短不一、图片尺寸各异。虚拟滚动的常见写法假定行高恒定,「第几项在哪」是一次除法;行高不定时这条捷径没了,位置要从一张前缀和表里查。表本身也不是一次算准的:真高要等排版完才量得到,流程只能是估高布局、挂上去、量真高、改表重算。

1 · 前缀和与挂载区间

offsets[i] 存第 ii 项顶端距列表顶端的距离,长度恒为 n+1n + 1,末项即总高。一万项的前缀和重算一遍实测 0.037 毫秒,所以行高一变就整表重算,不做增量修补。增量修补要区分「追加」与「插入」两种改动,而往头部插入时旧表的每一项都对应到了别的消息上,修补的代价与出错的余地都比重算大。

与视口相交的项由二分找到起点、线性走到终点。终点必须线性走:只知道起点后,「还有几项落在视口里」取决于这几项各自多高。

图 1-1 · 一万项的前缀和与二分过程。左条是整份内容按比例压缩,视口只占万分之五,仅以一条标记线示意,红线为每一步探测到的位置;右条放大到视口上下各一段,一条色带即一条消息,两条深线是视口的上下边。可拖动 scrollTop 与 overscan 观察探测次数与挂载数的变化。

挂载节点数只与视口高度有关,与总条数无关:视口 600 像素、平均行高 119 像素,相交项约 6 条,overscan 400 像素再各加三四条。overscan 是上下各多挂一段的余量,缺了它,快速滚动时会先看到空白再等一帧补上。

2 · 二分的边界

要找的是「最后一个满足 offsets[i]y\text{offsets}[i] \le yii」,不是「第一个大于 yy 的」。这个方向的二分用下界收敛写,中点取 (lo + hi + 1) >> 1

let lo = 0, hi = n - 1;
while (lo < hi) {
  const mid = (lo + hi + 1) >> 1;
  if (m.offsets[mid] <= y) lo = mid;
  else hi = mid - 1;
}

中点必须向上取整。写成 (lo + hi) >> 1 时,lo = mid 这一支在 hi === lo + 1 时算出 mid === lo,区间不缩,循环挂死。

三种情形走捷径不进循环:y0y \le 0 给首项,yy \ge 总高给末项而非 nn,空表给 0。末项这一档是有意为之:返回 nn 的话,调用方拿它当渲染起点会渲染出一个空窗。

3 · 量到真高之后

估高与真高的差落在视口上方时,offsets 整体位移,视口里的内容随之位移。补偿的写法是记下锚点项在改表前后的顶端位置,差多少就把 scrollTop 挪多少:

const before = metrics.offsets[anchor];
rebuild();
el.scrollTop += metrics.offsets[anchor] - before;

锚点必须取视口顶端那一项,不能取挂载区间的第一项。后者落在 overscan 里,位于视口上方几百像素处;拿它当锚点,它与视口之间那几项的高度一改,补偿量就漏掉了这段误差。实测每翻一批更早的消息,视口会漂 10 到 22 像素,四批下来累积到肉眼可辨。改用视口顶端那一项后,上方的项怎么改高都不影响画面,同样四批实测漂移为 0。

贴底时不能走锚点补偿,得继续贴底。锚点在视口顶端,它下方那些项一改高,底部就被推走:首屏按估高滚到底、随后一轮测量补偿,实测停在离底 50 像素处,最新那条消息的下半截被切在视口外。改高前先记下是否贴底,贴着就在重算后重新贴回底部。

补偿之后要再触发一次测量:scrollTop 一动,挂载区间就换了一批,新挂上的那几项还没量过。这个循环会收敛,因为每一项只在第一次挂载时改高一次。

警示 · 浏览器自带的 scroll anchoring 在虚拟列表里不生效。它锚定普通文档流中的元素,而虚拟列表的项是绝对定位、且随时被增删的,浏览器找不到稳定的锚。手写锚点还原并非重复实现,而是补它管不到的场景。

4 · 往头部插入更早的消息

滚到接近顶端时取下一批更早的消息,插到数组头部。这会把后面所有项整体推下去,位移量等于新插入那几项的高度之和,而这几项此刻只有估高,位移量本身也就是估的。做法与上一节相同:插入前记下锚点项的屏幕位置,重算后把它还原回去。

图 4-1 · 一万条消息的变高虚拟列表。可向上滚动触发取更早的一批,观察读数里挂载节点数不随已加载条数增长;+1 与 +10 追加新消息,视口贴底时自动跟到最新。

原实现有一处相关的缺陷值得记下来。它的前缀和是增量更新的:已处理项数取自缓存长度,然后从这个下标往后逐项追加。这套算术只对「追加」成立,而取更早的消息走的是「插入头部」:缓存里的旧条目此刻都对应到了别的消息上。它没有当场出错,是因为随后的测量发现高度对不上而触发了整表重建。但锚点还原发生在重建之前,读的是那张错表,往上翻页时视口会先跳一下再被纠回来。

5 · 原生滚动与手写滚动的分界

原实现自己接 wheel 事件、把 scrollTop 存成组件状态、再画一根滚动条。这条路的真实好处只有一个:绕开浏览器对元素高度的上限。代价是键盘翻页、触摸惯性、滚到边界后把滚动交还给外层,全都要重写;嵌在文章里还会吞掉滚轮,读者滑到此处就卡住。

上限是可以量的。Chrome 151 下,元素高度写多大都只到 33554430 像素,滚动容器的 scrollHeight 停在 33554428(实测,核对于 2026-08)。本页一万条消息按估高共 1187125 像素,均高 118.7 像素,按这个均高要 28 万条才撑到上限。本页据此改用原生滚动:外层是普通的 overflow-y 容器,内层撑一个总高的空盒子,消息按 offsets[i] 绝对定位。容器另声明 overscroll-behavior: contain,滚到底不把页面一起带走。

自己滚仍有它的位置:十万量级以上的列表,或需要非线性滚动映射时。判据是条数乘均高有没有逼近那个上限,不是「虚拟列表就该自己接 wheel」。

6 · 数据源的确定性

数据由固定种子的 mulberry32 生成:条数、发送者、文本长度、图片宽高全部可复现,同一台机器上每次打开拿到同一批消息,截图与断言才对得上。原实现的延迟用 Math.random(),此处改成固定的 180 毫秒。

图片与头像原本来自两个外部图床,经开发服务器的代理转发。那两条依赖在离线和构建产物里都不成立,故头像画成首字母色块,图片消息画成标着原始尺寸的渐变占位块,尺寸仍按随机宽高生成,变高列表要的正是这份高度差异。顺带一提,原实现的总条数常量写着 4,而页面标题写着 10,000 messages。

相关链接

  • Element.scrollTop developer.mozilla.org 滚动位置的读写口径; 锚点还原就是往它上面写一个算出来的值。
  • ResizeObserver developer.mozilla.org 容器尺寸变化时重算可见区间; 比监听 window resize 更准。
  • overscroll-behavior developer.mozilla.org 列表滚到边界后不把页面一起带走, 嵌在正文里的滚动容器都该声明。
  • CSS scroll anchoring developer.mozilla.org 浏览器自带的滚动锚定; 它只在普通文档流里生效, 绝对定位的虚拟列表用不上, 故本系列手写锚点还原。
  • Binary search Wikipedia 在前缀和上找「最后一个不超过 y 的项」, 边界写法见本系列 §2。
  • Prefix sum Wikipedia 把「第 i 项顶端在哪」从 O(n) 累加降成一次查表。