news 2026/5/28 22:19:54

React 流式渲染 AI 对话组件:从 SSE 到打字机效果的优雅实现

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
React 流式渲染 AI 对话组件:从 SSE 到打字机效果的优雅实现

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 不再变化),不会触发重渲染。只有最后一条正在流式输出的消息会持续更新。

五、总结

整个对话组件的核心就三个东西:

  1. 流式读取ReadableStream+ 手动解析 SSE 格式
  2. 逐 token 更新setMessages函数式更新最后一条消息
  3. 打字机效果:CSSanimation做闪烁光标

总代码量不到 200 行(不算样式)。不需要引入任何 Chat UI 库。

好的组件,是你能完全理解并掌控的组件。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/5/28 22:19:20

深度学习都学不会的套路:如何用“废话架构”重构你的答辩PPT

毕业答辩是学业收官的关键一环&#xff0c;而答辩PPT作为研究成果的核心载体&#xff0c;直接决定评委对整场汇报的第一印象与核心评价。对于本、硕、博学子而言&#xff0c;数万字的毕业论文内容繁杂、维度多元&#xff0c;传统人工制作PPT的模式往往陷入效率低下、逻辑混乱、…

作者头像 李华
网站建设 2026/5/28 22:19:18

金融市场公告安全发布与反欺诈治理研究

摘要 金融市场官方公告是定价、决策与合规披露的核心信息载体&#xff0c;其真实性、完整性与可达性直接影响市场公平与投资者权益。当前&#xff0c;伪造公告、虚假披露、钓鱼式仿冒站点、中间人篡改与恶意推送等黑色产业链日趋产业化&#xff0c;对金融信息发布全流程构成系统…

作者头像 李华
网站建设 2026/5/28 22:14:59

从PID调参到无人机悬停:根轨迹到底怎么用?一个实战案例讲清楚

从PID调参到无人机悬停&#xff1a;根轨迹到底怎么用&#xff1f;一个实战案例讲清楚无人机在空中的稳定悬停&#xff0c;看似简单的动作背后却隐藏着复杂的控制逻辑。当工程师面对一台不断振荡或响应迟缓的无人机时&#xff0c;如何快速调整PID参数成为关键挑战。传统试错法不…

作者头像 李华
网站建设 2026/5/28 22:13:11

LLM应用安全实践:构建多层防御体系应对提示词注入与数据泄露

1. 项目概述&#xff1a;为什么LLM应用需要一个专属的“防火墙”&#xff1f;如果你正在开发或部署基于大语言模型的应用&#xff0c;无论是智能客服、代码助手还是内容生成工具&#xff0c;你可能已经意识到一个严峻的现实&#xff1a;你的应用正暴露在一个全新的、传统安全工…

作者头像 李华
网站建设 2026/5/28 22:11:25

从手机NFC到工卡:拆解柔性FPC线圈设计,用NFC Antenna Tool避坑指南

从手机NFC到工卡&#xff1a;拆解柔性FPC线圈设计&#xff0c;用NFC Antenna Tool避坑指南当你在便利店用手机轻触POS机完成支付&#xff0c;或是用智能手表刷开公司门禁时&#xff0c;背后都离不开一个关键组件——柔性FPC NFC天线。这种厚度不足0.2mm的铜箔线圈&#xff0c;承…

作者头像 李华
网站建设 2026/5/28 22:11:18

模型检验中的对称性破缺技术:应对核电站IC系统验证的组合爆炸

1. 项目概述&#xff1a;当模型检验遇上核电站的“双胞胎”系统在核电站仪表与控制&#xff08;I&C&#xff09;系统的设计与验证领域&#xff0c;我们面临着一个核心矛盾&#xff1a;一方面&#xff0c;系统必须达到近乎绝对的安全性与可靠性&#xff0c;任何潜在的逻辑缺…

作者头像 李华