2026.08.27
流式消息里最难的不是渲染,是滚动位置
AI 对话流两秒内能长高几百像素。虚拟列表 + 流式追加撞在一起时,用户看到的是一边打字一边往上跳。这篇记录「数据窗口 + DOM 锚点补偿」怎么解。
流式对话有两件事同时发生:内容在不断变高,用户在滚动。这两件事各自都不难,撞在一起就变成了一个经典的锚定问题。
先说结果:5000 条消息的会话,挂载节点 70001 → 1401(−98.0%),且流式输出过程中滚动位置稳定。
为什么普通虚拟列表会跳#
虚拟列表的标准实现是按 scrollTop 反推可见区间:
const start = Math.floor(scrollTop / itemHeight);这在固定高度下成立。但流式消息里,最后一条消息的高度每一帧都在变——正在被写入的那条从 40px 长到 400px,只用了两秒。
一旦总高度变化发生在视口上方,scrollTop 的含义就变了:同样的 scrollTop 现在对应到了不同的内容位置,浏览器于是"跳"一下。
用「数据窗口」的思路可以绕开这个问题:不按 scrollTop 推算,而是显式维护"当前渲染哪一段"。
数据窗口#
窗口是一个 [start, end) 区间加上一个状态:
type Window = {
start: number;
end: number;
/** 是否贴在底部(跟随最新消息) */
pinnedToBottom: boolean;
/** 未固定时,视口顶部那条消息的 id */
anchorId: string | null;
};- 贴底模式:新消息进来,窗口右移,内容向上滚。这是聊天软件 90% 的时间所处的状态。
- 游离模式:用户主动往上滚了,
pinnedToBottom置 false,并记下此刻视口顶部的消息 id 作为锚点。
区分这两种模式,是整个方案的地基。很多实现之所以跳,是因为它没有"用户想停在哪"这个概念——只有 scrollTop。
DOM 锚点补偿#
游离模式下,内容还会继续长(用户上滚了,但 AI 还在输出)。这时候要保证锚点那条消息在屏幕上的位置不动。
做法是绘制前后各测一次它的真实矩形,差值补到滚动位置上:
useLayoutEffect(() => {
const el = anchorRef.current;
if (!el || win.pinnedToBottom) return;
const before = el.getBoundingClientRect().top;
// 走到这里说明 React 已经完成了这一帧的 DOM 提交
const after = el.getBoundingClientRect().top;
const delta = after - before;
if (delta !== 0) {
scrollRef.current!.scrollTop += delta;
}
});关键在 useLayoutEffect:它在浏览器绘制之前同步执行,所以补偿是"用户看不到"的。换成 useEffect 就会先画错一帧再修,肉眼可见地抖。
这里测的是 getBoundingClientRect().top 的差值,而不是绝对高度。差值天然抵消了图片加载、字体切换等一切异步高度变化——不需要事先知道是什么让高度变了。
什么时候该重新贴底#
用户往上滚之后,什么时候算"回到贴底"?两种常见策略都不好:
- 死区固定(比如距底 50px 内算贴底):大屏幕上离底 50px 已经能看到大半屏新内容了,体验割裂。
- 只要有新消息就贴底:用户永远没法往上翻。
我的做法是把判定挂在用户手势上,而不是滚动位置:
function onUserScroll(deltaY: number) {
// 向上滚 = 想看历史 = 解除贴底
if (deltaY < 0) setPinned(false);
// 向下滚到底部附近 = 想回到最新 = 恢复贴底
const el = scrollRef.current!;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 8;
if (deltaY > 0 && atBottom) setPinned(true);
}区别在于:程序自己触发的滚动不参与判定。流式追加会把内容顶上去,那也算一次 scroll 事件,如果不过滤,就会把用户"想往上翻"的意图覆盖掉。给滚动事件打一个 programmatic 标记,只让真实手势进入上面这个函数。
结果#
- 挂载节点:70001 → 1401(5000 条会话)
- 流式输出全程视口位置稳定,无跳变
- 上翻历史时不被打断,回到底部自动恢复跟随
事后回看,真正起作用的是那个状态区分(贴底 / 游离)和 useLayoutEffect 里测差值这两点。虚拟列表只是把节点数压下去了,它不是这个问题的解——节点数不压,滚动一样可以稳;反之,不做锚定补偿,节点数再少也照样跳。