长对话把全部 Markdown 挂进 DOM,输入框和滚动会一起变慢。只做虚拟列表,又容易把正在流式输出的最后一条裁掉或闪烁。本文把历史消息虚拟化、当前消息就地更新、以及「用户正在上翻则不要强行吸底」拆开,接到现有 SSE 状态机上,不绑定任何模型厂商。
长对话变慢,往往不是模型,是消息节点
AI 聊天页和普通 IM 不同:助手一条回复常常是带代码块、列表、表格的 Markdown。用 messages.map 把整段会话都挂进 DOM,等于让浏览器同时排版几十份富文本。滚动时主线程既要处理输入,又要给视口外的历史消息做 layout 和 paint,体感就是越聊越卡。
虚拟列表能回收视口外的节点,但聊天还有两个约束。第一,正在流式输出的那条高度一直在涨,必须就地改文本,不能按 token 换 key、也不能被窗口化回收。第二,用户若正在上翻核对前文,新 token 到来时不能强行吸底。这两点做不好,虚拟化省下来的性能会以闪烁和抢滚动的形式还回去。
下面的实现基于 React 18 和 @tanstack/react-virtual 的 useVirtualizer。消息形态保持最小:{ id, role, content },外加 status: 'idle' | 'streaming'。Markdown 渲染你可以继续用现有的 react-markdown 组件,关键是 稳定的 id 当 key,只改 content。
拆成两层:虚拟化历史 + 真实 DOM 中的当前条
不要把「正在生成」和「已经生成完」放进同一个虚拟窗口。更稳的切法是:除最后一条以外全部虚拟化;最后一条永远留在真实 DOM。用户刚发出的短消息、助手正在写的长消息,都落在这条「尾巴」上。流式结束、用户再发下一条时,旧尾巴自然进入历史,被虚拟列表接管。
import { useRef, useLayoutEffect, useCallback, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual";
type ChatMessage = { id: string; role: "user" | "assistant"; content: string };
type ChatTranscriptProps = {
messages: ChatMessage[];
status: "idle" | "streaming";
renderBody: (message: ChatMessage) => React.ReactNode;
};
const BOTTOM_GAP = 80;
export function ChatTranscript({
messages,
status,
renderBody,
}: ChatTranscriptProps) {
const parentRef = useRef<HTMLDivElement>(null);
const pinRef = useRef(true);
const [pinToBottom, setPinToBottom] = useState(true);
const history = messages.slice(0, -1);
const tail = messages[messages.length - 1] ?? null;
const virtualizer = useVirtualizer({
count: history.length,
getScrollElement: () => parentRef.current,
estimateSize: () => 96,
overscan: 8,
});
const onScroll = useCallback(() => {
const el = parentRef.current;
if (!el) return;
const pinned =
el.scrollHeight - el.scrollTop - el.clientHeight <= BOTTOM_GAP;
pinRef.current = pinned;
setPinToBottom(pinned);
}, []);
useLayoutEffect(() => {
const el = parentRef.current;
if (!el || !pinRef.current) return;
el.scrollTop = el.scrollHeight;
}, [tail?.content, history.length, status]);
return (
<div style={{ position: "relative", height: "100%" }}>
<div
ref={parentRef}
onScroll={onScroll}
style={{ height: "100%", overflow: "auto" }}
>
<div
style={{
height: virtualizer.getTotalSize(),
width: "100%",
position: "relative",
}}
>
{virtualizer.getVirtualItems().map((item) => {
const message = history[item.index];
return (
<div
key={message.id}
data-index={item.index}
ref={virtualizer.measureElement}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
transform: `translateY(${item.start}px)`,
}}
>
{renderBody(message)}
</div>
);
})}
</div>
{tail ? (
<div data-tail="true" style={{ width: "100%" }}>
{renderBody(tail)}
</div>
) : null}
</div>
{!pinToBottom ? (
<button
type="button"
onClick={() => {
pinRef.current = true;
setPinToBottom(true);
const el = parentRef.current;
if (el) el.scrollTop = el.scrollHeight;
}}
style={{ position: "absolute", right: 16, bottom: 16 }}
>
回到底部
</button>
) : null}
</div>
);
}
estimateSize 只是测量前的占位。历史消息高度差很大时,务必把 ref={virtualizer.measureElement} 和 data-index 一起挂上,让库在布局后写回真实高度。尾巴不进 count,流式增高就不会和回收逻辑打架。
流式就地更新,避免最后一条被回收
SSE 每来一个 delta,只应改最后一条助手消息的 content,并保持同一个 id。新对象、旧身份:React 才能复用那棵 Markdown 子树。若按 content 当 key,或每个 token crypto.randomUUID() 一次,等于整段重挂,代码高亮和未闭合的围栏都会闪。
import { startTransition } from "react";
function appendAssistantDelta(
messages: ChatMessage[],
delta: string
): ChatMessage[] {
if (delta.length === 0) return messages;
const last = messages[messages.length - 1];
if (!last || last.role !== "assistant") return messages;
const next = messages.slice();
next[next.length - 1] = { ...last, content: last.content + delta };
return next;
}
function createAssistantPlaceholder(): ChatMessage {
return { id: crypto.randomUUID(), role: "assistant", content: "" };
}
高频 token 不要每个都强制同步刷新。React 18 里用 startTransition 包一层状态更新,让输入框的按键仍然走紧急更新;若服务端一帧里打来多段 data:,先在 rAF 里合并再 setMessages,尾巴上的 Markdown 每帧最多排一次。
流式过程中 Markdown 往往还不闭合(代码围栏缺结尾)。这不是虚拟列表的问题,但会放大闪烁。就地更新已经把范围限制在尾巴节点;历史消息是完整文档,测量结果也稳定,不要在每个 token 上去 virtualizer.measure() 整表。
滚动锚定:上翻阅读时不要吸底
「始终 scrollTop = scrollHeight」只适合用户盯着生成过程的场景。人一旦上翻,这个等式就会抢走阅读位置。做法很普通:用距离底部的像素判断是否吸底,只在吸底时跟随尾巴增高。
上面的 onScroll 把结论写进 pinRef。用 ref 而不是只靠 state,是为了让 useLayoutEffect 读到最新意图,并赶在绘制前改 scrollTop,减少「先看到长出来再被拉回去」的一帧。useLayoutEffect 的依赖里放 tail.content 和 history.length 即可,不必听整个 messages 数组引用。
CSS 的 overflow-anchor: auto 对普通文档流有用,对绝对定位的虚拟行基本帮不上忙:子节点在变换、高度在估测和实测之间跳,浏览器的锚点对不齐。虚拟列表场景里,吸底还是自己算更可控。
用户点击发送时,把 pinRef.current = true 并滚到底。这是一次明确的「我要看新回复」,和中途上翻不是同一类手势。未吸底时露出「回到底部」,比静默抢滚动更不容易让人迷路。
接到现有 SSE 状态机
聊天页通常已经有一层「发消息 → 读流 → 改 status」的状态机。虚拟列表不需要知道模型,只要规定:开流时追加一条空的 assistant;读到 delta 时 appendAssistantDelta;结束时把 status 置回 idle。下面用 fetch + ReadableStream,因为带 body 的对话请求走不了只能 GET 的 EventSource。
import { useState, useRef } from "react";
async function consumeSseText(
response: Response,
onDelta: (delta: string) => void
) {
if (!response.body) throw new Error("missing body");
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const chunks = buffer.split("\n\n");
buffer = chunks.pop() ?? "";
for (const chunk of chunks) {
for (const line of chunk.split("\n")) {
if (!line.startsWith("data:")) continue;
const data = line.slice(5).trim();
if (data === "" || data === "[DONE]") continue;
onDelta(data);
}
}
}
}
export function useChatSession(endpoint: string) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [status, setStatus] = useState<"idle" | "streaming">("idle");
const abortRef = useRef<AbortController | null>(null);
async function send(text: string) {
if (status === "streaming") return;
const user: ChatMessage = {
id: crypto.randomUUID(),
role: "user",
content: text,
};
const assistant = createAssistantPlaceholder();
setMessages((prev) => [...prev, user, assistant]);
setStatus("streaming");
const ac = new AbortController();
abortRef.current = ac;
const response = await fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ messages: [...messages, user] }),
signal: ac.signal,
});
let pending = "";
let frame = 0;
const flush = () => {
frame = 0;
const delta = pending;
pending = "";
if (!delta) return;
startTransition(() => {
setMessages((prev) => appendAssistantDelta(prev, delta));
});
};
await consumeSseText(response, (delta) => {
pending += delta;
if (frame) return;
frame = requestAnimationFrame(flush);
});
flush();
setStatus("idle");
}
return { messages, status, send };
}
若你现有协议是 data: {"delta":"..."} 这种 JSON,只在 onDelta 前 JSON.parse 取出文本,不要改 ChatTranscript。取消生成时 abortRef.current.abort(),并让最后一条保留已写出的内容,仍用同一个 id。页面结构就是:上方 ChatTranscript,下方输入框;输入框不再和成百条 Markdown 抢同一棵深子树。
总结
聊天页的性能问题,多半是历史富文本进了 DOM,而不是「虚拟列表没开满」。把会话拆成可回收的历史和不可回收的尾巴,流式只改最后一条的 content,滚动只在用户贴近底部时跟随,这三件事可以独立验证,也接得上现有 SSE 状态机。不要为了窗口化把正在生成的节点裁掉,也不要在用户上翻时抢 scrollTop。先让最后一条稳定地长出来,再让历史在视口外消失,输入和滚动才会一起回到可交互的状态。
评论