需求背景
随着 ChatGPT 等大语言模型的爆火,AI 对话的"流式输出(Streaming)"成为了前端开发中的一个高频需求。
然而,在实际开发中,如果仅仅是简单地将后端通过 Server-Sent Events (SSE) 推送过来的 Chunk(数据块)直接拼接到页面上,你会发现体验非常糟糕:
- "块状蹦字":网络推送往往是不均匀的,导致文字是一块一块蹦出来的,毫无"打字机"的流畅感。
- "页面闪烁":AI 输出 Markdown 格式的文本时,未闭合的标签(如表格、代码块)会被渲染引擎误判,导致在闭合瞬间发生剧烈的 DOM 结构跳跃。
- "滚动卡顿" :流式输出通常伴随着页面自动滚动到底部,高频触发平滑滚动(
scroll-behavior: smooth)会严重阻塞浏览器的渲染线程。
踩坑历程
我们前端的项目的是基于京东开源项目 JoyAgent-JDGenie,具体代码是这里TypeWriterCore.ts
ts
// 老方案的核心逻辑
onAddQueueList(str: string) {
this.queueList = [...this.queueList, ...str.split('')];
}
consume() {
const str = this.queueList.shift();
str && this.onConsume(str); // 触发 setTypedText(prev => prev + str)
}
它的核心逻辑是:维护一个大数组作为队列,每次接收到字符串,就用 str.split('') 拆解成单字符存入队列,然后通过定时器逐个 shift() 消费并触发组件重绘。
起初在短文本场景下,它运行得还不错。但随着我们接入了真实的 AI 长对话(动辄几千字),且回答内容全部是 Markdown 格式时,这个老方案暴露出了 4 个致命的技术缺陷:
- 内存与性能灾难(GC 抖动) :由于频繁地执行
[...arr, ...newArr]和shift(),当文本越来越长时,会导致极其严重的内存抖动和浏览器垃圾回收(GC),使得页面明显卡顿。 - 不兼容 SSE 的"累加式"数据流:SSE 推送的全量文本是不断累加的("A" -> "AB" -> "ABC")。老 Hook 如果不经过繁琐的 Delta(增量)计算,直接塞入队列会导致文字重复错乱。
- 逐字触发 React 渲染卡死页面 :每消费一个字符就触发一次
setState。由于 AI 回复的是 Markdown,每一次重绘都要经历完整的 Markdown AST 解析。如果队列积压了 100 个字,它会强制进行 100 次昂贵的 Markdown 重绘,直接卡死浏览器渲染线程。 - 缺失结构感知导致闪烁 :它只是无脑吐出单字符,一旦在代码块
\```javascript中间停顿,Markdown 解析引擎会崩溃,下一帧闭合时页面会剧烈闪烁跳跃。
意识到这些问题后,我们决定彻底抛弃基于"数组队列"的老思路 ,重构为基于双指针/长度追赶的新方案。
核心解决思路
1. 双指针缓冲追赶与自适应加速
为了抹平网络带来的块状抖动,我们需要将"真实接收到的文本"和"屏幕显示的文本"分离开来,利用一个高频的 setTimeout(约 16ms,60FPS)让显示长度不断"追赶"真实长度。 更巧妙的是,我们要加入自适应变速机制:如果网络极好,瞬间堆积了大量字符,打字机的输出速度需要动态加快,避免严重滞后;如果网络慢,则按正常速度匀速输出。
typescript
// 双指针追赶与自适应加速
const useTypewriter = (
fullText: string,
isCompleted: boolean,
charsPerTick = 2,
intervalMs = 16,
) => {
const [displayLen, setDisplayLen] = useState(0);
const targetLenRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 同步最新接收到的真实长度
useEffect(() => {
targetLenRef.current = fullText.length;
}, [fullText]);
// 流结束时瞬间追平显示长度
useEffect(() => {
if (isCompleted) {
setDisplayLen(targetLenRef.current);
}
}, [isCompleted]);
// 高频 Tick 驱动显示长度逐步追赶
useEffect(() => {
const tick = () => {
setDisplayLen((prev) => {
const target = targetLenRef.current;
if (prev >= target) {
return prev; // 已追平,不再设置下一个定时器,直接返回
}
const gap = target - prev;
// 自适应加速:如果网络返回大量数据(积压严重),则按比例加快打字速度
const step = gap > 50 ? Math.ceil(gap / 20) : charsPerTick;
// 还没追平,继续设置下一次 tick
timerRef.current = setTimeout(tick, intervalMs);
return Math.min(prev + step, target);
});
};
// 每次 fullText 更新(接收到新数据)时,主动唤醒 tick
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(tick, intervalMs);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [fullText, charsPerTick, intervalMs]);
// 对截断的字符串进行结构感知修正后返回
return sanitizePartialMarkdown(fullText.slice(0, displayLen));
};
2. 结构感知修正(防止 Markdown 闪烁)
当你渲染一半的 Markdown 时:
markdown
\```javascript
function
由于没有闭合的 \``` ,渲染器会把它当成普通文本,一旦下一帧接收到闭合符,整个区域会突然变成代码块,造成视觉上的强烈闪烁。 我们需要在渲染前进行结构感知修正:动态补全未闭合的标签,确保即使只渲染一半的内容,DOM 树的结构也是稳定的。
ts
// 自动补全未闭合的代码块,防止渲染闪烁
const sanitizePartialMarkdown = (text: string) => {
const codeBlockCount = (text.match(/```/g) || []).length;
// 如果代码块标记是奇数个,说明当前代码块未闭合,手动补全闭合标签
if (codeBlockCount % 2 !== 0) {
return text + '\n```';
}
return text;
};
3. 高性能滚动:节流与直接 DOM 操作
抛弃 CSS 的平滑滚动动画,直接使用节流(Throttle)操作 scrollTop。因为在每秒输出几十个字符的高频渲染下,动画不仅毫无意义,还会导致严重的掉帧。
为什么不直接使用官方库?(Ant Design X)
在 React 生态中,目前也出现了一些专门针对 AI 对话场景的 UI 库,比如 Ant Design X ,它提供了一个内置了流式渲染特性的 <XMarkdown /> 组件。
由于有一些历史包袱在的,一个庞大的逻辑交织纵横,并且在开发节奏紧凑的前提下,并不能完成从现有逻辑到组件库的平滑切换。
最终效果演示

