跳到主要内容

流式渲染与 SSE

问题

AI 应用中如何实现类似 ChatGPT 的流式打字机效果?SSE 和 ReadableStream 在流式渲染中如何配合?如何优化流式 Markdown 渲染性能?

答案

流式渲染是 AI 产品体验的生命线——LLM 生成一段 500 字的回答可能需要 5-10 秒,如果等全部生成完再显示,用户会认为应用「卡住了」。流式渲染让用户在 TTFT(首 Token 时间,通常 0.5-1.5 秒)后立即看到内容逐步出现,心理等待感大幅降低。

本文深入讲解从 SSE 协议、ReadableStream API、服务端流式转发、流式 Markdown 渲染到渲染性能优化的全栈实现。

一、流式传输方案对比

方案优点缺点AI 应用适用性
EventSource(原生 SSE)内置自动重连、浏览器原生支持只能 GET、不能自定义 Header/Body❌ 不适合(需要 POST 发 messages)
fetch + ReadableStream支持 POST、可自定义 Header/Body、灵活需手动解析 SSE 格式、无自动重连AI 应用首选
WebSocket双向通信、低延迟需要维护长连接、部署复杂⚠️ 可选(实时协作场景)
轮询最简单、兼容性最好延迟高、浪费带宽❌ 不适合
为什么 AI 应用几乎都用 fetch + ReadableStream?

三个核心原因:

  1. 需要 POST:LLM 请求需要发送完整的 messages body(可能很大),GET 请求有 URL 长度限制
  2. 需要自定义 Header:认证信息(Authorization)、Provider 标识等
  3. 单向足够:LLM 生成是纯粹的 Server → Client 单向推送,不需要 WebSocket 的双向能力

二、SSE 协议详解

SSE(Server-Sent Events)是 HTTP 上的轻量级流协议。理解其格式是解析流式响应的基础。

SSE 数据格式规范

// SSE 每个事件由一个或多个 field: value 行组成
// 事件之间用空行(两个换行 \n\n)分隔

// 完整格式(所有可选字段):
event: message_delta // 事件类型(可选,默认 "message")
id: 42 // 事件 ID(可选,用于断点续传)
retry: 3000 // 重连间隔(ms,可选)
data: {"content": "Hello"} // 事件数据(必须,可多行)

// OpenAI 格式(只用 data 字段):
data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"你"}}]}

data: {"id":"chatcmpl-xxx","choices":[{"delta":{"content":"好"}}]}

data: [DONE]

// Anthropic 格式(event + data 组合):
event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"你"}}

event: content_block_delta
data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"好"}}

event: message_stop
data: {"type":"message_stop"}

服务端发送 SSE(Next.js Route Handler)

app/api/chat/route.ts
export async function POST(request: Request): Promise<Response> {
const { messages } = await request.json();

// 创建一个 TransformStream 用于向客户端发送 SSE 数据
const encoder = new TextEncoder();
const stream = new TransformStream();
const writer = stream.writable.getWriter();

// 异步处理 LLM 响应(不阻塞 Response 返回)
(async () => {
try {
const llmResponse = await fetch('https://api.openai.com/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
},
body: JSON.stringify({
model: 'gpt-4o',
messages,
stream: true, // LLM API 的 stream 参数:让模型以 SSE 格式逐 token 返回
stream_options: { include_usage: true },
}),
});

const reader = llmResponse.body!.getReader();
const decoder = new TextDecoder();

// 直接透传 LLM 的流式数据给客户端
while (true) {
const { done, value } = await reader.read();
if (done) break;
await writer.write(value); // 逐块转发
}
} catch (error) {
// 在流中发送错误信息
const errorEvent = `data: ${JSON.stringify({ error: (error as Error).message })}\n\n`;
await writer.write(encoder.encode(errorEvent));
} finally {
await writer.close();
}
})();

// 立即返回 Response,流数据将异步推送
return new Response(stream.readable, {
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache, no-transform', // 禁止缓存和压缩
Connection: 'keep-alive',
'X-Accel-Buffering': 'no', // Nginx 禁止缓冲
},
});
}
Nginx/CDN 缓冲问题

Nginx 默认会缓冲上游响应(proxy_buffering on),导致流式数据被攒到一定大小才发送,用户看不到逐字效果。解决方案:

  • 响应头加 X-Accel-Buffering: no(Nginx 特有)
  • Nginx 配置:proxy_buffering off;
  • Cloudflare/Vercel 等 CDN 通常已自动处理 SSE

三、前端 SSE 解析

通用 SSE 行读取器

lib/sse-reader.ts
/**
* 从 fetch Response 的 ReadableStream 中逐行读取 SSE 数据
* 处理三个关键问题:
* 1. 粘包:一次 read() 可能包含多条 SSE 事件
* 2. 分包:一条事件可能跨越多次 read()
* 3. 多字节字符:中文/emoji 可能被截断在两次 read() 之间
*/
async function* readSSELines(response: Response): AsyncGenerator<string> {
const reader = response.body!.getReader();
// ⚠️ 这里的 { stream: true } 是 TextDecoder.decode() 的参数,不是 LLM API 的 stream 选项
// 作用:确保多字节字符的中间字节不会产生乱码
// 例如 "你" 的 UTF-8 编码是 3 字节 [0xE4, 0xBD, 0xA0]
// 如果 read() 恰好在 [0xE4, 0xBD] 处截断,stream: true 会暂存不完整的字节
// 等下次 decode() 传入 [0xA0] 后再正确输出完整字符
const decoder = new TextDecoder();
let buffer = '';

try {
while (true) {
const { done, value } = await reader.read();
if (done) break;

// { stream: true } 是 TextDecoder 的选项,告诉它输入未结束,保留不完整字节
buffer += decoder.decode(value, { stream: true });

// 按换行分割
const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整

for (const line of lines) {
const trimmed = line.trim();
if (trimmed) yield trimmed;
}
}

// flush decoder(处理最后可能残留的不完整字节)
buffer += decoder.decode();

if (buffer.trim()) yield buffer.trim();
} finally {
reader.releaseLock();
}
}

// OpenAI 格式解析器
async function* parseOpenAIStream(
response: Response
): AsyncGenerator<{ type: 'text' | 'done'; content: string }> {
for await (const line of readSSELines(response)) {
if (!line.startsWith('data: ')) continue;

const data = line.slice(6);
if (data === '[DONE]') {
yield { type: 'done', content: '' };
return;
}

try {
const chunk = JSON.parse(data);
const content = chunk.choices?.[0]?.delta?.content;
if (content) {
yield { type: 'text', content };
}
} catch {
// 跳过解析失败的行
}
}
}

