React + Fetch API 构建 DeepSeek 流式对话应用实战

张开发
2026/4/10 17:02:38 15 分钟阅读

分享文章

React + Fetch API 构建 DeepSeek 流式对话应用实战
1. 为什么选择React Fetch API构建流式对话应用在开始动手之前我们先聊聊为什么React和Fetch API是构建流式对话应用的黄金组合。React的组件化开发模式特别适合处理动态更新的聊天界面而Fetch API作为现代浏览器原生支持的HTTP请求工具能够完美处理服务器推送事件(SSE)。我最近在开发一个AI客服系统时就选择了这个方案实测下来发现几个明显优势首先是性能表现优异不需要额外引入第三方库就能实现流式数据传输其次是代码可维护性高React的状态管理机制让聊天记录的更新变得非常直观最后是兼容性好现代浏览器都能完美支持。对比传统的WebSocket方案Fetch API处理SSE更加轻量级。特别是在AI对话场景中服务器返回的数据通常是单向流动的从服务器到客户端SSE的简单性反而成了优势。而且React的虚拟DOM机制能够高效处理频繁的界面更新这正是流式对话最需要的特性。2. 项目环境搭建与基础配置2.1 创建React项目并安装必要依赖首先用create-react-app初始化项目npx create-react-app deepseek-chat --template typescript cd deepseek-chat然后安装我们需要的依赖库npm install antd ant-design/icons ahooks react-markdown remark-gfm clipboard-polyfill这些库各司其职Ant Design提供美观的UI组件ahooks简化状态管理react-markdown负责渲染AI返回的Markdown格式内容clipboard-polyfill处理复制功能。2.2 配置基础样式在src/index.css中添加Tailwind CSS的基础配置tailwind base; tailwind components; tailwind utilities; .ottai-seek { height: 100vh; display: flex; flex-direction: column; align-items: center; background-color: white; }3. 核心聊天组件实现3.1 组件状态设计与初始化我们先定义组件的核心状态const AIChat: FC () { const scrollRef useRefany(); const textareaRef useRef(null); const [question, setQuestion] useState(); const [historyList, setHistoryList] useStateany[]([]); const [loading, setLoading] useState(false); // 自动滚动到底部 useEffect(() { const scrollToBottom () { const { current }: any scrollRef; if (current) { current.scrollTop current.scrollHeight; } }; scrollToBottom(); }, [historyList]); // ...其他代码 }这里定义了三个核心状态question存储当前输入的问题historyList保存对话历史loading表示是否正在等待AI响应。scrollRef用来控制聊天区域的自动滚动。3.2 流式请求处理实现关键的部分来了 - 使用Fetch API处理流式响应const { run: handleFetch } useRequest(async (reqQuestion: string) { setLoading(true); // 添加loading状态到对话历史 const loadingItem { type: 1, content: LoadingOutlined /, timeStamp: new Date().getTime(), contentType: loading, }; setHistoryList((list) [...list, loadingItem]); // 发起流式请求 const data: any await customerServiceOttaiSeek.chatStream({ question: reqQuestion, }); const reader data.body.getReader(); const decoder new TextDecoder(); if (!reader) return; let partialData ; while (true) { const { value, done } await reader.read(); if (done) break; const chunk decoder.decode(value, { stream: true }); partialData chunk; // 处理SSE格式数据 const lines partialData .split(\n) .filter((line) line.startsWith(data:)); for (const line of lines) { try { const json JSON.parse(line.replace(/^data:/, ).trim()); await new Promise((resolve) setTimeout(resolve, 50)); // 模拟打字效果 setHistoryList((prevHistory: any[]) { const lastMessage prevHistory[prevHistory.length - 1]; if (lastMessage lastMessage.isStreaming) { // 如果是流式消息则追加内容 return [ ...prevHistory.slice(0, -1), { ...lastMessage, type: 1, content: lastMessage.content json.answer, }, ]; } else { // 否则添加新消息 return [ ...prevHistory.slice(0, -1), { content: json.answer, isStreaming: true, type: 1, timeStamp: new Date().getTime(), }, ]; } }); } catch (e) { console.error(解析JSON失败:, e); } } // 处理不完整的数据块 const lastData partialData.slice(partialData.lastIndexOf(\n) 1); if (!(lastData.startsWith(data:{) lastData.endsWith(}))) { partialData lastData; } else { partialData ; } } setLoading(false); }, { manual: true });这段代码有几个关键点使用ReadableStream的getReader()方法读取流数据通过TextDecoder处理二进制数据解析SSE格式每行以data:开头实现打字机效果通过setTimeout延迟正确处理不完整的数据块3.3 用户输入与交互处理处理用户输入和快捷键const handleSearch () { if (/^\s*$/.test(question)) return; const searchItem { type: 0, content: question, timeStamp: new Date().getTime(), }; setHistoryList([...historyList, searchItem]); handleFetch(question); setQuestion(); }; const handleKeyDown (e: any) { // 单独按Enter发送消息 if (e.key Enter !e.ctrlKey !e.metaKey !e.shiftKey) { e.preventDefault(); handleSearch(); } // CtrlEnter换行 else if (e.key Enter (e.ctrlKey || e.metaKey)) { e.preventDefault(); insertNewline(); } }; // 在光标位置插入换行 const insertNewline () { if (!textareaRef.current) return; const textarea (textareaRef.current as any).resizableTextArea.textArea; const start textarea.selectionStart; const end textarea.selectionEnd; const newQuestion question.substring(0, start) \n question.substring(end); setQuestion(newQuestion); // 更新光标位置 setTimeout(() { textarea.selectionStart start 1; textarea.selectionEnd start 1; textarea.focus(); }, 0); };4. 界面渲染与功能完善4.1 聊天记录渲染使用React-Markdown渲染AI返回的内容return ( div ref{scrollRef} className{ottai-seek ${historyList.length ? : justify-center}} div classNamew-[80%] pt-[20px] {historyList.length ? ( historyList.map((item, index) { if (item.type 0) { return ( div classNameflex justify-end key{index} p classNamebg-[#3B78FF] text-white p-[8px] rounded-[10px] {item.content} /p /div ); } else { return ( div classNameflex mt-[12px] key{index} TwitchOutlined classNametext-[30px] / div classNamep-[8px] {item.contentType loading ? ( item.content ) : ( div Markdown remarkPlugins{[remarkGfm]} {item.content} /Markdown /div )} div classNameflex gap-[8px] mt-[8px] div className{${loading ? answer-btn-item-disabled : answer-btn-item}} onClick{() handleCopy(item.content)} CopyOutlined / /div div className{${loading ? answer-btn-item-disabled : answer-btn-item}} onClick{() handleRefresh(index)} RedoOutlined / /div /div /div /div ); } }) ) : ( div classNametext-center pb-[20px] TwitchOutlined classNametext-[30px] / span classNametext-[20px] font-bold mt-[32px] ml-[12px]AI助手/span /div )} /div {/* 底部输入区域 */} div classNamesticky bottom-0 w-[80%] pb-[20px] bg-white rounded-t-[10px] div classNametext-area-box Input.TextArea ref{textareaRef} classNamepr-0 placeholder请输入查询内容 variantborderless autoSize{{ minRows: 4 }} disabled{loading} value{question} onChange{(e) setQuestion(e.target.value)} onKeyDown{handleKeyDown} / div className{btn ${(!question || loading) ? search-disabled-btn : search-btn}} onClick{handleSearch} {loading ? LoadingOutlined / : ArrowUpOutlined /} /div /div /div /div );4.2 实用功能实现添加复制和重新提问功能const handleCopy async (text: string) { if (loading) return; await clipboard.writeText(text); message.success(复制成功); }; const handleRefresh (index: number) { if (loading) return; const questionInfo historyList[index - 1]; if (questionInfo.type 0 questionInfo.content) { const searchItem { type: 0, content: questionInfo.content, timeStamp: new Date().getTime(), }; setHistoryList([...historyList, searchItem]); handleFetch(questionInfo.content); } };5. 性能优化与调试技巧5.1 流式处理性能优化在实际项目中我发现流式处理有几个常见的性能陷阱需要注意避免频繁状态更新虽然我们需要实时更新聊天内容但过于频繁的React状态更新会导致性能问题。解决方案是适当控制更新频率比如可以累积一定量的字符再更新或者使用防抖技术。内存泄漏预防流式请求可能会因为组件卸载而导致内存泄漏。一定要在组件卸载时取消请求useEffect(() { let isMounted true; // ...流式请求代码 return () { isMounted false; // 取消reader }; }, []);网络异常处理流式请求容易受到网络波动影响需要完善的错误处理try { const { value, done } await reader.read(); // ... } catch (error) { console.error(流式读取失败:, error); setHistoryList(prev [...prev, { type: 1, content: 网络异常请重试, timeStamp: new Date().getTime() }]); setLoading(false); return; }5.2 调试SSE流调试SSE流可能会比较棘手这里分享几个实用技巧使用Proxy拦截请求在开发环境中可以配置Webpack或Vite的proxy来拦截SSE请求方便查看原始数据格式。模拟SSE服务在开发初期可以用一个简单的Node.js服务模拟SSE响应// server.js app.get(/mock-sse, (req, res) { res.setHeader(Content-Type, text/event-stream); let i 0; const interval setInterval(() { res.write(data: ${JSON.stringify({ answer: Chunk ${i} })}\n\n); if (i 10) { clearInterval(interval); res.end(); } }, 300); });浏览器开发者工具在Chrome的Network面板中可以查看SSE连接状态和传输的数据但要注意SSE连接会一直保持不像普通请求那样会显示为完成状态。6. 样式优化与响应式设计6.1 聊天界面美化使用Tailwind CSS优化聊天界面div classNameflex mt-[12px] TwitchOutlined classNametext-[30px] text-[#4d6bfe] / div classNamep-[8px] ml-2 bg-gray-50 rounded-lg max-w-[80%] Markdown remarkPlugins{[remarkGfm]} {item.content} /Markdown div classNameflex gap-[8px] mt-[8px] {/* 操作按钮 */} /div /div /div6.2 移动端适配针对移动设备优化布局media (max-width: 640px) { .ottai-seek { padding: 0 10px; } .text-area-box { width: 100%; } .w-\[80\%\] { width: 95%; } }7. 项目部署与生产环境考量7.1 部署注意事项将React应用部署到生产环境时有几个关键点需要考虑HTTPS要求SSE在大多数浏览器中要求使用HTTPS连接特别是在生产环境。连接限制浏览器对同一个域的SSE连接数有限制通常是6个如果应用需要多个SSE连接需要考虑使用不同的子域。心跳机制为了防止连接超时服务器应该定期发送心跳消息注释行: heartbeat\n\n7.2 性能监控建议添加以下监控指标首字节时间(TTFB)衡量从发送问题到收到第一个响应的时间。流式传输速率监控每秒传输的字符数确保流畅的用户体验。错误率跟踪流式连接失败的比例。可以使用像Sentry这样的工具来捕获前端错误特别是流式处理中的异常情况。8. 扩展功能思路8.1 对话持久化如果需要保存对话历史可以考虑本地存储使用localStorage或IndexedDB在浏览器端保存最近对话。服务端存储将对话历史关联用户账号存储在服务器。导出功能添加对话导出为Markdown或PDF的功能。8.2 高级交互功能消息编辑重发允许用户编辑之前的问题重新提问。对话分支支持从历史某条消息开始新的对话分支。内容审核在前端添加敏感词过滤避免不当内容提交。8.3 性能高级优化虚拟滚动对于超长对话历史实现虚拟滚动优化性能。Web Worker将流式数据处理放到Web Worker中避免阻塞主线程。预加载预测用户可能的问题提前建立SSE连接。

更多文章