流式输出与贴底跟随
AI 回答是逐 token 流入的:同一条消息的高度在几秒内会变几十次。 这一节讲的是在这种持续变化中,视口该怎么动、以及什么时候不该动。
对照着试
发送一个问题看跟随;生成中向上滚,状态行会变成「已脱离跟随」; 再手动滚回底部,跟随恢复。这三个动作正是下面表格里的三行。
跟随的三个分支
一个可用的 AI 对话列表,需要同时满足这三条。它们看起来是三种行为, 实际由同一个机制给出:
| 用户此刻在做什么 | 期望行为 |
|---|---|
| 贴在底部看最新回答 | 内容变高时视口跟着走,始终看到最新的一行 |
| 已经向上滚去读历史 | 下面无论长出多少内容,视口都不能被拽走 |
| 手动把滚动条拖回底部 | 重新进入跟随,后续生成继续跟着走 |
第三条最容易被漏掉。少了它,用户会发现「我明明已经滚到底了,新内容还是会溢出视口」 ——很多聊天界面在流式输出下体感不稳,原因就在这里。
开启方式只有一个配置:
{
stickyBottom: true,
initialPosition: 'bottom', // 首屏直接落在最新消息
hasMoreBottom: false, // 对话没有「更新的消息」要拉,只有历史
}stickyBottom 同时管三件事:追加消息时仅在原本贴底时才跟随、 用户手动滚到底后重新进入跟随、以及跟随期间内容变高时跟着内容走。
解除跟随不需要任何配置或状态位:用户一向上滚,维持目标就被换成 「锚定当前顶部那一项」,跟随随之自然解除。
逐 token 更新:不要重建列表
流式输出改变的是已有项的内容,不是列表的结构。所以正确的更新只有一处: 那条消息的文本节点。
- 不需要为每个 token 重新设置整个列表;
- 不需要通知组件「高度变了」——尺寸由
ResizeObserver量到并自动上报, 跟随在那之后自动完成。
数据里也要同步写入全文,原因是项滚出视口会被回收,滚回来时要按数据重新渲染。 把「数据是全文,DOM 是就地更新」这两件事分开,离屏期间生成的内容就不会丢。
富消息:块内追加与插入新块是两回事
真实的一条回答不是一段文本,而是若干块拼起来的:思考过程、工具调用卡片、正文、 代码块、来源引用。它们让同一项的高度以三种不同的方式变化——
| 变化 | 高度怎么变 | 你这一侧要做的 |
|---|---|---|
| 往当前块里追加文本 | 连续增长,一帧几像素 | 只改那一个块的一个属性 |
| 插入一个新块(工具卡、代码块) | 一次跳变,几十到几百像素 | 往 blocks 里 push 一个块 |
| 工具卡从「执行中」落到终态 | 卡片内容变化带来的小幅跳变 | 改那张卡的 status |
| 图表 / 图片加载完成 | 一次跳变,时机不由你决定 | 占位换成真实内容 |
对跟随来说这四者没有区别:尺寸变化都由 ResizeObserver 报进来,维持目标重新 求解一次即可,连续增长和跳变走的是同一条路径。区别在开销:第一种每秒发生 几十次,必须只改一个属性;后三种一次回答里只发生几次,多做点事也无所谓。
把两者分开写,流式的成本就恒定在「一个文本节点」上,与这条消息里有多少块无关。
最后一行值得单独说:它是唯一不由你的代码决定时机的变化。图片下载完、图表数据 算完,都可能落在任何一帧。这类场景下「先测量再补偿」的实现最容易失稳,而把维持 目标交给一次重新求解则不需要区分变化的来源——组件根本不需要知道这次变高是谁引起的。
多条回答同时生成
真实 agent 界面允许你在生成过程中追问,于是同一帧里可能有两三项都在变高。这件事 本身对跟随没有新要求,但它会打破一个很容易写进代码的隐含前提:「正在变化的那一项 在末尾」。一旦不成立,用 id 去列表里找那条消息就是 O(历史条数 × 并发数)。
做法是让每个「生成任务」直接持有那条消息的引用(Vue 下是 reactive 对象,React 下是 消息 id 加一份独立的流式状态),与它此刻排在第几位无关。
项内的 UI 状态必须存在数据里
工具卡默认折叠、思考过程默认收起、消息可以点赞——这些都是项内的界面状态, 它们必须写在列表数据里,不能放在渲染项的组件内部。
原因是回收:项滚出视口后组件实例被销毁,连同它的局部状态一起消失, 滚回来时按数据重新渲染。存在数据里的展开状态不会丢,存在组件里的会—— 表现是「我展开的那张卡,滚一圈回来又合上了」。
合并高频更新
真实的 SSE 每帧可能只给几个字符,一秒能来上百次。若每个 token 都触发一次框架层的 重渲染,开销会集中在框架的 diff(React 要重建数组、Vue 要跑一轮 patch)上, 而列表结构压根没变。
把一帧内到达的片段合并成一次更新即可,视觉上没有区别——屏幕本来也只有一帧刷新一次。 组件包里带了这个工具:
import { createStreamBuffer } from '@virt-list/vue'; // 各框架包均已透出
const buffer = createStreamBuffer((chunk) => {
// 每帧至多来一次,chunk 是这一帧合并后的增量
appendToMessage(chunk);
});
// SSE 回调里只管往里塞
source.onmessage = (e) => buffer.push(e.data);
// 流结束时务必冲刷一次:最后一批片段可能还压在缓冲里等下一帧,
// 而生成已经停了,那一帧永远不会来
source.onend = () => buffer.flush();
// 用户中断生成 / 组件卸载
onUnmounted(() => buffer.cancel());回调拿到的是增量而不是累积的全文:全文仍由你自己往数据里累加—— 那份数据是项被回收后重新渲染的依据,不该有第二份副本。
为什么不需要「正在流式」这个标记
一种常见的设计是给组件传一个 streamingIndex,让它对那一项特殊处理。 这里不需要:尺寸变化是从 ResizeObserver 进来的事实, 组件不必知道这个变化的来源是流式输出、图片加载完成,还是用户点了「展开」。
少一个需要和业务状态保持同步的配置,就少一处会失同步的地方。
离屏的流式项
如果用户在生成过程中向上滚得足够远,正在生成的那条消息会滚出渲染区间、 DOM 被回收,它的尺寸随之停在最后一次测量值上——此时列表总高度是偏小的。
这不会造成可见问题:
- 偏差发生在视口之外,用户看不到;
- 用户回到底部的动作(点「回到底部」或滚到底)走的是「贴到内容末端」这个 动态目标,它会在项重新渲染、量到真实尺寸后自动收敛到准确位置。
换句话说,离屏期间的高度偏差会在回到底部的那一刻被修正掉,不需要额外处理。
复制整段回答
虚拟滚动只保留视口附近的 DOM,用户拖选跨越未渲染区域时,浏览器复制到的内容 会缺失中间那一段。配一个 copyText 即可补齐, 「复制整段回答」是 AI 场景的核心操作,建议默认开启。
Vanilla 写法
DOM 层对已在渲染池中的 key 不会重新执行 renderItem(只调整位置), 所以流式更新要自己找到那一项、就地把变化画上去。富消息的做法是只重建最后一个块:
const container = document.querySelector('#list');
/** 给每个块的元素写上 data-block,就是为了这里能定位到它 */
function syncLastBlock(msg) {
const body = container.querySelector(`[data-id="${msg.id}"] .agent-body`);
// 找不到说明这条此刻在视口外:数据已经是全文,滚回来时 renderItem 会重建
if (!body) return;
const i = msg.blocks.length - 1;
const fresh = renderBlock(msg.blocks[i], i);
const old = body.querySelector(`[data-block="${i}"]`);
if (old) body.replaceChild(fresh, old);
else body.appendChild(fresh); // 新块入场,一次高度跳变
}
function appendChunk(msg, chunk) {
// 1. 数据里写全文:项滚出视口被回收后,滚回来时按它重新渲染
appendToBlock(msg.blocks[msg.blocks.length - 1], chunk);
// 2. 就地重画那一个块。不调 setList:列表结构没变,
// 高度变化由 ResizeObserver 自动上报,贴底跟随随之生效
syncLastBlock(msg);
}data-id 是 DOM 层给每一项加的属性,值就是该项的 itemKey, 可以直接用它定位到具体某一项的元素。
这里的取舍
框架版能做到只更新一个文本节点,手写 DOM 退到「一个块」——因为一片新文本可能让 Markdown 多出一行,逐节点比对不值得。换来的是完全不需要 diff 机制, 而重建范围仍然局限在一个块内:消息的其他块、列表里其他项一个节点都不动。
完整配置:
const vl = new VirtList(
container,
{
list,
itemKey: 'id',
estimatedSize: 200,
initialPosition: 'bottom',
stickyBottom: true,
hasMoreBottom: false,
loadMore: async (direction) => {
if (direction !== 'top') return false;
const older = await fetchHistory();
list = older.concat(list);
vl.setList(list);
return hasMore;
},
// 读的是数据里的 blocks,因此离屏期间生成的内容、展开过的工具卡都不会丢
renderItem: (item) => createAgentMessage(item),
},
{
// atEnd 用来决定「回到底部」按钮要不要亮
scroll: (e) => { atBottom = e.atEnd; },
// pendingNew 是用户翻历史期间新到的消息数,用来渲染未读角标
loadStateChange: (state) => { pending = state.pendingNew; },
},
);示例的完整源码可以在上面的 demo 里展开查看。