长对话把全部 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。先让最后一条稳定地长出来,再让历史在视口外消失,输入和滚动才会一起回到可交互的状态。