
React 流式渲染 AI 对话组件从 SSE 到打字机效果的优雅实现前言最近在给自己的独立产品加 AI 对话功能。看了一圈开源的 Chat UI 组件要么依赖太重要么样式写死没法改。算了自己写一个。需求很简单接入大模型 SSE 流式接口实现打字机效果支持 Markdown 渲染。代码量控制在 200 行以内。一、SSE 流式协议1.1 SSE 是什么Server-Sent Events。服务端往客户端单向推数据的协议。和 WebSocket 的区别SSE 是单向的只能服务端推基于 HTTP天然支持断线重连。对于 AI 对话这种你问我答的场景SSE 比 WebSocket 更合适。sequenceDiagram participant 用户 as 前端 participant 服务 as 后端 participant AI as 大模型 API 用户-服务: POST /api/chat (用户消息) 服务-AI: 转发请求 (stream: true) AI--服务: data: {content: 你} 服务--用户: data: {content: 你} AI--服务: data: {content: 好} 服务--用户: data: {content: 好} AI--服务: data: [DONE] 服务--用户: data: [DONE]1.2 SSE 数据格式data: {choices:[{delta:{content:你}}]} data: {choices:[{delta:{content:好}}]} data: [DONE]每条消息以data:开头消息之间用空行分隔。就这么简单。二、核心组件实现2.1 流式请求 Hook先封装一个通用的流式请求 Hook。这是整个组件的心脏。// hooks/useStreamChat.ts import { useState, useCallback, useRef } from react; interface Message { role: user | assistant; content: string; } interface UseStreamChatOptions { apiUrl: string; // 接口地址 onError?: (err: Error) void; } export function useStreamChat({ apiUrl, onError }: UseStreamChatOptions) { const [messages, setMessages] useStateMessage[]([]); const [streaming, setStreaming] useState(false); const abortRef useRefAbortController | null(null); const send useCallback(async (userMessage: string) { if (!userMessage.trim() || streaming) return; // 添加用户消息 const newMessages: Message[] [ ...messages, { role: user, content: userMessage }, ]; setMessages(newMessages); setStreaming(true); // 支持中断用户可以随时停止生成 const controller new AbortController(); abortRef.current controller; try { const response await fetch(apiUrl, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages: newMessages }), signal: controller.signal, }); if (!response.ok || !response.body) { throw new Error(请求失败: ${response.status}); } // 先添加一条空的 assistant 消息 const assistantIndex newMessages.length; setMessages(prev [...prev, { role: assistant, content: }]); // 流式读取 const reader response.body.getReader(); const decoder new TextDecoder(); let buffer ; let fullContent ; while (true) { const { done, value } 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.startsWith(data: ) || line data: [DONE]) continue; try { const data JSON.parse(line.slice(6)); const token data.choices?.[0]?.delta?.content || ; fullContent token; // 逐 token 更新最后一条消息 setMessages(prev { const updated [...prev]; updated[assistantIndex] { role: assistant, content: fullContent, }; return updated; }); } catch { // 静默跳过解析失败 } } } } catch (err: unknown) { if (err instanceof Error err.name ! AbortError) { onError?.(err); } } finally { setStreaming(false); abortRef.current null; } }, [messages, streaming, apiUrl, onError]); // 停止生成 const stop useCallback(() { abortRef.current?.abort(); }, []); // 清空对话 const clear useCallback(() { setMessages([]); }, []); return { messages, streaming, send, stop, clear }; }设计要点AbortController支持用户手动停止生成buffer处理不完整的行网络包拆分用setMessages的函数式更新避免闭包陷阱2.2 消息气泡组件// components/ChatBubble.tsx import styles from ./ChatBubble.module.css; interface Props { role: user | assistant; content: string; streaming?: boolean; } export function ChatBubble({ role, content, streaming }: Props) { const isUser role user; return ( div className{${styles.bubble} ${isUser ? styles.user : styles.assistant}} div className{styles.avatar} {isUser ? : } /div div className{styles.content} {content || (streaming ? : ...)} {streaming role assistant ( span className{styles.cursor} / )} /div /div ); }气泡样式/* components/ChatBubble.module.css */ .bubble { display: flex; gap: 0.75rem; padding: 0.75rem 0; max-width: 85%; } .user { flex-direction: row-reverse; margin-left: auto; } .assistant { flex-direction: row; margin-right: auto; } .avatar { width: 32px; height: 32px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: 1.125rem; flex-shrink: 0; background: #f5f5f5; } .content { padding: 0.625rem 1rem; border-radius: 12px; line-height: 1.6; font-size: 0.9375rem; white-space: pre-wrap; word-break: break-word; } .user .content { background: #8b5cf6; color: white; border-bottom-right-radius: 4px; } .assistant .content { background: #f5f5f5; color: #1a1a1a; border-bottom-left-radius: 4px; } /* 打字机光标 */ .cursor { display: inline-block; width: 2px; height: 1em; background: #8b5cf6; margin-left: 2px; vertical-align: text-bottom; animation: blink 0.6s step-end infinite; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }2.3 输入框组件// components/ChatInput.tsx use client; import { useState, useRef, useEffect } from react; import styles from ./ChatInput.module.css; interface Props { onSend: (message: string) void; onStop: () void; streaming: boolean; disabled?: boolean; } export function ChatInput({ onSend, onStop, streaming, disabled }: Props) { const [input, setInput] useState(); const textareaRef useRefHTMLTextAreaElement(null); // 自动调整输入框高度 useEffect(() { const el textareaRef.current; if (el) { el.style.height auto; el.style.height Math.min(el.scrollHeight, 120) px; } }, [input]); const handleSubmit () { if (!input.trim() || disabled) return; onSend(input.trim()); setInput(); }; const handleKeyDown (e: React.KeyboardEvent) { // Enter 发送ShiftEnter 换行 if (e.key Enter !e.shiftKey) { e.preventDefault(); handleSubmit(); } }; return ( div className{styles.inputBar} textarea ref{textareaRef} className{styles.textarea} value{input} onChange{e setInput(e.target.value)} onKeyDown{handleKeyDown} placeholder输入你的问题... rows{1} disabled{streaming} / {streaming ? ( button className{styles.stopBtn} onClick{onStop} ■ 停止 /button ) : ( button className{styles.sendBtn} onClick{handleSubmit} disabled{!input.trim()} 发送 /button )} /div ); }/* components/ChatInput.module.css */ .inputBar { display: flex; gap: 0.5rem; align-items: flex-end; padding: 0.75rem; border-top: 1px solid #eee; background: white; } .textarea { flex: 1; padding: 0.625rem 0.875rem; border: 1px solid #e5e5e5; border-radius: 8px; font-size: 0.9375rem; line-height: 1.5; resize: none; font-family: inherit; transition: border-color 0.2s; } .textarea:focus { outline: none; border-color: #8b5cf6; } .sendBtn, .stopBtn { padding: 0.625rem 1rem; border: none; border-radius: 8px; font-size: 0.875rem; cursor: pointer; white-space: nowrap; transition: opacity 0.2s; } .sendBtn { background: #8b5cf6; color: white; } .sendBtn:disabled { opacity: 0.4; cursor: not-allowed; } .stopBtn { background: #ef4444; color: white; }三、组装完整的对话页面// app/page.tsx use client; import { useRef, useEffect } from react; import { useStreamChat } from /hooks/useStreamChat; import { ChatBubble } from /components/ChatBubble; import { ChatInput } from /components/ChatInput; import styles from ./page.module.css; export default function Home() { const scrollRef useRefHTMLDivElement(null); const { messages, streaming, send, stop, clear } useStreamChat({ apiUrl: /api/chat, onError: (err) console.error(对话出错:, err.message), }); // 自动滚动到底部 useEffect(() { const el scrollRef.current; if (el) { el.scrollTop el.scrollHeight; } }, [messages]); return ( div className{styles.container} header className{styles.header} h1对话/h1 {messages.length 0 ( button className{styles.clearBtn} onClick{clear} 清空 /button )} /header div className{styles.messages} ref{scrollRef} {messages.length 0 ( div className{styles.empty} 说点什么开始对话。 /div )} {messages.map((msg, i) ( ChatBubble key{i} role{msg.role} content{msg.content} streaming{streaming i messages.length - 1} / ))} /div ChatInput onSend{send} onStop{stop} streaming{streaming} / /div ); }四、避坑与优化4.1 滚动抖动流式更新时频繁触发scrollTop会导致画面抖动。用requestAnimationFrame节流// 优化后的自动滚动 useEffect(() { const el scrollRef.current; if (!el) return; // 只在用户没有手动上滑时才自动滚动 const isAtBottom el.scrollHeight - el.scrollTop - el.clientHeight 100; if (isAtBottom) { requestAnimationFrame(() { el.scrollTop el.scrollHeight; }); } }, [messages]);4.2 防止重复发送// useStreamChat 内部已经做了防护 const send useCallback(async (userMessage: string) { if (!userMessage.trim() || streaming) return; // streaming 时直接拦截 // ... }, [streaming]);4.3 性能避免全量重渲染如果消息很多100每次setMessages会导致所有气泡重渲染。加一个React.memo// 用 memo 包裹气泡组件 import { memo } from react; export const ChatBubble memo(function ChatBubble({ role, content, streaming }: Props) { // ... });memo会对 props 做浅比较。对于已完成的消息content 不再变化不会触发重渲染。只有最后一条正在流式输出的消息会持续更新。五、总结整个对话组件的核心就三个东西流式读取ReadableStream 手动解析 SSE 格式逐 token 更新setMessages函数式更新最后一条消息打字机效果CSSanimation做闪烁光标总代码量不到 200 行不算样式。不需要引入任何 Chat UI 库。好的组件是你能完全理解并掌控的组件。