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 里测差值这两点。虚拟列表只是把节点数压下去了,它不是这个问题的解——节点数不压,滚动一样可以稳;反之,不做锚定补偿,节点数再少也照样跳。