SSE 解析库推荐

手写 SSE 行读取器适合理解原理,但生产环境建议使用成熟的库:

库名特点适用场景
eventsource-parser最轻量,只做 SSE 协议解析,Vercel AI SDK 内部使用需要灵活控制传输层
fetch-event-source微软出品,基于 fetch 的 EventSource polyfill,支持 POST/Header/重连需要完整 EventSource 替代方案
sse.jsEventSource 增强版,支持 POST 和自定义 Header轻量替代 EventSource
Vercel AI SDK全栈方案,封装 SSE + 解析 + React Hooks + 多 ProviderAI 应用一站式开发
使用 eventsource-parser 示例
import { createParser, type EventSourceMessage } from 'eventsource-parser';

async function parseSSEWithLib(response: Response): Promise<string> {
let result = '';

// createParser 接收一个回调,每解析出一个完整 SSE 事件就调用
// 它内部处理了粘包、分包、多行 data 等边界情况
const parser = createParser((event: EventSourceMessage) => {
if (event.data === '[DONE]') return;
try {
const chunk = JSON.parse(event.data);
const content = chunk.choices?.[0]?.delta?.content;
if (content) result += content;
} catch { /* skip */ }
});

const reader = response.body!.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;
// 喂入原始文本,parser 自动按 SSE 规范拆分事件
parser.feed(decoder.decode(value, { stream: true }));
}

return result;
}
选型建议
  • 只需解析 SSE:用 eventsource-parser(~2KB,零依赖)
  • 需要重连和完整 EventSource 能力:用 fetch-event-source
  • 做 AI 产品:直接用 Vercel AI SDK,它封装了 SSE 解析 + 流式渲染 + React Hooks + 多模型 Provider 适配,是目前最完整的方案

完整的流式消费 Hook

hooks/useStreamChat.ts
import { useState, useCallback, useRef, useReducer } from 'react';

// 流状态机
type StreamStatus = 'idle' | 'connecting' | 'streaming' | 'done' | 'error' | 'aborted';

interface StreamState {
status: StreamStatus;
content: string;
thinking: string;
error: string | null;
startTime: number;
firstTokenTime: number;
}

type StreamAction =
| { type: 'START' }
| { type: 'FIRST_TOKEN' }
| { type: 'TEXT'; payload: string }
| { type: 'THINKING'; payload: string }
| { type: 'DONE' }
| { type: 'ERROR'; payload: string }
| { type: 'ABORT' }
| { type: 'RESET' };

function streamReducer(state: StreamState, action: StreamAction): StreamState {
switch (action.type) {
case 'START':
return { ...state, status: 'connecting', startTime: performance.now(), content: '', thinking: '', error: null };
case 'FIRST_TOKEN':
return { ...state, status: 'streaming', firstTokenTime: performance.now() };
case 'TEXT':
return { ...state, content: state.content + action.payload };
case 'THINKING':
return { ...state, thinking: state.thinking + action.payload };
case 'DONE':
return { ...state, status: 'done' };
case 'ERROR':
return { ...state, status: 'error', error: action.payload };
case 'ABORT':
return { ...state, status: 'aborted' };
case 'RESET':
return { status: 'idle', content: '', thinking: '', error: null, startTime: 0, firstTokenTime: 0 };
default:
return state;
}
}

export function useStreamChat(apiUrl: string) {
const [state, dispatch] = useReducer(streamReducer, {
status: 'idle',
content: '',
thinking: '',
error: null,
startTime: 0,
firstTokenTime: 0,
});

const abortRef = useRef<AbortController | null>(null);
// RAF 批量更新缓冲区:合并高频 token 到一帧内的一次 setState
const textBufferRef = useRef('');
const thinkingBufferRef = useRef('');
const rafRef = useRef<number>(0);

// 将 buffer 刷入 state(在 RAF 回调中执行)
const flushBuffer = useCallback(() => {
if (textBufferRef.current) {
dispatch({ type: 'TEXT', payload: textBufferRef.current });
textBufferRef.current = '';
}
if (thinkingBufferRef.current) {
dispatch({ type: 'THINKING', payload: thinkingBufferRef.current });
thinkingBufferRef.current = '';
}
rafRef.current = 0;
}, []);

// 追加 token 到 buffer,下一帧统一更新
const appendToken = useCallback((type: 'text' | 'thinking', content: string) => {
if (type === 'text') {
textBufferRef.current += content;
} else {
thinkingBufferRef.current += content;
}

if (!rafRef.current) {
rafRef.current = requestAnimationFrame(flushBuffer);
}
}, [flushBuffer]);

const send = useCallback(async (messages: Array<{ role: string; content: string }>) => {
dispatch({ type: 'START' });
abortRef.current = new AbortController();
let isFirstToken = true;

try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ messages }),
signal: abortRef.current.signal,
});

if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${await response.text()}`);
}

for await (const line of readSSELines(response)) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;

try {
const chunk = JSON.parse(data);
const textContent = chunk.choices?.[0]?.delta?.content;

if (textContent) {
if (isFirstToken) {
dispatch({ type: 'FIRST_TOKEN' });
isFirstToken = false;
}
appendToken('text', textContent);
}
} catch { /* skip */ }
}

// 刷入最后的 buffer
if (rafRef.current) cancelAnimationFrame(rafRef.current);
flushBuffer();
dispatch({ type: 'DONE' });
} catch (error) {
if ((error as Error).name === 'AbortError') {
flushBuffer();
dispatch({ type: 'ABORT' });
} else {
dispatch({ type: 'ERROR', payload: (error as Error).message });
}
}
}, [apiUrl, appendToken, flushBuffer]);

const stop = useCallback(() => {
abortRef.current?.abort();
}, []);

// TTFT 和 TPS 指标
const metrics = {
ttft: state.firstTokenTime > 0 ? state.firstTokenTime - state.startTime : null,
tps: state.firstTokenTime > 0 && state.content.length > 0
? (state.content.length / ((performance.now() - state.firstTokenTime) / 1000))
: null,
};

return { ...state, send, stop, metrics };
}

四、流式 Markdown 渲染

AI 输出通常是 Markdown 格式。流式接收时 Markdown 可能处于不完整状态(代码块未闭合、链接写了一半),需要实时修补。

核心挑战

// 流式接收过程中的不完整 Markdown 示例:
//
// Chunk 1: "## 标题\n\n下面是代码:\n\n```typ"
// Chunk 2: "escript\nconst x = 1;\nconst y"
// Chunk 3: " = 2;\n```\n\n这段代码"
//
// 问题:
// 1. Chunk 1 结尾的 ```typ 是一个未闭合的代码块,后续文本会被当成代码
// 2. 行内代码 `foo 如果被截断,反引号不配对会导致渲染混乱
// 3. 链接 [text](http://exa 被截断,不应该渲染为半截链接
// 4. 粗体 **impor 被截断,后面的文字都变粗体

