像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({ stream, signal, parseJson, extractText, onDelta, }: { stream: ReadableStream | 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({ 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 对话效果,不妨参考本文的拆分方式,能让代码更清晰、更容易维护

相关推荐
AI视觉网奇2 小时前
LtxVAE 学习笔记
人工智能·pytorch·深度学习
juyou51182 小时前
清明踏青亲子研学升温,AI+数字乡村技术破解体验与安全管控痛点
大数据·人工智能·科技·ar·语音识别
Juicedata2 小时前
一文解锁 JuiceFS 在 AI 场景中的性能优化
人工智能·性能优化
木头程序员2 小时前
关于load_data_fashion_mnist函数运行原理以及运行速度慢解决方案
人工智能·python·深度学习·d2l
东离与糖宝2 小时前
2026 Java AI框架选型:Spring AI/LangChain4j企业级对比
java·人工智能
yunpeng.zhou2 小时前
深度理解agent与llm之间的关系、及mcp与skill的区别
人工智能·python·ai
CoderJia程序员甲2 小时前
GitHub 热榜项目 - 日榜(2026-04-03)
人工智能·ai·大模型·github·ai教程
TDengine (老段)2 小时前
TDengine IDMP 可视化 —— 趋势图
大数据·数据库·人工智能·物联网·时序数据库·tdengine·涛思数据
东离与糖宝2 小时前
Java AI工程化:PyTorch On Java+SpringBoot微服务部署(2025-2026最新实战)
java·人工智能