完整代码实现
tsx
import React, { useState, useEffect, useRef } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
// 结构感知修正:自动补全未闭合的代码块,防止 Markdown 渲染跳跃闪烁
const sanitizePartialMarkdown = (text: string) => {
const codeBlockCount = (text.match(/```/g) || []).length;
if (codeBlockCount % 2 !== 0) {
return text + "\n```";
}
return text;
};
// 双指针追赶与自适应加速
const useTypewriter = (
fullText: string,
isCompleted: boolean,
charsPerTick = 2,
intervalMs = 16,
) => {
const [displayLen, setDisplayLen] = useState(0);
const targetLenRef = useRef(0);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// 同步最新接收到的真实长度
useEffect(() => {
targetLenRef.current = fullText.length;
}, [fullText]);
// 流结束时瞬间追平显示长度
useEffect(() => {
if (isCompleted) {
setDisplayLen(targetLenRef.current);
}
}, [isCompleted]);
// 高频 Tick 驱动显示长度逐步追赶
useEffect(() => {
const tick = () => {
setDisplayLen((prev) => {
const target = targetLenRef.current;
if (prev >= target) {
return prev; // 已追平,不再设置下一个定时器,直接返回
}
const gap = target - prev;
// 自适应加速:如果网络返回大量数据(积压严重),则按比例加快打字速度
const step = gap > 50 ? Math.ceil(gap / 20) : charsPerTick;
// 还没追平,继续设置下一次 tick
timerRef.current = setTimeout(tick, intervalMs);
return Math.min(prev + step, target);
});
};
// 每次 fullText 更新(接收到新数据)时,主动唤醒 tick
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(tick, intervalMs);
return () => {
if (timerRef.current) clearTimeout(timerRef.current);
};
}, [fullText, charsPerTick, intervalMs]);
// 对截断的字符串进行结构感知修正后返回
return sanitizePartialMarkdown(fullText.slice(0, displayLen));
};
export default function TypewriterDemo() {
const [text, setText] = useState("");
const [isCompleted, setIsCompleted] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// 模拟后端 SSE 流式推送
useEffect(() => {
const mockData = `### Vue 3 核心新特性解析\n\nVue 3 带来了许多激动人心的新特性,极大地提升了开发体验和性能。以下是几个核心亮点:\n\n#### 核心优势\n1. **组合式 API (Composition API)**:更灵活的代码组织方式,利于逻辑复用。\n2. **响应式系统升级**:基于 Proxy 实现,性能全面提升,不再有对象属性增加或删除的限制。\n3. **宏 (Macros)**:如 \`defineProps\` 和 \`defineEmits\`,极大地简化了 \`<script setup>\` 的编写体验。\n\n\`\`\`vue\n<script setup lang="ts">\nimport { ref } from 'vue';\n\n// 响应式状态\nconst count = ref(0);\n\n// 更新状态函数\nfunction increment() {\n count.value++;\n}\n</script>\n\`\`\`\n\n| 特性 | 优势 |\n| --- | --- |\n| 性能 | 渲染速度更快,内存占用更小 |\n| TypeScript | 提供了更好的类型推导与支持 |\n`;
let currentIndex = 0;
const pushChunk = () => {
if (currentIndex >= mockData.length) {
setIsCompleted(true);
return;
}
// 模拟网络推送的数据块大小 (每次 10~30个字符)
const chunkSize = Math.floor(Math.random() * 20) + 10;
const chunk = mockData.slice(currentIndex, currentIndex + chunkSize);
setText((prev) => prev + chunk);
currentIndex += chunkSize;
// 模拟网络延迟 (100~400ms)
setTimeout(pushChunk, Math.floor(Math.random() * 300) + 100);
};
// 延迟 500ms 开始模拟推送
setTimeout(pushChunk, 500);
}, []);
const displayText = useTypewriter(text, isCompleted);
// 滚动处理
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
scrollTimerRef.current = setTimeout(() => {
if (containerRef.current) {
// 直接设置 scrollTop,不使用 scrollIntoView(smooth),防止严重掉帧
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, 50);
}, [displayText]);
return (
<div
style={{
padding: "20px",
maxWidth: "800px",
margin: "0 auto",
fontFamily: "sans-serif",
}}
>
<h2>🤖 效果演示 (Typewriter Demo)</h2>
<div
ref={containerRef}
className="markdown-body"
style={{
height: "400px",
overflowY: "auto",
border: "1px solid #e1e4e8",
padding: "24px",
borderRadius: "8px",
backgroundColor: "#f6f8fa",
lineHeight: "1.6",
}}
>
<ReactMarkdown remarkPlugins={[remarkGfm]}>{displayText}</ReactMarkdown>
</div>
<div
style={{
marginTop: "16px",
padding: "12px",
background: "#eef",
borderRadius: "4px",
fontSize: "14px",
color: "#555",
}}
>
真实接收到的字符数:{" "}
<span style={{ color: "#d73a49" }}>{text.length}</span>
<br />
屏幕渲染出的字符数:{" "}
<span style={{ color: "#0366d6" }}>{displayText.length}</span>
<br />
当前状态:{" "}
<strong>{isCompleted ? "接收完成" : "模拟网络推送中..."}</strong>
</div>
</div>
);
}