Skip to content

流式输出与贴底跟随

AI 回答是逐 token 流入的:同一条消息的高度在几秒内会变几十次。 这一节讲的是在这种持续变化中,视口该怎么动、以及什么时候不该动。

微应用尚未挂载。

对照着试

发送一个问题看跟随;生成中向上滚,状态行会变成「已脱离跟随」; 再手动滚回底部,跟随恢复。这三个动作正是下面表格里的三行。

跟随的三个分支

一个可用的 AI 对话列表,需要同时满足这三条。它们看起来是三种行为, 实际由同一个机制给出:

用户此刻在做什么期望行为
贴在底部看最新回答内容变高时视口跟着走,始终看到最新的一行
已经向上滚去读历史下面无论长出多少内容,视口都不能被拽走
手动把滚动条拖回底部重新进入跟随,后续生成继续跟着走

第三条最容易被漏掉。少了它,用户会发现「我明明已经滚到底了,新内容还是会溢出视口」 ——很多聊天界面在流式输出下体感不稳,原因就在这里。

开启方式只有一个配置:

ts
{
  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)上, 而列表结构压根没变。

把一帧内到达的片段合并成一次更新即可,视觉上没有区别——屏幕本来也只有一帧刷新一次。 组件包里带了这个工具:

ts
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(只调整位置), 所以流式更新要自己找到那一项、就地把变化画上去。富消息的做法是只重建最后一个块

js
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 机制, 而重建范围仍然局限在一个块内:消息的其他块、列表里其他项一个节点都不动。

完整配置:

js
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 里展开查看。