sse流式数据配合markdown渲染实现ai对话打字机效果,关键在于按语义块(句子/段落/token)累积、实时更新与安全渲染,需用fetch-event-source替代eventsource、增量解析markdown、控制渲染节奏并智能滚动。

要让SSE流式数据在前端配合Markdown渲染器呈现AI对话的“打字机效果”,关键不是单纯逐字渲染,而是按语义块(如句子、段落或token)拼接 → 实时更新 → 渲染为带格式的Markdown内容。整个流程需兼顾流式解析、文本累积、安全渲染与视觉反馈。
一、SSE连接必须支持POST和自定义请求头
浏览器原生 EventSource 只支持 GET,且无法携带 Authorization、Content-Type 等关键请求头,不适合真实AI接口(OpenAI/Ollama等均要求 POST + token)。
✅ 正确做法:用 @microsoft/fetch-event-source 或 uni.request({ enableChunked: true })(小程序端)
import { fetchEventSource } from '@microsoft/fetch-event-source';
let currentContent = '';
const streamingRef = useRef(null);
fetchEventSource('/api/chat', {
method: 'POST',
headers: { 'Authorization': `Bearer ${token}` },
body: JSON.stringify({ message: userInput }),
onmessage(event) {
try {
const data = JSON.parse(event.data);
// OpenAI格式:data.choices[0].delta.content
const delta = data?.choices?.[0]?.delta?.content || '';
currentContent += delta;
streamingRef.current = currentContent;
// 触发重新渲染(React中用setState)
setAiMessage(currentContent);
} catch (e) {
console.warn('SSE数据解析失败,跳过:', event.data);
}
},
});
⚠️ 注意:后端必须返回
Content-Type: text/event-stream,每条消息以data: {...}\n\n结尾,UTF-8编码。
二、Markdown渲染器需支持增量更新与实时高亮
不能每次全量重解析整段文本(性能差、闪烁),应:
- 使用轻量级、可增量调用的 Markdown 解析器(如
marked或markdown-it) - 避免直接
innerHTML = md.render(text)—— 会清空已有DOM并重建,打断打字动画 - 推荐方案:用
<streamingmarkdown></streamingmarkdown>组件封装,内部做「差异更新」或「光标锚定」
示例(基于 markdown-it + 自定义光标插件):
// 插件:在末尾添加闪烁光标
function cursorPlugin(md) {
md.core.ruler.after('inline', 'cursor', (state) => {
if (state.src.endsWith('\n') || !state.src.trim()) return;
const token = state.push('cursor_open', 'span', 0);
token.markup = 'cursor';
});
}
const md = markdownit({ html: true }).use(cursorPlugin);
渲染时只处理新增部分(或全量但启用 skipHtml: false + xhtmlOut: true 保证安全):
<streamingmarkdown content="{aiMessage}"></streamingmarkdown>
// 内部:md.render(aiMessage) → 返回含 <code>、<p>、<em> 的HTML 字符串
// 再用 dangerouslySetInnerHTML 渲染(确保内容可信)或用 react-markdown 安全渲染</em></p></code>
三、打字机效果 ≠ 逐字,而是“语义流+视觉节奏”
真实AI流式输出是按 token 或小段落推送(如 "Hello" → " world" → "!"),直接逐字渲染会卡顿、断句奇怪。更合理的方式:
- ✅ 按自然停顿(逗号、句号、换行)做「软暂停」
- ✅ 用
setTimeout或requestAnimationFrame控制最小渲染间隔(如 ≥30ms/次),避免过快闪动 - ✅ 渲染前对
currentContent做简单清理:去掉未闭合的<code>、截断不完整 Markdown 行
// 简单防抖 + 断句优化
const safeRender = (raw) => {
// 移除孤立反引号、未闭合的链接等(避免渲染错误)
let cleaned = raw.replace(/`[^`]*$/g, '`'); // 补全单个反引号
cleaned = cleaned.replace(/\*\*[^*]*$/g, '**'); // 同理粗体
return cleaned;
};
setAiMessage(safeRender(currentContent));
四、滚动与交互体验细节不能忽略
用户正在看历史消息时,自动滚动到底部会打断阅读:
- 监听聊天容器
scrollTop与scrollHeight差值 - 仅当容器已滚动到底部(
scrollTop + clientHeight >= scrollHeight - 2)才触发scrollIntoView() - 提供「新消息提示」按钮(如右下角小红点),点击再滚动
useEffect(() => {
if (isAtBottom && aiMessage) {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}
}, [aiMessage]);
SSE + Markdown 打字机效果本质是「流式文本管道」:接收 → 累积 → 清洗 → 解析 → 渲染 → 滚动。每环都影响最终观感,但核心逻辑清晰——别把SSE当成逐字信号源,而应视作语义片段流;别把Markdown当成静态转换器,而应看作可响应式更新的富文本引擎。
Java免费学习笔记:立即使用
解锁 Java 大师之旅:从入门到精通的终极指南