流式 Markdown 修补器

lib/markdown-patcher.ts
/**
* 修补流式传输中不完整的 Markdown
* 原则:宁可多闭合,不能让标记泄漏影响后续内容
*/
export function patchIncompleteMarkdown(text: string): string {
let result = text;

// 1. 修补未闭合的代码块(最重要!)
// 统计 ``` 出现次数,奇数说明有未闭合的代码块
const codeBlockMatches = result.match(/```/g);
if (codeBlockMatches && codeBlockMatches.length % 2 !== 0) {
result += '\n```';
}

// 2. 修补未闭合的行内代码
// 排除代码块内的反引号(简化处理:只在代码块外计数)
const outsideCodeBlocks = result.replace(/```[\s\S]*?```/g, '');
const backtickCount = (outsideCodeBlocks.match(/`/g) || []).length;
if (backtickCount % 2 !== 0) {
result += '`';
}

// 3. 修补未闭合的粗体
const boldCount = (outsideCodeBlocks.match(/\*\*/g) || []).length;
if (boldCount % 2 !== 0) {
result += '**';
}

// 4. 修补未闭合的斜体(单个 *)
const italicCount = (outsideCodeBlocks.match(/(?<!\*)\*(?!\*)/g) || []).length;
if (italicCount % 2 !== 0) {
result += '*';
}

// 5. 移除末尾不完整的链接
// [text](url... → 保留 text 文本,移除链接标记
result = result.replace(/\[([^\]]*)\]\([^)]*$/, '$1');
// [text... → 移除不完整的链接开始标记
result = result.replace(/\[[^\]]*$/, '');

// 6. 移除末尾不完整的图片
result = result.replace(/!\[[^\]]*$/, '');
result = result.replace(/!\[([^\]]*)\]\([^)]*$/, '');

return result;
}

StreamMarkdown 组件

components/StreamMarkdown.tsx
import { useMemo, memo } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { oneDark } from 'react-syntax-highlighter/dist/esm/styles/prism';

interface StreamMarkdownProps {
content: string;
isStreaming: boolean;
}

export const StreamMarkdown = memo(function StreamMarkdown({
content,
isStreaming,
}: StreamMarkdownProps) {
// 流式过程中修补不完整的 Markdown
const processedContent = useMemo(() => {
if (!isStreaming) return content;
return patchIncompleteMarkdown(content);
}, [content, isStreaming]);

return (
<div className="prose prose-sm dark:prose-invert max-w-none">
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
// 代码块:区分行内和块级
code({ className, children, ...props }) {
const match = /language-(\w+)/.exec(className || '');

// 块级代码:带语法高亮
if (match) {
return (
<div className="relative group">
<SyntaxHighlighter
language={match[1]}
style={oneDark}
PreTag="div"
customStyle={{ margin: 0, borderRadius: '0.5rem' }}
>
{String(children).replace(/\n$/, '')}
</SyntaxHighlighter>
{/* highlight-start */}
{/* 流式过程中不显示复制按钮(代码还在变化) */}
{!isStreaming && <CopyButton text={String(children)} />}
{/* highlight-end */}
</div>
);
}

// 行内代码
return (
<code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm" {...props}>
{children}
</code>
);
},

// 表格增强
table({ children }) {
return (
<div className="overflow-x-auto">
<table className="min-w-full">{children}</table>
</div>
);
},
}}
>
{processedContent}
</ReactMarkdown>

{/* 流式光标 */}
{isStreaming && <StreamingCursor />}
</div>
);
});

// 复制按钮
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);

return (
<button
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity
bg-gray-700 text-white text-xs px-2 py-1 rounded"
onClick={() => {
navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}}
>
{copied ? '已复制' : '复制'}
</button>
);
}

五、打字机光标效果

components/StreamingCursor.tsx
export function StreamingCursor() {
return <span className="streaming-cursor"></span>;
}
styles/streaming-cursor.css
.streaming-cursor {
display: inline-block;
animation: cursor-blink 0.8s step-end infinite;
color: var(--ifm-color-primary, #3b82f6);
font-weight: bold;
margin-left: 1px;
}

@keyframes cursor-blink {
0%, 50% { opacity: 1; }
51%, 100% { opacity: 0; }
}

六、流式渲染性能优化

问题分析

LLM 流式输出的频率约 30-100 token/秒(每 10-30ms 一个 chunk)。如果每个 chunk 都触发 React 重渲染,会导致:

  • setState 调用 30-100 次/秒
  • 每次都触发 Markdown 解析和 DOM diff
  • 在低端设备上出现卡顿、掉帧

方案 1:requestAnimationFrame 批量更新(推荐)

hooks/useStreamBuffer.ts
import { useState, useCallback, useRef } from 'react';

/**
* 使用 RAF 将高频 token 合并到 ~60fps 的渲染帧中
* 效果:原本 100 次/秒 setState → 约 60 次/秒(且与浏览器渲染同步)
*/
export function useStreamBuffer() {
const [content, setContent] = useState('');
const bufferRef = useRef('');
const rafRef = useRef<number>(0);

const append = useCallback((chunk: string) => {
// 累积到 buffer 中
bufferRef.current += chunk;

// 在下一帧统一刷入 state
if (!rafRef.current) {
rafRef.current = requestAnimationFrame(() => {
setContent(prev => prev + bufferRef.current);
bufferRef.current = '';
rafRef.current = 0;
});
}
}, []);

// 立即刷入(流结束时调用)
const flush = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
if (bufferRef.current) {
setContent(prev => prev + bufferRef.current);
bufferRef.current = '';
}
rafRef.current = 0;
}, []);

const reset = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
bufferRef.current = '';
rafRef.current = 0;
setContent('');
}, []);

return { content, append, flush, reset };
}

方案 2:已完成消息 memo 化

components/MessageList.tsx
import { memo } from 'react';

// 核心思路:只有最后一条正在流式输出的消息需要频繁重渲染
// 历史消息的 content 不会变化,使用 React.memo 完全跳过重渲染

