React 如何实现一个稳定的 Streaming Chat UI

React 做一个 Chat UI 很容易:一个 messages 数组,一个输入框,一个发送按钮。

React 做一个稳定的 Streaming Chat UI 就复杂很多。因为 assistant 回复不是一次性数据,而是持续到达的增量事件。组件要在几十秒内不断更新,同时处理取消、重试、滚动、Markdown、错误恢复和页面切换。

这篇文章不做完整项目实践,而是梳理一个稳定实现需要的前端工程设计。

先把状态边界划清楚

Streaming Chat UI 至少有四类状态:

  • 会话状态:conversation id、消息列表、当前会话是否可用。
  • 生成状态:当前 run id、是否 streaming、是否可取消、错误信息。
  • 输入状态:输入框内容、是否提交中、附件或引用。
  • 视图状态:滚动位置、是否贴底、当前 hover 的消息、Markdown 展开状态。

不要把所有状态塞进 messages。消息列表是核心数据,但它不是整个 UI 的垃圾桶。

一个简化模型可以这样写:

type MessageStatus = 'ready' | 'streaming' | 'completed' | 'failed' | 'cancelled';

type Message = {
  id: string;
  role: 'user' | 'assistant';
  content: string;
  status: MessageStatus;
  runId?: string;
  error?: string;
};

type ChatState = {
  conversationId: string;
  messages: Message[];
  activeRunId?: string;
  isStreaming: boolean;
  error?: string;
};

真实业务还会有 tool call、citation、usage、attachments 等字段,但核心原则不变:message 表达内容和展示状态,run 表达一次生成过程。

用 reducer 管理流式事件

Streaming 会产生很多事件。如果每个事件都散落在组件里 setState,逻辑很快会失控。

更稳的方式是定义事件,再用 reducer 合并状态。

type ChatAction =
  | { type: 'user_submitted'; message: Message }
  | { type: 'assistant_started'; runId: string; message: Message }
  | { type: 'assistant_delta'; messageId: string; text: string }
  | { type: 'assistant_done'; messageId: string }
  | { type: 'assistant_failed'; messageId: string; error: string }
  | { type: 'assistant_cancelled'; messageId: string };

function chatReducer(state: ChatState, action: ChatAction): ChatState {
  switch (action.type) {
    case 'user_submitted':
      return {
        ...state,
        messages: [...state.messages, action.message],
        error: undefined,
      };

    case 'assistant_started':
      return {
        ...state,
        activeRunId: action.runId,
        isStreaming: true,
        messages: [...state.messages, action.message],
      };

    case 'assistant_delta':
      return {
        ...state,
        messages: state.messages.map((message) =>
          message.id === action.messageId
            ? { ...message, content: message.content + action.text }
            : message,
        ),
      };

    case 'assistant_done':
      return {
        ...state,
        isStreaming: false,
        activeRunId: undefined,
        messages: state.messages.map((message) =>
          message.id === action.messageId
            ? { ...message, status: 'completed' }
            : message,
        ),
      };

    case 'assistant_failed':
      return {
        ...state,
        isStreaming: false,
        activeRunId: undefined,
        error: action.error,
        messages: state.messages.map((message) =>
          message.id === action.messageId
            ? { ...message, status: 'failed', error: action.error }
            : message,
        ),
      };

    case 'assistant_cancelled':
      return {
        ...state,
        isStreaming: false,
        activeRunId: undefined,
        messages: state.messages.map((message) =>
          message.id === action.messageId
            ? { ...message, status: 'cancelled' }
            : message,
        ),
      };
  }
}

reducer 的好处是把“收到某个事件后状态怎么变化”集中起来。后续增加 tool_call、usage、citation,也是在同一套事件模型上扩展。

流式读取应该和组件渲染解耦

React 组件不应该直接承载所有网络流解析细节。可以把读取逻辑封装成一个函数,只向外发出结构化事件。

type StreamEvent =
  | { type: 'start'; runId: string; messageId: string }
  | { type: 'delta'; messageId: string; text: string }
  | { type: 'done'; messageId: string }
  | { type: 'error'; messageId: string; message: string };

