虚拟列表真正难的不是渲染

聊聊前端虚拟列表里更难处理的部分:尺寸测量、滚动锚点、动态高度和浏览器在重排上的边界。

虚拟列表真正难的不是渲染

很多人第一次做虚拟列表,注意力都会放在“只渲染可见项”上。这个目标本身没错,但它只解决了最表面的性能问题。

真正把虚拟列表做难的,是尺寸不稳定。尤其当行高不是常数,而是会被内容、字体加载、图片、折叠状态、语言切换一起影响时,问题就从“少渲染一点”变成了“你到底准备相信哪一次测量”。

固定高度是幻觉

固定高度列表看起来很稳,因为滚动位置和索引之间是一条简单映射。但现实项目里,固定高度往往只是最开始的假设。

一旦出现这些情况,假设就开始松动:

  • 文本换行
  • 用户缩放
  • 字体回退
  • 图片晚到
  • 交互展开或折叠
  • 不同语言的字符串长度差异

这时候最危险的不是性能,而是列表开始悄悄漂移。你以为自己在显示第 120 行,实际上视觉上已经偏了两个 item。

测量不是读一次 offsetHeight

很多实现会在渲染后读一次 DOM 高度,然后把结果记下来。这个方案能跑,但通常只适合演示。

原因很简单:高度不是静态值。它会在下一帧、下一次字体回流、下一次容器宽度变化后继续变。

如果你把第一次测量当成真值,后面就很容易遇到两类问题:

  1. 估算的偏移量错误,导致滚动跳动。
  2. 重新修正位置时,又触发额外布局,形成抖动。

所以成熟实现里,测量本身通常要被当成一个持续过程,而不是一次性动作。

滚动锚点比看起来更敏感

虚拟列表里最难受的体验之一,是用户已经在中间位置浏览,结果上方 item 尺寸变化后,视图突然往上或往下跳了一截。

这不是小问题。对用户来说,它意味着“我刚才看的位置不再可信”。

处理这件事时,关键不是“不要变化”,而是“变化之后,锚点要能守住”。也就是说,当上方 item 高度更新时,你要补偿 scrollTop,维持当前可见区域对应的内容不变。

这听起来像数学题,实际上更像约束管理。你要同时照顾:

  • 当前视口
  • 视口上方已测量项的累计误差
  • 还没测量项的估算高度
  • 用户正在主动滚动还是程序在修正位置

少一个状态,这套逻辑就会开始互相打架。

预估值不是越准越好

很多工程师会本能地追求“更准确的预估高度”。但在虚拟列表里,预估值不一定越精确越好,关键是要稳定。

原因在于,预估值如果频繁变化,列表总偏移量也会跟着来回修正。看起来像更聪明,实际上会让滚动体验更碎。

更稳的做法通常是:

  • 先给一个保守的估算
  • 只在足够确定时更新累计值
  • 把修正放在更低频的节奏里
  • 避免每个 item 的微小变化都触发全局重算

这不是懒,是为了减少系统振荡。

ResizeObserver 也不是银弹

很多人会把尺寸变化交给 ResizeObserver,这当然有用,但它不是终点。

问题在于,ResizeObserver 只负责告诉你“变了”,并不会替你决定什么时候更新、怎么批处理、如何避免反馈循环。你一旦在回调里直接改布局,下一轮观察就可能继续触发。

所以比较可靠的做法是把它当成信号源,而不是执行器:

  • 先收集变化
  • 合并同一帧内的更新
  • 再统一写入缓存和偏移量
  • 必要时延后到下一帧修正 scroll 状态

这类代码最后看起来都不简洁,但那通常是因为它真的在处理边界情况。

虚拟列表的本质是预算

虚拟列表不是在“优化渲染”,而是在分配预算。

你把预算分给了三件事:

  • 当前屏幕必须稳定
  • 视口外的数据要尽量提前准备
  • 尺寸变化时,滚动状态不能失真

这三个目标彼此冲突。你不可能同时做到绝对准确、绝对平滑、绝对省资源。真正成熟的实现,只是在不同场景里选一个更不坏的折中。

所以虚拟列表写到最后,最值钱的不是渲染那部分,而是你怎么处理测量误差、滚动锚点和回流节奏。只要这三件事没收住,列表规模一上来,bug 会比性能更先冒出来。