const MessageItem = memo(function MessageItem({ message }: { message: Message }) {
return (
<div className={`message message-${message.role}`}>
<StreamMarkdown
content={message.content}
isStreaming={message.status === 'streaming'}
/>
</div>
);
});

function MessageList({ messages }: { messages: Message[] }) {
return (
<div className="message-list">
{messages.map(msg => (
// key 使用稳定的 id,确保 memo 生效
<MessageItem key={msg.id} message={msg} />
))}
</div>
);
}

方案 3:代码块延迟高亮

// 流式过程中代码块一直在变化,语法高亮计算开销大
// 策略:流式中用纯文本渲染代码块,完成后再做语法高亮

function CodeBlock({ code, language, isStreaming }: {
code: string;
language: string;
isStreaming: boolean;
}) {
if (isStreaming) {
// 流式中:纯文本 + 背景色,不做语法高亮
return (
<pre className="bg-gray-900 text-gray-100 p-4 rounded-lg overflow-x-auto">
<code>{code}</code>
</pre>
);
}

// 完成后:完整语法高亮
return (
<SyntaxHighlighter language={language} style={oneDark}>
{code}
</SyntaxHighlighter>
);
}

七、Thinking 流展示(推理模型)

推理模型(Claude extended thinking、OpenAI o3、DeepSeek R1)会先输出「思考过程」再输出最终回答。前端需要区分展示。

components/ThinkingBlock.tsx
import { useState, useMemo } from 'react';

interface ThinkingBlockProps {
thinking: string;
isThinking: boolean; // 是否正在思考中
thinkingTime?: number; // 思考耗时 ms
}

export function ThinkingBlock({ thinking, isThinking, thinkingTime }: ThinkingBlockProps) {
const [isExpanded, setIsExpanded] = useState(false);

if (!thinking) return null;

// 思考过程通常很长,截取摘要显示
const summary = useMemo(() => {
if (thinking.length <= 100) return thinking;
return thinking.slice(0, 100) + '...';
}, [thinking]);

return (
<div className="thinking-block border-l-4 border-purple-300 bg-purple-50 dark:bg-purple-900/20 rounded-r-lg my-2">
<button
className="w-full px-4 py-2 flex items-center justify-between text-sm text-purple-700 dark:text-purple-300 hover:bg-purple-100 dark:hover:bg-purple-900/30 transition-colors"
onClick={() => setIsExpanded(!isExpanded)}
>
<span className="flex items-center gap-2">
{isThinking ? (
<>
<span className="animate-spin">⚙️</span>
思考中...
</>
) : (
<>
💭 思考过程
{thinkingTime && (
<span className="text-xs text-purple-400">
({(thinkingTime / 1000).toFixed(1)}s)
</span>
)}
</>
)}
</span>
<span className="text-xs">{isExpanded ? '▼' : '▶'}</span>
</button>

{!isExpanded && !isThinking && (
<p className="px-4 pb-2 text-xs text-purple-400 italic">{summary}</p>
)}

{isExpanded && (
<div className="px-4 pb-3 text-sm text-gray-600 dark:text-gray-400 whitespace-pre-wrap">
{thinking}
{isThinking && <StreamingCursor />}
</div>
)}
</div>
);
}

八、流状态机

用状态机管理流的完整生命周期,确保状态转换清晰可控:

九、性能指标监控

lib/stream-metrics.ts
interface StreamMetrics {
ttft: number; // Time to First Token(首 token 延迟,目标 < 1s)
tps: number; // Tokens per Second(生成速度,目标 > 30)
totalTime: number; // 总耗时
contentLength: number; // 最终内容字符数
renderFPS: number; // 渲染帧率
}

// 测量流式性能
function useStreamMetrics() {
const startTimeRef = useRef(0);
const firstTokenTimeRef = useRef(0);
const tokenCountRef = useRef(0);
const frameCountRef = useRef(0);
const fpsTimerRef = useRef(0);

const start = () => {
startTimeRef.current = performance.now();
firstTokenTimeRef.current = 0;
tokenCountRef.current = 0;

// 监控渲染帧率。
// 正常情况下,浏览器会在每次重绘前调用 rAF 回调,数回调次数就是数帧数。
// 主线程繁忙时,浏览器会跳帧。
// fps < 60 说明存在卡顿。
let lastTime = performance.now();
const measureFPS = () => {
frameCountRef.current++;
fpsTimerRef.current = requestAnimationFrame(measureFPS);
};
fpsTimerRef.current = requestAnimationFrame(measureFPS);
};

const onToken = () => {
if (!firstTokenTimeRef.current) {
firstTokenTimeRef.current = performance.now();
}
tokenCountRef.current++;
};

const getMetrics = (): StreamMetrics => {
cancelAnimationFrame(fpsTimerRef.current);
const now = performance.now();
const totalTime = now - startTimeRef.current;
const streamTime = now - (firstTokenTimeRef.current || now);

return {
ttft: firstTokenTimeRef.current - startTimeRef.current,
tps: streamTime > 0 ? (tokenCountRef.current / streamTime) * 1000 : 0,
totalTime,
contentLength: 0, // 由调用方填充
renderFPS: totalTime > 0 ? (frameCountRef.current / totalTime) * 1000 : 0,
};
};

return { start, onToken, getMetrics };
}

// 在开发者模式下展示指标
function MetricsDisplay({ metrics }: { metrics: StreamMetrics | null }) {
if (!metrics) return null;

return (
<div className="text-xs text-gray-400 flex gap-3 mt-1">
<span>TTFT: {metrics.ttft.toFixed(0)}ms</span>
<span>Speed: {metrics.tps.toFixed(1)} tok/s</span>
<span>Total: {(metrics.totalTime / 1000).toFixed(1)}s</span>
<span>FPS: {metrics.renderFPS.toFixed(0)}</span>
</div>
);
}

十、长文本流式输出 DOM 优化

当 AI 生成一篇超长回答(如 5000+ 字),流式渲染会持续往 DOM 中追加内容,导致 DOM 节点数爆炸、重排开销剧增、页面卡顿。这是 AI 聊天产品必须面对的性能瓶颈。

问题分析

// 一条 5000 字的消息经 Markdown 渲染后可能产生:
// - 200+ 个 <p>、<li>、<pre>、<table> 等块级元素
// - 500+ 个 <code>、<strong>、<a> 等行内元素
// - 每次 setState → react-markdown 重新解析全文 → diff 整棵子树
// - 代码块语法高亮:每次都重新 tokenize 全部代码
//
// 结果:渲染帧耗时 50-200ms,FPS 降到 10-20

