从 0 实现 ChatGPT 风格的流式对话 UI
2026/7/7 3:41:44
网站开发
流式对话 UI 表面上是「后端推 chunk、前端拼进 state」实际上线后通常会碰到这几类问题Markdown 边输出边闪、每个 chunk 触发重渲染导致掉帧、自动滚动打断用户翻历史、点了停止但 loading 没收干净。下面按实现顺序拆开讲。传输层SSE fetch写 UI 之前先搞清楚数据怎么从服务端流到浏览器。常见三种方案方向自动重连协议适用场景SSE服务端 → 客户端浏览器自带HTTPLLM 流式输出首选WebSocket双向需自己实现ws/wss协作编辑、实时对战Fetch ReadableStream服务端 → 客户端需自己实现HTTPPOST 自定义 Header 的流式LLM 对话是单向的问一次答一长串生成过程中客户端不需要往回发数据。SSE 语义匹配走标准 HTTP鉴权、网关、CDN 都能复用。OpenAI、Claude 官方接口也是这个路子。格式简单Content-Type: text/event-stream每条事件data: {...}\n\n。注意原生 EventSource只支持 GET不能自定义请求头。调 LLM 要 POST messages 并带 Authorization所以实际项目里几乎都用 fetch 读 body自己按 SSE 格式解析而不是 EventSource。streamChat()async generator 封装response.body.getReader()逐块读按\n\n切事件从data:提取 deltayield出去// streamChat.ts export interface StreamChatOptions { messages: { role: user | assistant | system; content: string }[] signal?: AbortSignal // 用于中断 } export async function* streamChat({ messages, signal }: StreamChatOptions) { const res await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages, stream: true }), signal, }) if (!res.ok || !res.body) { throw new Error(请求失败: ${res.status}) } const reader res.body.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break // chunk 可能在任意字节处被切断必须 buffer 拼接 buffer decoder.decode(value, { stream: true }) // SSE 以 \n\n 分隔每条事件 const lines buffer.split(\n\n) buffer lines.pop() ?? // 最后一段可能不完整留到下次 for (const block of lines) { const line block.trim() if (!line.startsWith(data:)) continue const data line.slice(5).trim() if (data [DONE]) return // OpenAI 风格的结束标记 try { const json JSON.parse(data) const delta json.choices?.[0]?.delta?.content if (delta) yield delta as string } catch { // 半条 JSON忽略正常不会走到这因为按 \n\n 切过了 } } } }消费for await (const chunk of streamChat({ messages, signal })) { console.log(chunk) }解析层最容易踩坑的地方reader.read() 返回的字节块不保证落在字符边界也不保证落在 SSE 事件边界。汉字 UTF-8 占三个字节可能被切成两半一条 data: 也可能拦腰截断。必须同时做两件事decoder.decode(value, { stream: true })—— 让 decoder 缓存不完整的多字节字符。用buffer累积按\n\n切分末尾不完整的一段留到下一轮。缺任何一个中文乱码或JSON.parse失败会随机出现慢网下更容易复现。打字机收流和显示要解耦流接好后常见写法是每个 chunk 直接追加到 statefor await (const chunk of streamChat({ messages })) { setText(prev prev chunk) }问题在于 LLM 吐 token 节奏不均匀——有时长时间无输出有时一口气推半段。直接拼接会出现「顿一下、蹦一大段」和 ChatGPT 的匀速感差很远chunk 密集时高频setState还会拖慢 reconcileMarkdown 重渲染时更明显。逐 token 显示来一个显示一个实现最简单但粒度不均视觉上忽快忽慢。更稳的做法是把接收和显示拆开**网络层往缓冲区写显示层用 requestAnimationFrame 匀速吐字**。// typewriter.ts —— 框架无关React/Vue 共用 export function createTypewriter(onUpdate: (text: string) void) { let displayed let pending let rafId: number | null null // 手感旋钮中文 2~3英文 4~6 const CHARS_PER_FRAME 2 function tick() { if (pending.length 0) { const take Math.min(CHARS_PER_FRAME, pending.length) displayed pending.slice(0, take) pending pending.slice(take) onUpdate(displayed) } if (pending.length 0) { rafId requestAnimationFrame(tick) } else { rafId null } } return { push(chunk: string) { pending chunk if (rafId null) rafId requestAnimationFrame(tick) }, // 流结束时调用避免网络已结束 UI 还在逐字蹦 flush() { displayed pending pending onUpdate(displayed) if (rafId ! null) { cancelAnimationFrame(rafId) rafId null } }, get text() { return displayed }, } }React / Vue 接入Reactimport { useState, useRef, useCallback } from react import { streamChat } from ./streamChat import { createTypewriter } from ./typewriter export function useStreamingChat() { const [text, setText] useState() const [loading, setLoading] useState(false) const abortRef useRefAbortController | null(null) const send useCallback(async (messages) { setText() setLoading(true) const controller new AbortController() abortRef.current controller const typer createTypewriter(setText) try { for await (const chunk of streamChat({ messages, signal: controller.signal })) { typer.push(chunk) } typer.flush() } catch (err) { if ((err as Error).name ! AbortError) { setText(t t \n\n⚠️ 出错了请重试) } } finally { setLoading(false) abortRef.current null } }, []) const stop useCallback(() abortRef.current?.abort(), []) return { text, loading, send, stop } }Vue 只是把setText换成text.value // useStreamingChat.ts import { ref } from vue import { streamChat } from ./streamChat import { createTypewriter } from ./typewriter export function useStreamingChat() { const text ref() const loading ref(false) let controller: AbortController | null null async function send(messages) { text.value loading.value true controller new AbortController() const typer createTypewriter(t (text.value t)) try { for await (const chunk of streamChat({ messages, signal: controller.signal })) { typer.push(chunk) } typer.flush() } catch (err) { if ((err as Error).name ! AbortError) { text.value \n\n⚠️ 出错了请重试 } } finally { loading.value false controller null } } function stop() { controller?.abort() } return { text, loading, send, stop } }typewriter.ts与框架无关React、Vue 共用同一份。流式 Markdown未闭合语法导致闪烁回答里一旦带 Markdown代码块、列表、表格边输出边解析就会出问题。比如模型正在输出这是一段代码 js const a 1结尾的 还没到时解析器面对的是未闭合的代码块。不同库行为不一致有的把后续内容全当 code有的当普通文本——每来一个 chunkDOM 结构就变一轮屏幕闪烁。列表-、表格|在半截状态同样麻烦。两种方案**方案 Areact-markdown 节流 补全语法。**接入成本最低适合大多数项目。Markdown 不必每个 rAF 帧都 parse节流约 60ms渲染前对文本做闭合补全。import ReactMarkdown from react-markdown import { useMemo } from react // text 先 throttleuseThrottle / lodash.throttle function MarkdownMessage({ text }: { text: string }) { const safe useMemo(() completeUnclosed(text), [text]) return ReactMarkdown{safe}/ReactMarkdown }方案 B增量解析如 streaming-markdown或只 parse 变化尾部的自研方案。长代码块场景 CPU 占用更低但接入成本高对话量特别大、流畅度要求极高时再考虑。completeUnclosed渲染前检查围栏和行内反引号奇数个就临时补闭合符export function completeUnclosed(text: string): string { let result text const fenceCount (result.match(//g) || []).length if (fenceCount % 2 1) { result \n } const inlineTicks (result.replace(//g, ).match(//g) || []).length if (inlineTicks % 2 1) { result } return result }代码块输出到一半时解析器看到的是合法闭合块布局稳定真正的 到达后临时补的全会被正常替换用户无感知。代码高亮Shiki vs PrismShiki 质量高但是整块 re-highlight。流式过程中每帧对增长中的代码块跑 Shiki主线程开销很大。更合理的策略流式期间用纯文本或轻量样式块闭合后再 Shiki 一次性高亮。Prism 更轻适合需要实时高亮的场景画质略逊于 Shiki。交互细节中断AbortControllerhook 里传入signalabort()后 fetch 抛AbortError应静默处理不当成业务错误。finally里复位loading、清除 controller。已生成内容保留允许继续追问。function StopButton({ loading, onStop }) { if (!loading) return null return button onClick{onStop}■ 停止生成/button }跟随滚动text一变就scrollTop scrollHeight用户往上翻历史时会被新 chunk 拽回底部。改法维护stick标志只有用户距底部 40px 以内才自动跟随主动上滑则停滑回底部再恢复。function useAutoScroll(deps: unknown[]) { const ref useRefHTMLDivElement(null) const stick useRef(true) function onScroll() { const el ref.current if (!el) return const distance el.scrollHeight - el.scrollTop - el.clientHeight stick.current distance 40 } useEffect(() { const el ref.current if (el stick.current) { el.scrollTop el.scrollHeight } }, deps) return { ref, onScroll } }ref、onScroll绑到滚动容器deps传[text]。超时、重试、空响应-超时AbortSignal.timeout(60_000) 或定时器 abort避免一直停在「思考中」。-重试网络瞬断可对整次请求退避重试。LLM 流式没有断点续传重试等于重发请求。-空响应流正常结束但 text 为空显示「未生成内容请重试」。async function withTimeoutT(p: PromiseT, ms: number, onTimeout: () void) { const timer setTimeout(onTimeout, ms) try { return await p } finally { clearTimeout(timer) } }Loading 三态首个 chunk 未到骨架或跳动省略号。正在吐字光标▋发送按钮换成停止。出错气泡内错误信息 重试。{loading text ThinkingDots /} {text MarkdownMessage text{text} /} {loading text span classNamecursor▋/span}性能与边界虚拟列表不必过早引入。聊天消息高度不固定代码块、图片、表格高度测量成本高。消息数到几百条、Performance 明显吃紧时再考虑react-window/tanstack/virtual/vue-virtual-scroller。几个低成本优化消息key用稳定 id不用数组下标。历史消息React.memo/v-memo只让正在生成的那条高频更新。Markdown parse 高亮配合 ~60ms 节流与吐字引擎对齐。移动端键盘弹起时监听visualViewport.resize调整底部 paddingtextarea限高内部滚动。function autoGrow(el: HTMLTextAreaElement, maxPx 160) { el.style.height auto el.style.height Math.min(el.scrollHeight, maxPx) px }小结流式 UI 的难点不在「接住流」——fetchgetReader几十行能搞定。难在边流边渲染token 节奏与网络解耦、Markdown 半截语法补全、重型高亮延后、滚动让位给用户操作、停止与错误状态区分清楚。排查问题时优先看 buffer 解码和 Markdown 闭合这两处。**项目地址 **https://github.com/Mr-liu6/streaming-chat