像ChatGPT一样逐字输出:React + TypeScript 流式接收与“打字机”效果实现方案

1. 背景与痛点

在 AI 助手场景中,用户期望"模型一边思考,内容一边逐渐显示",而不是等待完整响应回来后一次性展示。这要求前端同时解决两个问题:

如何读取流式接口:响应体分块到达,同一条消息可能被拆分到不同 chunk;且接口兼容 data: {...} 的 SSE-like 行流格式。

如何实现"打字机"节奏:每次收到增量后,不是整段替换,而是逐字/分段渐显,形成"正在输入"的体验。

本文基于真实项目(React + TypeScript,Vite 构建)的经验,给出完整实现方案。

2. 适用场景与目标

2.1 场景

扁平化输出:纯文本流式增长,打字机效果逐步写入当前消息框。

模块化输出:结构化结果在流式生成过程中被增量解析为多个分区,通过 onPartialResult 持续刷新。

2.2 核心目标

稳定读取 SSE-like 行流,正确处理 chunk 边界(半行 JSON)与 DONE 结束标记。

将解析出的增量文本按顺序累积,并以可控的"打字机节奏"更新 UI。

抽象出可复用的工具函数,与 React 解耦,便于测试和维护。

3. 技术栈与模块职责

分类 技术/模块 职责
前端框架 React + TypeScript 状态驱动 UI 更新
构建代理 Vite 开发环境转发 AI 网关请求
流式读取 fetch + ReadableStream 读取 resp.body 分块
流式解析 sseLikeStreamText.ts(新增) buffer 拼接、行分割、JSON 解析、增量提取
打字机决策 typewriter.ts(新增) 纯函数计算下一帧展示内容及停止条件
UI 组件 AIChatPanel.tsx / AIMessage.tsx 渲染文本输出区域与结构化消息
状态管理 useAiChatSessions.ts 管理消息列表、流式请求、打字机循环

核心文件路径(基于 src/app/):

txt 复制代码
services/
  aiChatClient.ts               # streamChat() / streamDiagnosis()
  sseLikeStreamText.ts          # accumulateSseLikeJsonStreamText()
  aiChatSessionStorage.ts
hooks/
  useAiChatSessions.ts          # 状态 + 打字机循环
utils/
  typewriter.ts                 # decideTypewriterTick()
components/
  AIChatPanel.tsx
  AIMessage.tsx
  shared/device/AIDiagnosisEntry.tsx   # 模块化输出入口

4. 整体链路(一图看懂)

4.1 打字机逐字渲染主流程

onDelta
nextContent
AIChatPanel
useAiChatSessions.sendMessage
aiChatClient.streamChat
fetch + stream
accumulateSseLikeJsonStreamText
appendTypingDelta
queueTyping
pumpTypingTask(每帧)
decideTypewriterTick
patchMessage 更新
React 重渲染,逐字出现

4.2 模块化增量输出流程

模块区 Card accumulateSseLikeJsonStreamText streamDiagnosis AIDiagnosisEntry 模块区 Card accumulateSseLikeJsonStreamText streamDiagnosis AIDiagnosisEntry loop 每收到一个 delta 触发诊断 fetch + 流式读取 onDelta(delta) rawText += delta, 尝试解析部分结构 onPartialResult(partial) setResult(partial) 渐进刷新

5. 核心实现详解

5.1 流式解析:处理 SSE-like 行流与 chunk 边界

5.1.1 协议形态

流式接口返回文本行流,每行可能是:

data: {"response": {...}} (SSE 格式)

纯 JSON 文本

DONE 表示结束

由于网络分块,一个完整的 JSON 行可能被拆到两个 chunk 中,必须用 buffer 拼接。

5.1.2 工具函数:accumulateSseLikeJsonStreamText

位置:src/app/services/sseLikeStreamText.ts(示例文件名)

核心逻辑:

ts 复制代码
export async function accumulateSseLikeJsonStreamText<T>({
  stream,
  signal,
  parseJson,
  extractText,
  onDelta,
}: {
  stream: ReadableStream<Uint8Array> | null;
  signal: AbortSignal;
  parseJson: (text: string) => T;
  extractText: (parsed: T) => string | undefined;
  onDelta: (delta: string) => void;
}) {
  const reader = stream?.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

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

    buffer += decoder.decode(value, { stream: true });
    const lines = buffer.split(/\n+/);
    buffer = lines.pop() ?? ""; // 保留最后一个可能不完整的行

    for (const rawLine of lines) {
      const line = rawLine.trim();
      if (!line) continue;
      processLine(line);
    }
  }

  function processLine(rawJsonText: string) {
    let jsonText = rawJsonText.trim();
    if (jsonText.startsWith("data:")) {
      jsonText = jsonText.slice(5).trim();
    }
    if (!jsonText || jsonText === "[DONE]") return;

    try {
      const parsed = parseJson(jsonText);
      const delta = extractText(parsed);
      if (delta) onDelta(delta);
    } catch {
      // 忽略不完整 JSON,等待后续 buffer 补全
    }
  }
}

5.1.3 在 aiChatClient.ts 中复用

ts 复制代码
await accumulateSseLikeJsonStreamText<StreamChunk>({
  stream: resp.body,
  signal,
  parseJson: (text) => JSON.parse(text),
  extractText: extractTextFromChunk,
  onDelta,
});

其中 extractTextFromChunk 遍历 chunk.response.output\[\].content\[\],找到 type === "output_text" 的 text 字段并返回。

5.2 打字机渲染:从 delta 到逐字显示

5.2.1 节奏控制参数

TYPEWRITER_INTERVAL_MS = 20:每帧间隔