方案 1:分块渲染(Chunked Rendering)

将长消息按段落/代码块拆分为多个独立组件,只有最后一个 chunk 在流式更新,前面的 chunk 内容已稳定,用 React.memo 跳过重渲染。

components/ChunkedMessage.tsx
import { useMemo, memo } from 'react';

interface Chunk {
id: string;
content: string;
isComplete: boolean; // 该段落是否已完整接收
}

// 将流式文本按段落/代码块边界拆分
function splitIntoChunks(text: string): Chunk[] {
// 按双换行(段落)和代码块边界拆分
const parts = text.split(/(\n\n|(?=```)|(?<=```\n[\s\S]*?```))/);
const chunks: Chunk[] = [];
let current = '';
let id = 0;

for (const part of parts) {
if (part === '\n\n' && current.trim()) {
chunks.push({ id: `chunk-${id++}`, content: current.trim(), isComplete: true });
current = '';
} else {
current += part;
}
}

// 最后一个 chunk 可能还在流式接收中
if (current.trim()) {
chunks.push({ id: `chunk-${id}`, content: current.trim(), isComplete: false });
}

return chunks;
}

// 已完成的 chunk:memo 化,永不重渲染
const StableChunk = memo(function StableChunk({ content }: { content: string }) {
return <StreamMarkdown content={content} isStreaming={false} />;
});

// 流式 chunk:正常渲染
function StreamingChunk({ content }: { content: string }) {
return <StreamMarkdown content={content} isStreaming={true} />;
}

export function ChunkedMessage({ text, isStreaming }: { text: string; isStreaming: boolean }) {
const chunks = useMemo(() => splitIntoChunks(text), [text]);

return (
<div className="message-content">
{chunks.map((chunk, i) => {
const isLast = i === chunks.length - 1;
// 只有最后一个 chunk 需要流式渲染,前面的全部 memo 化
return isLast && isStreaming ? (
<StreamingChunk key={chunk.id} content={chunk.content} />
) : (
<StableChunk key={chunk.id} content={chunk.content} />
);
})}
</div>
);
}

方案 2:虚拟滚动(Virtual Scrolling)

对于超长消息(如 AI 生成完整文章),可以按段落虚拟化,只渲染可视区域的段落

components/VirtualMessage.tsx
import { useRef, useState, useEffect, useCallback } from 'react';

interface ParagraphMeta {
content: string;
estimatedHeight: number; // 预估高度
measuredHeight?: number; // 实际测量高度
}

export function VirtualMessage({ text }: { text: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const [visibleRange, setVisibleRange] = useState({ start: 0, end: 10 });

// 将文本拆分为段落并估算高度
const paragraphs: ParagraphMeta[] = text.split('\n\n').map(content => ({
content,
// 估算:每行约 20px,每 40 字符一行,代码块额外加高
estimatedHeight: content.startsWith('```')
? Math.max(content.split('\n').length * 22 + 40, 80)
: Math.max(Math.ceil(content.length / 40) * 22, 30),
}));

const totalHeight = paragraphs.reduce(
(sum, p) => sum + (p.measuredHeight ?? p.estimatedHeight), 0
);

// IntersectionObserver 计算可视范围
const handleScroll = useCallback(() => {
if (!containerRef.current) return;
const { scrollTop, clientHeight } = containerRef.current;
const buffer = clientHeight; // 上下各多渲染一屏

let accHeight = 0;
let start = 0;
let end = paragraphs.length;

for (let i = 0; i < paragraphs.length; i++) {
const h = paragraphs[i].measuredHeight ?? paragraphs[i].estimatedHeight;
if (accHeight + h < scrollTop - buffer) start = i + 1;
if (accHeight > scrollTop + clientHeight + buffer) { end = i; break; }
accHeight += h;
}

setVisibleRange({ start, end });
}, [paragraphs]);

// 渲染:只渲染可视区域 + 上下缓冲区
const offsetTop = paragraphs
.slice(0, visibleRange.start)
.reduce((sum, p) => sum + (p.measuredHeight ?? p.estimatedHeight), 0);

return (
<div ref={containerRef} onScroll={handleScroll} style={{ height: '100%', overflow: 'auto' }}>
<div style={{ height: totalHeight, position: 'relative' }}>
<div style={{ transform: `translateY(${offsetTop}px)` }}>
{paragraphs.slice(visibleRange.start, visibleRange.end).map((p, i) => (
<StreamMarkdown
key={visibleRange.start + i}
content={p.content}
isStreaming={false}
/>
))}
</div>
</div>
</div>
);
}

方案 3:requestIdleCallback 分片渲染

将非关键渲染(如历史段落的语法高亮)推迟到空闲时间执行。

hooks/useIdleRender.ts
import { useState, useEffect, useRef } from 'react';

/**
* 分片渲染:先快速渲染纯文本,空闲时再逐步启用语法高亮
*/
export function useIdleHighlight(chunks: string[]) {
// 记录哪些 chunk 已完成高亮
const [highlightedSet, setHighlightedSet] = useState<Set<number>>(new Set());
const idleCallbackRef = useRef<number>(0);

useEffect(() => {
// 从后往前逐个启用高亮(最新的优先)
let index = chunks.length - 1;

function processNext(deadline: IdleDeadline) {
// 每帧只处理一个 chunk,避免抢占
while (index >= 0 && deadline.timeRemaining() > 5) {
setHighlightedSet(prev => new Set([...prev, index]));
index--;
}

if (index >= 0) {
idleCallbackRef.current = requestIdleCallback(processNext);
}
}

idleCallbackRef.current = requestIdleCallback(processNext);
return () => cancelIdleCallback(idleCallbackRef.current);
}, [chunks.length]);

return highlightedSet;
}

方案对比

方案效果复杂度适用场景
分块渲染 + memo减少 70-90% 重渲染所有 AI 聊天应用(推荐首选)
虚拟滚动DOM 节点恒定(~50 个)超长文档生成(>1 万字)
requestIdleCallback 分片首屏更快代码块多的技术回答
RAF 批量更新(已在第六节介绍)减少 setState 频率配合以上方案使用
实战建议

大多数 AI 聊天产品使用 分块渲染 + memo + RAF 批量更新 组合即可。虚拟滚动只在极端场景(生成完整技术文章)才需要。ChatGPT 和 Claude 的 Web 端都采用分块 memo 方案而非虚拟滚动。

十一、富内容渲染(LaTeX 公式 / Mermaid 图表)

