AI 对话流式输出: 实现“逐字丝滑、不闪烁、不卡顿”的打字机效果

需求背景

随着 ChatGPT 等大语言模型的爆火,AI 对话的"流式输出(Streaming)"成为了前端开发中的一个高频需求。

然而,在实际开发中,如果仅仅是简单地将后端通过 Server-Sent Events (SSE) 推送过来的 Chunk(数据块)直接拼接到页面上,你会发现体验非常糟糕:

  1. "块状蹦字":网络推送往往是不均匀的,导致文字是一块一块蹦出来的,毫无"打字机"的流畅感。
  2. "页面闪烁":AI 输出 Markdown 格式的文本时,未闭合的标签(如表格、代码块)会被渲染引擎误判,导致在闭合瞬间发生剧烈的 DOM 结构跳跃。
  3. "滚动卡顿" :流式输出通常伴随着页面自动滚动到底部,高频触发平滑滚动(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 个致命的技术缺陷:

  1. 内存与性能灾难(GC 抖动) :由于频繁地执行 [...arr, ...newArr]shift(),当文本越来越长时,会导致极其严重的内存抖动和浏览器垃圾回收(GC),使得页面明显卡顿。
  2. 不兼容 SSE 的"累加式"数据流:SSE 推送的全量文本是不断累加的("A" -> "AB" -> "ABC")。老 Hook 如果不经过繁琐的 Delta(增量)计算,直接塞入队列会导致文字重复错乱。
  3. 逐字触发 React 渲染卡死页面 :每消费一个字符就触发一次 setState。由于 AI 回复的是 Markdown,每一次重绘都要经历完整的 Markdown AST 解析。如果队列积压了 100 个字,它会强制进行 100 次昂贵的 Markdown 重绘,直接卡死浏览器渲染线程。
  4. 缺失结构感知导致闪烁 :它只是无脑吐出单字符,一旦在代码块 \```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>
    );
  }
 
相关推荐
Devin_chen4 小时前
Pinia 渐进式学习指南
前端·vue.js
你听得到114 小时前
周下载60w,但是作者删库!我从本地 pub 缓存里把它救出来,顺手备份到了自己的 GitHub
前端·flutter
PeterMap4 小时前
Vue组合式API响应式状态声明:ref与reactive实战解析
前端·vue.js
CodeGuru4 小时前
UniApp Vue3 生成海报并分享到朋友圈
前端
三原4 小时前
附源码:三原管理系统新增俩种常用布局
java·前端·vue.js
布局呆星4 小时前
Vue3 | 组件化开发---组件插槽与通信
前端·javascript·vue.js
新智元4 小时前
全球 AI 双榜第一!力压谷歌 Veo 与 Grok,Vidu Q3「参考生」之王归来
aigc·openai
DyLatte4 小时前
当我想把所有角色都做好时,就开始内耗了
前端·后端·程序员
a1117765 小时前
汽车展厅项目 开源项目 ThreeJS
前端·开源·html