虚拟列表真正难的不是渲染
聊聊前端虚拟列表里更难处理的部分:尺寸测量、滚动锚点、动态高度和浏览器在重排上的边界。
虚拟列表真正难的不是渲染
很多人第一次做虚拟列表,注意力都会放在“只渲染可见项”上。这个目标本身没错,但它只解决了最表面的性能问题。
真正把虚拟列表做难的,是尺寸不稳定。尤其当行高不是常数,而是会被内容、字体加载、图片、折叠状态、语言切换一起影响时,问题就从“少渲染一点”变成了“你到底准备相信哪一次测量”。
固定高度是幻觉
固定高度列表看起来很稳,因为滚动位置和索引之间是一条简单映射。但现实项目里,固定高度往往只是最开始的假设。
一旦出现这些情况,假设就开始松动:
- 文本换行
- 用户缩放
- 字体回退
- 图片晚到
- 交互展开或折叠
- 不同语言的字符串长度差异
这时候最危险的不是性能,而是列表开始悄悄漂移。你以为自己在显示第 120 行,实际上视觉上已经偏了两个 item。
测量不是读一次 offsetHeight
很多实现会在渲染后读一次 DOM 高度,然后把结果记下来。这个方案能跑,但通常只适合演示。
原因很简单:高度不是静态值。它会在下一帧、下一次字体回流、下一次容器宽度变化后继续变。
如果你把第一次测量当成真值,后面就很容易遇到两类问题:
- 估算的偏移量错误,导致滚动跳动。
- 重新修正位置时,又触发额外布局,形成抖动。
所以成熟实现里,测量本身通常要被当成一个持续过程,而不是一次性动作。
滚动锚点比看起来更敏感
虚拟列表里最难受的体验之一,是用户已经在中间位置浏览,结果上方 item 尺寸变化后,视图突然往上或往下跳了一截。
这不是小问题。对用户来说,它意味着“我刚才看的位置不再可信”。
处理这件事时,关键不是“不要变化”,而是“变化之后,锚点要能守住”。也就是说,当上方 item 高度更新时,你要补偿 scrollTop,维持当前可见区域对应的内容不变。
这听起来像数学题,实际上更像约束管理。你要同时照顾:
- 当前视口
- 视口上方已测量项的累计误差
- 还没测量项的估算高度
- 用户正在主动滚动还是程序在修正位置
少一个状态,这套逻辑就会开始互相打架。
预估值不是越准越好
很多工程师会本能地追求“更准确的预估高度”。但在虚拟列表里,预估值不一定越精确越好,关键是要稳定。
原因在于,预估值如果频繁变化,列表总偏移量也会跟着来回修正。看起来像更聪明,实际上会让滚动体验更碎。
更稳的做法通常是:
- 先给一个保守的估算
- 只在足够确定时更新累计值
- 把修正放在更低频的节奏里
- 避免每个 item 的微小变化都触发全局重算
这不是懒,是为了减少系统振荡。
ResizeObserver 也不是银弹
很多人会把尺寸变化交给 ResizeObserver,这当然有用,但它不是终点。
问题在于,ResizeObserver 只负责告诉你“变了”,并不会替你决定什么时候更新、怎么批处理、如何避免反馈循环。你一旦在回调里直接改布局,下一轮观察就可能继续触发。
所以比较可靠的做法是把它当成信号源,而不是执行器:
- 先收集变化
- 合并同一帧内的更新
- 再统一写入缓存和偏移量
- 必要时延后到下一帧修正 scroll 状态
这类代码最后看起来都不简洁,但那通常是因为它真的在处理边界情况。
虚拟列表的本质是预算
虚拟列表不是在“优化渲染”,而是在分配预算。
你把预算分给了三件事:
- 当前屏幕必须稳定
- 视口外的数据要尽量提前准备
- 尺寸变化时,滚动状态不能失真
这三个目标彼此冲突。你不可能同时做到绝对准确、绝对平滑、绝对省资源。真正成熟的实现,只是在不同场景里选一个更不坏的折中。
所以虚拟列表写到最后,最值钱的不是渲染那部分,而是你怎么处理测量误差、滚动锚点和回流节奏。只要这三件事没收住,列表规模一上来,bug 会比性能更先冒出来。