大模型常返回包含 LaTeX 数学公式和 Mermaid 图表的 Markdown。流式场景下需要处理不完整的公式/图表代码,并在完成后正确渲染。

技术方案

内容类型渲染库包大小加载策略
LaTeX 公式KaTeX~300KB(含字体)按需加载
Mermaid 图表Mermaid.js~1MB按需加载
代码高亮Shiki / Prism~100KB-1MB按语言按需

react-markdown 插件配置

components/RichStreamMarkdown.tsx
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import remarkMath from 'remark-math';
import rehypeKatex from 'rehype-katex';
import 'katex/dist/katex.min.css'; // KaTeX 样式

// Mermaid 懒加载渲染组件
import { Suspense, lazy, useState, useEffect, useRef, memo } from 'react';

// Mermaid 体积大(~1MB),使用动态导入
const MermaidRenderer = lazy(() => import('./MermaidRenderer'));

export function RichStreamMarkdown({ content, isStreaming }: {
content: string;
isStreaming: boolean;
}) {
const processed = isStreaming ? patchIncompleteMarkdown(content) : content;

return (
<ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[rehypeKatex]}
components={{
code({ className, children }) {
const match = /language-(\w+)/.exec(className || '');
const codeString = String(children).replace(/\n$/, '');

// Mermaid 图表:检测 language-mermaid
if (match?.[1] === 'mermaid') {
// 流式中不渲染 Mermaid(代码不完整会导致解析错误)
if (isStreaming) {
return (
<pre className="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg">
<code className="text-sm text-gray-500">📊 图表渲染中...</code>
<code className="block mt-2 text-xs opacity-50">{codeString}</code>
</pre>
);
}
return (
<Suspense fallback={<div className="animate-pulse h-32 bg-gray-100 rounded" />}>
<MermaidRenderer chart={codeString} />
</Suspense>
);
}

// 其他代码块:语法高亮
if (match) {
return <CodeBlock code={codeString} language={match[1]} isStreaming={isStreaming} />;
}

return <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded text-sm">{children}</code>;
},
}}
>
{processed}
</ReactMarkdown>
);
}

Mermaid 异步渲染组件

components/MermaidRenderer.tsx
import { useEffect, useRef, useState, memo } from 'react';
import mermaid from 'mermaid';

// 初始化 Mermaid(全局一次)
mermaid.initialize({
startOnLoad: false,
theme: 'default',
securityLevel: 'strict', // 防止 XSS
});

// memo 化:相同图表代码不重新渲染
export default memo(function MermaidRenderer({ chart }: { chart: string }) {
const containerRef = useRef<HTMLDivElement>(null);
const [svg, setSvg] = useState<string>('');
const [error, setError] = useState<string>('');

useEffect(() => {
let cancelled = false;
const id = `mermaid-${Math.random().toString(36).slice(2)}`;

(async () => {
try {
// mermaid.render 是异步的,不会阻塞主线程
const { svg: renderedSvg } = await mermaid.render(id, chart);
if (!cancelled) setSvg(renderedSvg);
} catch (err) {
if (!cancelled) setError((err as Error).message);
}
})();

return () => { cancelled = true; };
}, [chart]);

if (error) {
return (
<pre className="bg-red-50 dark:bg-red-900/20 p-4 rounded-lg text-sm text-red-600">
<code>图表语法错误: {error}</code>
<code className="block mt-2 opacity-50">{chart}</code>
</pre>
);
}

if (!svg) {
return <div className="animate-pulse h-32 bg-gray-100 rounded" />;
}

return (
<div
ref={containerRef}
className="mermaid-container my-4 flex justify-center overflow-x-auto"
dangerouslySetInnerHTML={{ __html: svg }}
/>
);
});

LaTeX 流式修补

lib/latex-patcher.ts
/**
* 修补流式传输中不完整的 LaTeX 公式
* LaTeX 使用 $ 和 $$ 作为定界符,未闭合会导致后续文本被当作公式
*/
export function patchIncompleteLaTeX(text: string): string {
let result = text;

// 1. 修补未闭合的块级公式 $$
const blockCount = (result.match(/\$\$/g) || []).length;
if (blockCount % 2 !== 0) {
result += '\n$$';
}

// 2. 修补未闭合的行内公式 $(排除 $$)
const withoutBlock = result.replace(/\$\$/g, '');
const inlineCount = (withoutBlock.match(/\$/g) || []).length;
if (inlineCount % 2 !== 0) {
result += '$';
}

// 3. 移除末尾不完整的 \begin{} 环境
result = result.replace(/\\begin\{[^}]*$/, '');

return result;
}

// 整合到总修补函数中
export function patchIncompleteRichMarkdown(text: string): string {
let result = patchIncompleteMarkdown(text); // 基础 Markdown 修补
result = patchIncompleteLaTeX(result); // LaTeX 修补
return result;
}
LaTeX 和 Mermaid 的流式渲染策略差异
  • LaTeX 公式:可以在流式过程中实时渲染(KaTeX 渲染速度快,且修补闭合标记后能正确显示)
  • Mermaid 图表:应在流式完成后才渲染(图表语法严格,不完整代码会导致解析错误,且渲染开销大)
  • 代码块:流式中用纯文本,完成后再做语法高亮(见第六节方案 3)

常见面试问题

Q1: SSE 和 WebSocket 的区别?AI 应用为什么选 SSE?

答案

特性SSEWebSocket
通信方向单向(Server→Client)双向
协议HTTP(标准 GET/POST)独立 ws:// 协议
自动重连EventSource 内置需手动实现
数据格式文本(UTF-8)文本 + 二进制
代理兼容好(标准 HTTP 流)差(部分代理不支持升级)
连接限制HTTP/1.1 下同域 6 个无限制

AI 应用选 SSE 原因:LLM 生成过程是单向推送(Server → Client 逐 token),不需要 WebSocket 的双向能力。SSE 基于标准 HTTP,部署和代理兼容性都更好。实际上大多数 AI 应用使用 fetch + ReadableStream(SSE over POST)而非原生 EventSource,因为需要 POST 发送对话历史。

Q2: 为什么不用原生 EventSource?

答案

EventSource 有 3 个限制让它不适合 AI 应用:

  1. 只支持 GET:AI 请求需要 POST 发送 messages body(可能很大,超过 URL 限制)
  2. 不能自定义 Header:无法设置 Authorization 认证头
  3. 自动重连可能有害:EventSource 断开后自动重连并重新发送请求,对 AI 对话来说会导致重复请求和重复扣费

