一万条消息的变高虚拟列表
一万条聊天记录,文本长短不一、图片尺寸各异。虚拟滚动的常见写法假定行高恒定,「第几项在哪」是一次除法;行高不定时这条捷径没了,位置要从一张前缀和表里查。表本身也不是一次算准的:真高要等排版完才量得到,流程只能是估高布局、挂上去、量真高、改表重算。
1 · 前缀和与挂载区间
offsets[i] 存第
项顶端距列表顶端的距离,长度恒为
,末项即总高。一万项的前缀和重算一遍实测 0.037 毫秒,所以行高一变就整表重算,不做增量修补。增量修补要区分「追加」与「插入」两种改动,而往头部插入时旧表的每一项都对应到了别的消息上,修补的代价与出错的余地都比重算大。
与视口相交的项由二分找到起点、线性走到终点。终点必须线性走:只知道起点后,「还有几项落在视口里」取决于这几项各自多高。
挂载节点数只与视口高度有关,与总条数无关:视口 600 像素、平均行高 119 像素,相交项约 6 条,overscan 400 像素再各加三四条。overscan 是上下各多挂一段的余量,缺了它,快速滚动时会先看到空白再等一帧补上。
2 · 二分的边界
要找的是「最后一个满足
的
」,不是「第一个大于
的」。这个方向的二分用下界收敛写,中点取 (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,区间不缩,循环挂死。
三种情形走捷径不进循环: 给首项, 总高给末项而非 ,空表给 0。末项这一档是有意为之:返回 的话,调用方拿它当渲染起点会渲染出一个空窗。
3 · 量到真高之后
估高与真高的差落在视口上方时,offsets 整体位移,视口里的内容随之位移。补偿的写法是记下锚点项在改表前后的顶端位置,差多少就把 scrollTop 挪多少:
const before = metrics.offsets[anchor];
rebuild();
el.scrollTop += metrics.offsets[anchor] - before;
锚点必须取视口顶端那一项,不能取挂载区间的第一项。后者落在 overscan 里,位于视口上方几百像素处;拿它当锚点,它与视口之间那几项的高度一改,补偿量就漏掉了这段误差。实测每翻一批更早的消息,视口会漂 10 到 22 像素,四批下来累积到肉眼可辨。改用视口顶端那一项后,上方的项怎么改高都不影响画面,同样四批实测漂移为 0。
贴底时不能走锚点补偿,得继续贴底。锚点在视口顶端,它下方那些项一改高,底部就被推走:首屏按估高滚到底、随后一轮测量补偿,实测停在离底 50 像素处,最新那条消息的下半截被切在视口外。改高前先记下是否贴底,贴着就在重算后重新贴回底部。
补偿之后要再触发一次测量:scrollTop 一动,挂载区间就换了一批,新挂上的那几项还没量过。这个循环会收敛,因为每一项只在第一次挂载时改高一次。
警示 · 浏览器自带的 scroll anchoring 在虚拟列表里不生效。它锚定普通文档流中的元素,而虚拟列表的项是绝对定位、且随时被增删的,浏览器找不到稳定的锚。手写锚点还原并非重复实现,而是补它管不到的场景。
4 · 往头部插入更早的消息
滚到接近顶端时取下一批更早的消息,插到数组头部。这会把后面所有项整体推下去,位移量等于新插入那几项的高度之和,而这几项此刻只有估高,位移量本身也就是估的。做法与上一节相同:插入前记下锚点项的屏幕位置,重算后把它还原回去。
原实现有一处相关的缺陷值得记下来。它的前缀和是增量更新的:已处理项数取自缓存长度,然后从这个下标往后逐项追加。这套算术只对「追加」成立,而取更早的消息走的是「插入头部」:缓存里的旧条目此刻都对应到了别的消息上。它没有当场出错,是因为随后的测量发现高度对不上而触发了整表重建。但锚点还原发生在重建之前,读的是那张错表,往上翻页时视口会先跳一下再被纠回来。
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) 累加降成一次查表。