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>
    );
  }
 
相关推荐
剪刀石头布啊2 分钟前
泛洪DFS、BFS
前端
qetfw2 分钟前
Windows Server AD CS:根 CA、Web 证书模板与域内自动注册
前端·windows·windows-server
剪刀石头布啊2 分钟前
为什么React组件很少前缀,而vue的不少组件都有前缀
前端
剪刀石头布啊8 分钟前
gird网格布局
前端
啃火龙果的兔子10 分钟前
Google Chrome(谷歌浏览器)常用快捷键
前端·chrome
剪刀石头布啊12 分钟前
js真的是单线程实现异步并发么?
前端
剪刀石头布啊17 分钟前
js中改变this指向的操作有哪些
前端
剪刀石头布啊17 分钟前
vue、react 列表中使用 index 作为 key 的话,修改某一个元素后会发生什么
前端
方方洛19 分钟前
glowglow:语言无关的语法高亮器是怎么实现的
前端·前端框架
java_nnnn43 分钟前
JavaEE进阶-HTML基础认识
前端·java-ee·html