所以用 fetch + ReadableStream 手动解析 SSE 格式,兼顾灵活性和协议兼容性。

Q3: TextDecoder 的 stream: true 参数有什么用?

答案

stream: true 解决的是多字节字符被截断的问题。

UTF-8 编码中,中文字符需要 3 个字节,emoji 需要 4 个字节。reader.read() 返回的是原始 Uint8Array,可能恰好在多字节字符的中间截断。例如「你」的编码是 [0xE4, 0xBD, 0xA0],如果 read() 只返回 [0xE4, 0xBD],不带 stream: true 的 TextDecoder 会输出乱码字符

设置 stream: true 后,TextDecoder 会暂存不完整的字节,等下次 decode 传入后续字节后再正确输出完整字符。最后一次调用应该不带参数(decoder.decode())来 flush 残留数据。

Q4: 如何修补流式传输中不完整的 Markdown?

答案

核心策略是「计数法」——统计成对出现的标记,奇数则补闭合:

  1. 代码块:统计 ``` 出现次数,奇数则追加 ``` 闭合
  2. 行内代码:统计代码块外的 ` 数量,奇数则追加
  3. 粗体/斜体:统计 *** 的数量
  4. 链接/图片:正则匹配末尾不完整的 [text](url... 模式并移除

原则是宁可多闭合,不能让未闭合的标记泄漏到后续内容。最重要的是代码块修补——未闭合的代码块会把之后所有文本都当成代码。

Q5: 流式渲染如何避免性能问题?

答案

LLM 每秒输出 30-100 个 token,如果每个 token 都 setState → 重渲染 → Markdown 解析 → DOM diff,性能会很差。优化方案:

  1. RAF 批量更新:token 先存入 buffer,requestAnimationFrame 回调中统一 flush 到 state。一帧内收到 3-5 个 token,只触发一次重渲染
  2. 历史消息 memoReact.memo 包裹 MessageItem,只有最后一条流式消息重渲染,历史消息完全跳过
  3. 代码块延迟高亮:流式中用纯文本渲染代码,流结束后再做语法高亮(避免反复解析不完整代码)
  4. 虚拟化长消息:超长回答按段落拆分,用虚拟列表只渲染可视区域

Q6: 什么是 TTFT?为什么它是 AI 应用最重要的指标?

答案

TTFT(Time to First Token)= 从发送请求到收到第一个 token 的时间。它之所以最重要:

  • 心理临界点:研究表明用户对 1 秒内响应的容忍度远高于 3 秒以上
  • 流式的前提:TTFT 之后用户才能看到内容逐步出现
  • 大模型 TTFT 差异大:GPT-4o-mini ~200ms,GPT-4o ~500ms,o3 ~2-5s,Claude Opus ~1-3s

优化手段:

  1. 选用 TTFT 更快的模型(小模型通常更快)
  2. 边缘部署 + 就近 API region
  3. 减少 prompt token 数(input 越短,prefill 越快)
  4. 在 TTFT 期间显示「思考中」动画降低焦虑感
  5. 预请求:用户还在输入时就建立连接

Q7: 如何实现「停止生成」功能?

答案

使用 AbortController

const controller = new AbortController();
const response = await fetch('/api/chat', {
signal: controller.signal, // 关联 signal
...
});

// 用户点击停止
function handleStop() {
controller.abort(); // 立即终止 HTTP 连接
}

技术细节:

  • abort() 会让 reader.read() 抛出 AbortError
  • 需要在 catch 中区分 AbortError 和真实错误
  • 已接收的内容应该保留(不要丢弃已显示的文字)
  • 后端也会收到连接关闭信号(如果后端在转发 LLM 流,也应该 abort 上游请求以节省费用)

Q8: Nginx 反向代理导致流式响应不生效怎么办?

答案

Nginx 默认开启 proxy_buffering,会缓冲上游响应直到达到一定大小才发送给客户端,导致用户看到的是「一坨一坨」出现而非逐字。

解决方案(任选其一):

  1. 响应头:设置 X-Accel-Buffering: no(最简单)
  2. Nginx 配置
location /api/chat {
proxy_pass http://backend;
proxy_buffering off; # 关闭缓冲
proxy_cache off; # 关闭缓存
proxy_http_version 1.1; # 使用 HTTP/1.1
proxy_set_header Connection ""; # Keep-Alive
}
  1. Cloudflare:默认已处理 text/event-stream 的 Content-Type,无需额外配置

Q9: 流式传输中的粘包和分包问题怎么处理?

答案

粘包:一次 reader.read() 返回的数据包含多条完整的 SSE 事件。处理方式:按 \n 分割后逐行处理。

分包:一条 SSE 事件被拆分到两次 reader.read() 中。处理方式:维护一个 buffer 字符串,每次 read() 后将数据追加到 buffer,按 \n 分割后保留最后一行(可能不完整)作为下次的 buffer 起始值。

const lines = buffer.split('\n');
buffer = lines.pop() || ''; // 最后一行可能不完整,留到下一次

这两个问题是同一个方案解决的——split('\n') + 保留尾行 既处理了粘包的多行数据,也处理了分包的不完整数据。

Q10: 如何测量流式输出的 TPS(Tokens Per Second)?

答案

TPS 衡量 LLM 的生成速度。测量方法:

// 从第一个 token 开始计时(排除 TTFT)
const streamStart = firstTokenTime;
const streamEnd = lastTokenTime;
const tps = tokenCount / ((streamEnd - streamStart) / 1000);

注意:

  • 前端无法精确获取 token 数(一个 chunk 可能包含多个 token),通常用 chunk 数近似
  • 精确 token 数需要从后端 usage 数据获取
  • 通常 GPT-4o 约 50-80 tps,Claude Sonnet 约 40-80 tps,o3 约 20-40 tps
  • TPS 影响用户「阅读追赶」体验——如果生成速度比阅读速度快,用户需要等生成完再阅读

Q11: ReadableStream 的 tee() 方法在流式处理中有什么用?

答案

tee() 将一个 ReadableStream 分叉为两个独立的流,两者可以独立消费:

const [stream1, stream2] = response.body.tee();
// stream1 → 转发给客户端渲染
// stream2 → 后端异步统计 token / 记录日志 / 内容审计

在 AI BFF 层常用于:在不延迟响应的情况下同时做 Token 统计和日志记录。两个流共享底层 buffer,开销比发两次请求小得多。

Q12: 流式响应中如何处理 JSON 解析错误?

答案

SSE 流中偶尔会出现解析异常(不完整的 JSON、空数据、注释行等)。正确做法是 try-catch 跳过而非中断整个流:

for await (const line of readSSELines(response)) {
if (!line.startsWith('data: ')) continue;
const data = line.slice(6);
if (data === '[DONE]') break;

try {
const chunk = JSON.parse(data);
// 处理 chunk...
} catch {
// 跳过这一行,不中断流
console.warn('Failed to parse SSE chunk:', data);
}
}

一个 chunk 解析失败不影响后续 chunk,因为 SSE 事件之间是独立的。

Q13: 长文本流式输出时 DOM 节点过多导致页面卡顿,如何优化?

答案

核心问题是每次 setState 触发 react-markdown 重新解析全文并 diff 整棵子树,随着文本增长,开销线性增加。

优化方案(推荐组合使用):

  1. 分块渲染 + memo 化(最推荐):按段落/代码块边界将长消息拆分为多个独立组件。已完成的 chunk 用 React.memo 跳过重渲染,只有最后一个 chunk 参与流式更新。可减少 70-90% 的重渲染开销

  2. RAF 批量更新:将高频 token 合并到 requestAnimationFrame 回调中统一 flush,减少 setState 次数

  3. 代码块延迟高亮:流式中用纯文本渲染代码块,流结束后再做语法高亮(Prism/Shiki 解析开销大)

  4. requestIdleCallback 分片:将非关键渲染(历史段落的语法高亮)推迟到空闲帧执行

  5. 虚拟滚动(极端场景):超长文档(>1 万字)按段落虚拟化,只渲染可视区域。但大多数 AI 聊天不需要

实际产品经验:ChatGPT / Claude Web 都采用分块 memo + RAF 批量更新组合,足以应对绝大多数场景。

Q14: 如何处理大模型返回的 LaTeX 公式或 Mermaid 图表?

答案

LaTeX 公式

  • 使用 remark-math + rehype-katex 插件链(remark-math 解析 $...$$$...$$ 语法,rehype-katex 渲染为公式)
  • 流式过程中需要修补未闭合的 $ / $$ 定界符,防止后续文本被当作公式
  • KaTeX 渲染速度快(~1ms/公式),可在流式中实时渲染
  • 需要引入 katex/dist/katex.min.css(约 300KB 含字体)

Mermaid 图表

  • 检测 ```mermaid 代码块,使用 Mermaid.js 异步渲染为 SVG
  • 流式过程中不应渲染 Mermaid——图表语法严格,不完整代码会导致解析错误
  • 流式中显示占位 UI("图表渲染中..."),流结束后再调用 mermaid.render()
  • Mermaid.js 体积大(~1MB),使用 React.lazy() 动态导入