TYPEWRITER_STEP = 2:每次推进的字符数(可调)

5.2.2 纯函数决策器:decideTypewriterTick

位置:src/app/utils/typewriter.ts

输入当前可见内容、目标内容、当前状态等,输出"是否需要 patch、下一帧内容、是否停止、是否继续排程"。

ts 复制代码
export function decideTypewriterTick(params: {
  currentVisibleContent: string;
  targetContent: string;
  currentStatus: string;
  step: number;
  completionStatus?: string;
}) {
  const { currentVisibleContent, targetContent, currentStatus, step, completionStatus } = params;

  // 情况1:内容已完全一致
  if (currentVisibleContent === targetContent) {
    if (completionStatus && currentStatus !== completionStatus) {
      return { patchRequired: true, nextContent: targetContent, nextStatus: completionStatus, shouldStop: true, scheduleNext: false };
    }
    return { patchRequired: false, shouldStop: true, scheduleNext: false };
  }

  // 情况2:目标内容与当前可见内容不匹配(比如错误强制同步)
  if (!targetContent.startsWith(currentVisibleContent)) {
    const nextStatus = completionStatus ?? "streaming";
    return {
      patchRequired: true,
      nextContent: targetContent,
      nextStatus,
      shouldStop: completionStatus !== undefined,
      scheduleNext: false,
    };
  }

  // 情况3:正常推进
  const nextContent = targetContent.slice(0, currentVisibleContent.length + step);
  return {
    patchRequired: true,
    nextContent,
    nextStatus: "streaming",
    shouldStop: false,
    scheduleNext: true,
  };
}

5.2.3 Hook 中消费决策

useAiChatSessions.ts 内的 pumpTypingTask:

ts 复制代码
const decision = decideTypewriterTick({
  currentVisibleContent: currentContent,
  targetContent: typingTask.targetContent,
  currentStatus: message.status,
  step: TYPEWRITER_STEP,
  completionStatus: typingTask.completionStatus, // 'done' 或 'error'
});

if (decision.patchRequired) {
  patchMessage(sessionId, messageId, (msg) => ({
    ...msg,
    content: decision.nextContent ?? msg.content,
    status: decision.nextStatus,
  }));
}

if (decision.shouldStop) {
  typingTaskRef.current = null;
  finalizeStreamingStateIfIdle();
  return;
}

if (decision.scheduleNext) {
  typingTimerRef.current = setTimeout(pumpTypingTask, TYPEWRITER_INTERVAL_MS);
}

5.3 结构化消息渲染

AIMessage.tsx 不参与逐字动画,仅根据 message.content 实时解析结构:

按换行分割

以 【标题】 格式识别段落标题

标题下的行作为正文渲染为

由于打字机每帧都会更新 message.content,React 自动重渲染,从而实现渐进显示。

6. 验证与效果

6.1 验证步骤

触发一次 AI 对话,观察文字是否按固定节奏逐字出现。

检查停止/错误场景:状态是否正确转为 done 或 error,内容是否完整显示。

触发模块化输出(如诊断结果),观察分区是否在部分解析时持续刷新。

6.2 预期效果

文本区域内容平滑增长,无突兀跳变。

流式结束时自动停止打字机循环。

模块区卡片内容随解析进度逐步填充。

7. 踩坑与注意事项

chunk 边界半行 JSON

必须保留 buffer 中最后一行,不能直接按行解析每个 chunk。

data: 前缀处理

SSE 格式必须去掉 data: 再 JSON.parse。

JSON 解析失败容忍

不完整 JSON 直接跳过,等待后续 chunk 补全。

频繁 patch 的性能

当前每帧都会触发 React 重渲染和 localStorage 写入(如有持久化)。若消息极长,可考虑对 storage 写入做节流。

8. 总结

本文从项目真实需求出发,系统性地实现了:

SSE-like 行流解析器:处理 buffer、行分割、协议前缀、DONE,输出纯增量文本。

打字机节奏决策器:纯函数决定每帧显示多少内容,何时停止。

与 React 的集成:在自定义 hook 中驱动定时器,每次 patch 消息状态,自动触发 UI 更新。

这两类抽象完全解耦于框架,可直接复用到任何需要"流式增量 + 渐进展示"的前端项目中。如果你也在实现类似 AI 对话效果,不妨参考本文的拆分方式,能让代码更清晰、更容易维护

相关推荐
程序员cxuan5 小时前
为每个任务配一套 harness:Claude Code 里的动态工作流
人工智能
程序员cxuan5 小时前
Claude Fable 5 来了
人工智能·后端·程序员
云边云科技_云网融合5 小时前
云边云科技亮相 2026 WOD 制造业数智化博览会 云网融合赋能制造焕新
人工智能·科技·安全·制造
Σίσυφος19005 小时前
激光三角 光平面标定-多高度误差分析
人工智能·计算机视觉·平面
JS菌5 小时前
手写一个 AI Agent 全栈项目:从沙箱执行到子智能体的完整实现
前端·人工智能·后端
lqqjuly5 小时前
前沿算法深度解析(二)
人工智能·算法·机器学习
Bode_20025 小时前
基于大数据分析的全生命周期质量追溯质量评估体系落地方案
大数据·人工智能
分布式存储与RustFS6 小时前
RustFS S3 Table 开源后,我重新梳理了一下 Iceberg 数据湖的选型思路
人工智能·开源·minio·dpu·rustfs·ai存储·s3 table
DevOpenClub6 小时前
用 Agent 搭建网页内容采集与结构化处理流水线
人工智能
56AI6 小时前
2026 企业级AI智能体开发平台推荐:聚焦底层安全与准确率的智能体平台
人工智能·安全·智能体