核心功能说明
完全对标豆包官网,涵盖所有生产级必备功能,无任何冗余逻辑:
- SSE 标准流式解析:兼容所有主流大模型(豆包、通义千问、ChatGPT),严格处理 TCP 分包/粘包,不丢字、不乱码。
- 停止输出 + 重试机制:用户可随时停止 AI 输出,异常时支持一键重试,贴合企业级用户体验。
- 历史消息管理:保存完整对话上下文,后端可基于历史消息实现连续对话,和豆包官网逻辑完全一致。
- 防重复请求:通过 loading 状态锁,防止用户多次点击发送,避免后端压力过大。
- Token 权限校验:适配企业级权限管理,从 localStorage 读取 Token,支持后端身份验证。
- 并发优化:使用 useTransition 标记低优先级更新,输入框、按钮、滚动永远流畅,不卡顿。
- 异常兜底:捕获网络中断、Token 过期、浏览器不兼容、HTTP 错误等所有异常,给用户清晰提示,不卡死页面。
- 自动滚动:AI 输出时自动滚动到底部,和豆包官网交互完全一致,提升用户体验。
- 细节优化:Shift+Enter 换行、加载中闪烁光标、滚动条美化、空状态提示,所有细节对标豆包。
- 内存管理:组件销毁时清理中断控制器,防止内存泄漏,符合企业级代码规范。
完整代码
javascript
import { useState, useRef, useEffect, useTransition } from 'react';
import './ChatStream.css'; // 可自行创建样式文件
// 企业级 AI 流式对话组件(豆包官网同款)
const AIChatStream = () => {
// 核心状态管理
const [messages, setMessages] = useState([]); // 历史消息列表(企业级必存)
const [inputValue, setInputValue] = useState(''); // 输入框内容
const [loading, setLoading] = useState(false); // 加载状态(防重复请求)
const [error, setError] = useState(''); // 异常提示
const [isPending, startTransition] = useTransition(); // 并发优化(防卡顿)
// 引用管理(企业级内存管理核心)
const abortRef = useRef(null); // 中断控制器引用
const chatEndRef = useRef(null); // 滚动到底部引用
const inputRef = useRef(null); // 输入框引用(聚焦优化)
// 自动滚动到底部(豆包官网同款流畅滚动)
useEffect(() => {
chatEndRef.current?.scrollIntoView({ behavior: 'smooth', block: 'end' });
}, [messages]);
// 组件销毁:清理资源(企业级必做,防止内存泄漏)
useEffect(() => {
return () => {
if (abortRef.current) {
abortRef.current.abort();
}
};
}, []);
// 重试发送(企业级异常处理必备)
const retrySend = (lastPrompt) => {
setInputValue(lastPrompt);
inputRef.current?.focus();
};
// 停止输出(豆包官网同款停止功能)
const stopStream = () => {
if (abortRef.current) {
abortRef.current.abort();
setLoading(false);
}
};
// 清空聊天记录(企业级基础功能)
const clearChat = () => {
stopStream();
setMessages([]);
setInputValue('');
setError('');
};
// 发送流式请求(核心逻辑,企业级标准实现)
const sendStream = async () => {
const prompt = inputValue.trim();
// 防重复请求、空请求(企业级校验)
if (!prompt || loading) return;
// 重置状态
setLoading(true);
setError('');
setInputValue('');
// 新增用户消息(企业级消息格式,对齐后端)
const newUserMsg = { role: 'user', content: prompt, timestamp: Date.now() };
startTransition(() => {
setMessages(prev => [...prev, newUserMsg, { role: 'assistant', content: '', loading: true, timestamp: Date.now() }]);
});
// 初始化流式核心变量
const controller = new AbortController();
abortRef.current = controller;
let buffer = ''; // 分包/粘包缓存(核心)
let fullText = ''; // AI 完整响应内容
const token = localStorage.getItem('authToken'); // 生产级 Token 存储(适配权限校验)
try {
// 发起企业级流式请求(对齐后端 SSE 标准)
const response = await fetch('/api/v1/chat/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`, // 企业级权限校验(必加)
'X-Request-Id': Date.now().toString(), // 链路追踪(企业级监控必备)
},
body: JSON.stringify({
prompt,
stream: true,
messages: messages.map(({ role, content }) => ({ role, content })), // 携带历史消息(上下文对话)
temperature: 0.7, // 企业级参数(可控随机性)
}),
signal: controller.signal, // 绑定中断信号
});
// 企业级 HTTP 异常捕获(401/403/429/500 等,fetch 不会自动抛错)
if (!response.ok) {
const errMsg = response.status === 401
? 'Token 过期,请重新登录'
: `请求失败(${response.status}):${response.statusText}`;
throw new Error(errMsg);
}
// 浏览器兼容性判断(企业级兼容处理)
if (!response.body) {
throw new Error('当前浏览器不支持流式交互,请升级浏览器');
}
// 初始化流读取器和解码器(避免中文乱码)
const reader = response.body.getReader();
const decoder = new TextDecoder('utf-8');
// 循环读取流数据(核心逻辑)
while (true) {
const { done, value } = await reader.read();
// 流结束:处理剩余缓存(防止最后几个字丢失,企业级兜底)
if (done) {
if (buffer.trim()) {
parseSSELine(buffer);
}
// 更新 AI 消息状态(结束 loading)
startTransition(() => {
setMessages(prev => {
const copy = [...prev];
const lastMsg = copy.at(-1);
if (lastMsg) lastMsg.loading = false;
return copy;
});
});
break;
}
// 解码并拼接缓存(关键:不加 stream:true,避免中文乱码)
buffer += decoder.decode(value);
// 按换行拆分,只处理完整行,不完整行留在缓存(解决分包/粘包)
const lines = buffer.split('\n');
buffer = lines.pop() || '';
// 逐行解析 SSE 数据(标准格式)
lines.forEach(line => parseSSELine(line));
}
} catch (err) {
// 企业级异常分类处理
let errTip = '';
if (err.name === 'AbortError') {
errTip = '\n\n[已停止输出]';
} else {
errTip = `\n\n[加载失败:${err.message}]`;
setError(err.message); // 显示异常提示,支持重试
}
// 更新异常状态和消息
startTransition(() => {
setMessages(prev => {
const copy = [...prev];
const lastMsg = copy.at(-1);
if (lastMsg) {
lastMsg.content += errTip;
lastMsg.loading = false;
}
return copy;
});
});
} finally {
// 清理状态(企业级收尾,防止内存泄漏)
setLoading(false);
abortRef.current = null;
}
// 解析单行 SSE 数据(标准 SSE 格式:data: xxx)
function parseSSELine(line) {
const trimLine = line.trim();
// 过滤非标准 SSE 行(避免解析报错)
if (!trimLine.startsWith('data: ')) return;
// 提取核心数据(去掉 data: 前缀)
const dataStr = trimLine.replace('data: ', '').trim();
// 流结束标志(后端标准返回 [DONE])
if (dataStr === '[DONE]') return;
try {
// 解析 JSON(兼容主流大模型格式:delta.content / content)
const data = JSON.parse(dataStr);
const content = data.delta?.content || data.content || '';
if (content) {
fullText += content;
// 并发优化:标记为非紧急更新,避免卡顿(企业级性能优化)
startTransition(() => {
setMessages(prev => {
const copy = [...prev];
const lastMsg = copy.at(-1);
if (lastMsg) lastMsg.content = fullText;
return copy;
});
});
}
} catch (e) {
// 忽略分包导致的不完整 JSON(企业级容错,不影响整体流程)
console.warn('SSE 解析警告(分包忽略):', dataStr);
}
}
};
// 回车发送(Shift+Enter 换行,豆包官网同款交互)
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendStream();
}
};
return (
<div className="ai-chat-container">
{/* 聊天头部(企业级布局,对标豆包) */}
<div className="chat-header">
<h3>AI 流式对话(豆包官网同款)</h3>
<button onClick={clearChat} disabled={loading} className="clear-btn">
清空记录
</button>
</div>
{/* 异常提示(企业级用户体验) */}
{error && (
<div className="error-tip">
{error}
<button onClick={() => retrySend(messages.at(-2)?.content || '')} className="retry-btn">
重试
</button>
</div>
)}
{/* 聊天消息列表(企业级样式,区分用户/AI) */}
<div className="chat-message-list">
{messages.length === 0 ? (
<div className="empty-chat">
<p>发送消息,开始 AI 对话...</p>
</div>
) : (
messages.map((msg, idx) => (
<div key={`${msg.timestamp}-${idx}`} className={`message-item ${msg.role}`}>
<div className="message-avatar">
{msg.role === 'user' ? '我' : 'AI'}
</div>
<div className="message-content">
<div className="content-inner">{msg.content}</div>
{/* 加载中光标(豆包官网同款闪烁效果) */}
{msg.loading && (
<span className="loading-cursor">
<span className="cursor"></span>
</span>
)}
</div>
</div>
))
)}
<div ref={chatEndRef} />
</div>
{/* 输入区域(企业级交互,对标豆包) */}
<div className="chat-input-area">
<textarea
ref={inputRef}
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="输入问题,按 Enter 发送,Shift+Enter 换行..."
disabled={loading}
className="chat-input"
rows={4}
/>
<div className="input-btn-group">
<button onClick={sendStream} disabled={loading || !inputValue.trim()} className="send-btn">
{loading ? '发送中...' : '发送'}
</button>
{loading && (
<button onClick={stopStream} className="stop-btn">
停止
</button>
)}
</div>
</div>
</div>
);
};
export default AIChatStream;
核心原理
1. 为什么必须用 buffer 缓存?
TCP 传输会出现分包、粘包:
- 一行 JSON 被切成 2 次传输
- 多行数据粘在一起
直接解析一定会报错
buffer = 碎片拼接器
2. 为什么不能加 { stream:true }?
decoder.decode(value, { stream: true })
会保留未解析完的字节,中文必乱码
3. 为什么流结束要再解析一次 buffer?
最后一段数据可能没有换行符 ,会留在缓存里不解析
→ 表现:最后几个字消失
4. SSE 标准格式
所有主流 AI 都遵循:
data: { "content": "你" }
data: { "content": "好" }
data: [DONE]
生产环境注意事项
1. 必须处理分包粘包
不加 buffer 缓存,线上必崩
这是 90% 新手翻车点
2. 高频更新必须用 useTransition 优化
javascript
startTransition(() => {
setText(fullText)
})
否则输入框、按钮会卡顿
3. 必须捕获 HTTP 错误
fetch 只有断网 才进 catch
401/403/429/500 不会报错,需要手动判断 response.ok
4. 必须支持停止输出
使用 AbortController 中断请求
否则组件销毁后仍会继续输出
5. 浏览器兼容性
- 支持:Chrome、Firefox、Edge、Safari 14.1+
- 不支持:IE、部分老旧微信浏览器
6. 避免重复请求
加 loading 锁,防止用户多次点击发送
7. 滚动优化
打字机输出时自动滚动到底部
使用 scrollIntoView 或 scrollTop
8. 异常兜底
网络中断、解析失败时给用户提示
不要让页面卡死
面试必问
1. 流式输出为什么会出现解析失败?
因为 TCP 分包,一行 JSON 被切成多段,必须用 buffer 拼接完整行才能解析。
2. React 为什么要用 useTransition?
流式输出是高频低优更新 ,会阻塞输入框、点击
useTransition 标记为非紧急更新,保证交互流畅。
3. 如何实现停止输出?
使用 AbortController 中断 fetch 请求。
4. 中文乱码怎么解决?
不要使用 { stream:true },直接解码并用 buffer 拼接。