async function readChatStream(
  response: Response,
  onEvent: (event: StreamEvent) => void,
) {
  if (!response.body) {
    throw new Error('Response body is empty.');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder();
  let buffer = '';

  while (true) {
    const { value, done } = await reader.read();

    if (done) break;

    buffer += decoder.decode(value, { stream: true });

    const lines = buffer.split('\n');
    buffer = lines.pop() ?? '';

    for (const line of lines) {
      if (!line.trim()) continue;
      onEvent(JSON.parse(line) as StreamEvent);
    }
  }
}

这里用 newline-delimited JSON 做示例。实际项目也可以解析 SSE 格式。重点是:网络 chunk 不等于完整事件,所以一定要有 buffer。

发送消息的流程

发送用户消息时,前端要做的事比普通表单提交多。

一个推荐流程是:

  1. 生成本地 user message,立即渲染。
  2. 创建 AbortController
  3. 发起流式请求。
  4. 收到 start 事件后创建 assistant message。
  5. 收到 delta 后追加内容。
  6. 收到 done 后关闭 streaming 状态。
  7. 捕获错误并区分失败与取消。
async function sendMessage(text: string) {
  const controller = new AbortController();
  abortRef.current = controller;

  dispatch({
    type: 'user_submitted',
    message: {
      id: crypto.randomUUID(),
      role: 'user',
      content: text,
      status: 'completed',
    },
  });

  try {
    const response = await fetch('/api/chat', {
      method: 'POST',
      body: JSON.stringify({ text }),
      signal: controller.signal,
    });

    await readChatStream(response, (event) => {
      if (event.type === 'start') {
        dispatch({
          type: 'assistant_started',
          runId: event.runId,
          message: {
            id: event.messageId,
            role: 'assistant',
            content: '',
            status: 'streaming',
            runId: event.runId,
          },
        });
      }

      if (event.type === 'delta') {
        dispatch({
          type: 'assistant_delta',
          messageId: event.messageId,
          text: event.text,
        });
      }

      if (event.type === 'done') {
        dispatch({ type: 'assistant_done', messageId: event.messageId });
      }

      if (event.type === 'error') {
        dispatch({
          type: 'assistant_failed',
          messageId: event.messageId,
          error: event.message,
        });
      }
    });
  } catch (error) {
    if (controller.signal.aborted) {
      // 这里需要知道当前 assistant message id,真实项目可从 active run 中读取。
      return;
    }

    throw error;
  } finally {
    abortRef.current = undefined;
  }
}

示例省略了一些业务细节,比如 active assistant message id 的保存、服务端错误格式、请求鉴权和上下文组装。但整体结构应该保持:发送、读取、事件分发、状态合并。

取消生成要可预期

取消生成不是简单地把按钮禁用。它要同时影响网络请求、run 状态和 assistant message 状态。

function stopGenerating() {
  abortRef.current?.abort();

  const streamingMessage = state.messages.find(
    (message) => message.status === 'streaming',
  );

  if (streamingMessage) {
    dispatch({
      type: 'assistant_cancelled',
      messageId: streamingMessage.id,
    });
  }
}

真实项目里,前端 abort 后最好也让后端知道 run 已取消。因为浏览器断开连接不一定意味着上游模型调用立刻停止。后端需要处理连接关闭、保存 cancelled 状态,并尽量中断模型请求。

UI 上,取消后的消息不要显示成普通错误。它是用户主动选择的部分结果,可以提供“重新生成”“继续提问”“复制已生成内容”等操作。

自动滚动要尊重用户阅读

Streaming Chat 很容易出现滚动体验问题。每次 delta 到达都滚到底部,会打断正在向上阅读历史的用户。

更好的规则是:只有用户本来就在底部附近时,才自动滚动。

function isNearBottom(element: HTMLElement) {
  const distance =
    element.scrollHeight - element.scrollTop - element.clientHeight;

  return distance < 80;
}

可以维护一个 isPinnedToBottom 状态。用户滚动到上方时关闭自动贴底;用户点击“回到底部”或手动滚到底部时再打开。

这个细节会直接影响用户对产品的感受。AI 回复越长,越不能粗暴地每次更新都抢滚动。

Markdown 渲染要处理半成品

AI Chat 经常输出 Markdown 和代码块。Streaming 时,内容可能处于不完整状态:

```ts
function hello() {

这时 Markdown 解析器可能表现不稳定,代码高亮也可能比较耗时。

前端可以采用几条策略:

  • streaming 中使用较轻量的 Markdown 渲染。
  • completed 后再启用完整代码高亮。
  • 对代码块做容错,允许未闭合代码块临时渲染。
  • 对长消息使用 memo,避免整个列表频繁重渲染。
  • 限制 delta 合并频率,比如每 30 到 80 毫秒刷新一次 UI。

不要在每个 token 到达时都重新高亮整篇长文。那会让页面越来越卡。

性能关键是减少无意义重渲染

React 状态更新会触发组件渲染。Streaming 中,delta 很密集,如果每个 delta 都更新完整 messages 数组,长对话会明显卡顿。

可以从几个方向优化:

  • React.memo 包裹单条消息组件。
  • 只让正在 streaming 的消息频繁更新。
  • 合并多个 delta 后再 dispatch。
  • 对 Markdown 渲染结果做缓存。
  • 长列表使用虚拟滚动,但要小心动态高度和自动滚动。

一个简单的 delta 合并思路是:流读取层先把文本放入临时 buffer,再按固定节奏 flush 到 reducer。

let pendingText = '';
let scheduled = false;

function enqueueDelta(messageId: string, text: string) {
  pendingText += text;

  if (scheduled) return;
  scheduled = true;

  window.setTimeout(() => {
    dispatch({ type: 'assistant_delta', messageId, text: pendingText });
    pendingText = '';
    scheduled = false;
  }, 50);
}

这不是唯一方案,但原则很重要:网络事件频率不一定等于 UI 渲染频率。

重试要基于消息关系

Streaming Chat 的重试要先确认目标是哪条消息。

常见规则是:

  • assistant message failed:基于上一条 user message 重新生成。
  • assistant message cancelled:可以重新生成,也可以保留部分内容继续对话。
  • user message edited:截断它之后的 assistant 回复,再创建新的 run。

因此,消息之间最好能通过顺序或 parent id 找到关系。不要只存一堆扁平文本,否则重试时很难知道应该从哪里恢复。

前端入口也要清晰:用户点击 assistant 消息上的“重新生成”,不应该再插入同一条 user message。它应该创建新的 assistant run,并替换或追加一个新的 assistant message。

常见坑

坑一:在组件卸载后继续 setState。

用户切换会话或页面时,旧请求可能还在流式返回。需要 abort 请求,或在事件处理里检查当前 conversation id 和 run id。

坑二:没有 buffer,直接 JSON.parse chunk。

一个 chunk 可能包含半个 JSON,也可能包含多个事件。必须按协议分包解析。

坑三:每个 delta 都重渲染全列表。

短 Demo 看不出来,长回答和多轮对话会明显卡顿。要合并 delta、拆分组件并缓存昂贵渲染。

坑四:取消后仍然接收旧事件。

Abort 后可能还有已经到达事件在队列里。reducer 应该根据 active run id 或 message status 忽略过期事件。

坑五:自动滚动抢用户阅读。

只要用户离开底部,就不要强制滚到底。提供一个“回到底部”入口即可。

坑六:把 Markdown 当成完整文档渲染。

Streaming 中的 Markdown 是半成品。渲染器和样式都要能接受不完整代码块、表格和列表。

工程设计建议

第一,用事件驱动状态。

把 start、delta、done、error、cancelled 都变成明确 action。不要让网络回调直接修改多个零散状态。

第二,用 run id 防止串流。

当用户快速切换会话、连续重试或取消后重新生成时,旧流的事件不能写入新消息。每个事件都应该能关联 run 或 message。

第三,把滚动、输入和生成状态分开。

它们相互影响,但不应该混成一个大对象。状态边界越清楚,交互越容易维护。

第四,先保证协议稳定,再优化视觉效果。

没有 done、error、cancelled 这些事件,再漂亮的 UI 也会在异常场景里崩掉。

第五,在慢网和长回答下测试。

Streaming Chat 的问题往往只在真实延迟、长 Markdown、快速取消和会话切换中出现。不要只用十几个字的回答验证。

小结

稳定的 React Streaming Chat UI,不是把文本逐字 append 到页面上。

它需要清晰的状态模型、事件协议、流式解析、取消语义、重试关系、滚动策略和渲染性能控制。前端工程师要把 AI 回复看成一个正在进行的生成任务,而不是一条已经完成的消息。这样设计出来的 Chat UI,才经得住长回答、多轮对话和真实网络环境。