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。
发送消息的流程
发送用户消息时,前端要做的事比普通表单提交多。
一个推荐流程是:
- 生成本地 user message,立即渲染。
- 创建
AbortController。
- 发起流式请求。
- 收到 start 事件后创建 assistant message。
- 收到 delta 后追加内容。
- 收到 done 后关闭 streaming 状态。
- 捕获错误并区分失败与取消。
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 时,内容可能处于不完整状态:
这时 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,才经得住长回答、多轮对话和真实网络环境。