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] = useState<Message[]>([]); const [streaming, setStreaming] = useState(false); const abortRef = useRef<AbortController | 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 = useRef<HTMLTextAreaElement>(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 发送,Shift+Enter 换行 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 = useRef<HTMLDivElement>(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函数式更新最后一条消息 - 打字机效果:CSS
animation做闪烁光标
总代码量不到 200 行(不算样式)。不需要引入任何 Chat UI 库。
好的组件,是你能完全理解并掌控的组件。