一、问题:为什么 AI 流式输出 Markdown 会出 bug?
假设 AI 正在逐字返回一段 Markdown:
这个效果非常**粗体文字**棒
如果你在收到 **粗体 时就 dangerouslySetInnerHTML,渲染结果是:
- 粗体 的后一半丢失
- 后续文字被错误的 HTML 包裹
- 页面布局可能错乱
这会导致:
- 代码块的高亮闪烁
- 链接只显示一半
- 表格结构破碎
- 用户体验极差
二、方案一:全量重渲染(最简单)
import { marked } from 'marked';
import { useState, useRef } from 'react';
function ChatMessage({ streamText }: { streamText: string }) {
// 每次都重新渲染全部 Markdown
const html = marked.parse(streamText);
return <div dangerouslySetInnerHTML={{ __html: html }} />;
}
优点:实现简单,因为 marked 库可以容错处理不完整的 Markdown
缺点:长文本高频刷新有性能开销
适用场景:消息总长度 < 5000 字,大多数应用的首选方案。
三、方案二:块级增量渲染(推荐)
核心思路:只在完整块(段落/代码块/标题 等)结束时才渲染,避免截断。
import { useState, useRef, useEffect } from 'react';
import { marked } from 'marked';
function useStreamingMarkdown(streamText: string) {
const [blocks, setBlocks] = useState<string[]>([]);
const lastBuffer = useRef('');
useEffect(() => {
// 按双换行分割块
const allText = streamText;
const segments = allText.split('\n\n');
// 最后一个可能不完整,先不渲染
const completeBlocks = segments.slice(0, -1);
const pendingBlock = segments[segments.length - 1];
const newHtmlBlocks = completeBlocks.map((block, i) => {
if (i >= blocks.length) {
return marked.parse(block);
}
return blocks[i];
});
setBlocks(newHtmlBlocks);
lastBuffer.current = pendingBlock;
}, [streamText]);
// 流结束后渲染缓冲块
const finalize = () => {
if (lastBuffer.current) {
setBlocks(prev => [...prev, marked.parse(lastBuffer.current)]);
lastBuffer.current = '';
}
};
return { blocks, finalize };
}
优点:兼顾性能与体验,不会出现标签截断
缺点:需要额外处理代码块(三引号跨多行)
四、方案三:状态机增量解析(终极方案)
使用状态机跟踪当前解析位置,只在内容完整时输出 HTML。
type StreamState = 'normal' | 'code_fence' | 'inline_code' | 'bold';
class StreamingMarkdownParser {
private state: StreamState = 'normal';
private buffer = '';
private output = '';
feed(chunk: string): string {
this.buffer += chunk;
let newOutput = '';
for (const char of this.buffer) {
switch (this.state) {
case 'normal':
if (this.buffer.endsWith('```')) {
this.state = 'code_fence';
} else if (char === '\n' && this.buffer.includes('\n\n')) {
newOutput += this.processParagraph();
}
break;
case 'code_fence':
if (this.buffer.endsWith('\n```\n')) {
newOutput += this.processCodeBlock();
this.state = 'normal';
}
break;
}
}
this.output += newOutput;
return newOutput;
}
private processParagraph(): string {
// 段落完整,渲染
const lines = this.buffer.split('\n\n');
const paragraph = lines[0];
this.buffer = lines.slice(1).join('\n\n');
return marked.parse(paragraph);
}
private processCodeBlock(): string {
const result = marked.parse(this.buffer);
this.buffer = '';
return result;
}
}
优点:性能最优,体验最好
缺点:实现复杂,需要处理各种 Markdown 语法状态
五、生产环境推荐组合
import { marked } from 'marked';
import DOMPurify from 'dompurify';
// 配置 marked
marked.setOptions({
breaks: true, // 单换行 = <br>
gfm: true, // 表格、任务列表
highlight: (code, lang) => {
return hljs.highlightAuto(code, [lang]).value;
}
});
// 安全防护
const renderSafe = (md: string) => {
const raw = marked.parse(md);
return DOMPurify.sanitize(raw, {
ALLOWED_TAGS: ['p', 'br', 'strong', 'em', 'a', 'code', 'pre', 'ul', 'ol', 'li', 'h1', 'h2', 'h3', 'h4', 'blockquote', 'table', 'thead', 'tbody', 'tr', 'th', 'td'],
ALLOWED_ATTR: ['href', 'target', 'class']
});
};
六、总结
| 方案 | 复杂度 | 性能 | 体验 | 推荐场景 |
|---|---|---|---|---|
| 全量重渲染 | ⭐ | ⭐⭐ | ⭐⭐⭐ | 短文本、快速原型 |
| 块级增量 | ⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | 生产推荐 |
| 状态机 | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | 极致体验要求 |
实际项目中,块级增量渲染 是最佳性价比方案:实现简单、体验流畅、大模型回复也不会卡顿。
Comments 留言讨论
还没有评论,来抢个沙发,聊聊你的看法~