关键区别:LaTeX 可以流式实时渲染(修补闭合标记即可),Mermaid 必须等流结束后渲染。代码高亮同理,流式中用纯文本,完成后再做高亮。

Q15: 流式输出时 Markdown 渲染闪烁是什么原因?怎么解决?

答案

闪烁根因:每收到一个 token 就 setState(prev + token),触发 react-markdown 对完整文本重新解析 → 生成新 AST → React diff 整棵 DOM 子树。当文本增长到几百字时,这个过程产生大量 DOM 操作(节点销毁 + 重建),视觉上表现为"闪烁"和"跳动"。

具体闪烁场景:

场景原因表现
代码块生长token 拼接过程中 ``` 未闭合 → Markdown 解析器判定为行内代码 → 闭合后突然变成代码块文本格式瞬间切换,大幅闪动
列表/表格生长新增行导致整个列表/表格 DOM 结构重建列表项闪烁重排
公式生长$ 未配对时后续文本被误判为公式大段文字突然消失或变为公式格式
长文本全量 diff文本越长,React diff 越慢,两帧之间 DOM 差异越大整体闪动、滚动跳跃

解决方案(按优先级):

hooks/useStableStream.ts
import { useRef, useState, useCallback } from 'react';

interface StreamChunk {
id: number;
content: string;
isComplete: boolean;
}

/**
* 分块 memo 渲染 —— 解决闪烁的核心方案
* 原理:将流式文本按段落/代码块边界切分为多个 chunk,
* 已完成的 chunk 用 React.memo 冻结,只有最后一个 chunk 参与流式更新
*/
export function useStableStream() {
const [chunks, setChunks] = useState<StreamChunk[]>([]);
const bufferRef = useRef('');
const chunkIdRef = useRef(0);

const append = useCallback((token: string) => {
bufferRef.current += token;
const text = bufferRef.current;

// 按段落边界(连续两个换行)切分
const parts = text.split(/\n\n/);

const newChunks: StreamChunk[] = parts.map((part, i) => ({
id: i < parts.length - 1 ? i : chunkIdRef.current,
content: part,
isComplete: i < parts.length - 1, // 非最后一个段落视为已完成
}));

if (parts.length > chunkIdRef.current + 1) {
chunkIdRef.current = parts.length - 1;
}

setChunks(newChunks);
}, []);

return { chunks, append };
}
components/StableMarkdown.tsx
import React from 'react';
import ReactMarkdown from 'react-markdown';

// 关键:已完成的 chunk 用 memo 跳过重渲染,杜绝闪烁
const FrozenChunk = React.memo(({ content }: { content: string }) => (
<ReactMarkdown>{content}</ReactMarkdown>
));

interface StreamChunk {
id: number;
content: string;
isComplete: boolean;
}

export function StableMarkdown({ chunks }: { chunks: StreamChunk[] }) {
return (
<div className="prose">
{chunks.map((chunk) =>
chunk.isComplete ? (
// 已完成段落:memo 冻结,不再重渲染
<FrozenChunk key={chunk.id} content={chunk.content} />
) : (
// 最后一个段落:正在流式更新
<ReactMarkdown key="active">{chunk.content}</ReactMarkdown>
)
)}
</div>
);
}

其他辅助手段:

  1. RAF 批量更新:将高频 token 用 requestAnimationFrame 合并后再 setState,减少渲染次数
  2. 修补未闭合标记:在传给 Markdown 解析器前,自动闭合未配对的 `$** 等标记,防止格式突变
  3. 代码块延迟高亮:流式中代码块用纯 <pre> 渲染,流结束后再做 Shiki/Prism 高亮
  4. CSS 稳定性:给消息容器设置 min-heightoverflow-anchor: auto 防止滚动跳动
实际产品做法

ChatGPT 和 Claude Web 均采用分块 memo + RAF 批量更新 + 未闭合标记修补的组合方案。单独使用任一方案效果有限,三者组合可基本消除闪烁。

相关链接