ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

AI对话打字机效果实战:从SSE流式渲染到Vue 3增量更新

2026/9/29 11:26:50 拓冰建站 浏览量
AI对话打字机效果实战:从SSE流式渲染到Vue 3增量更新 1. 打字机效果在AI对话里的真实定位它不是动画1.1 被误解的打字机它承担的是状态表达我在做AI对话类项目时经常遇到产品经理提这么一个需求聊天回复来了之后要像ChatGPT那样一个字一个字蹦出来打字机效果安排上。一听这个问题很多前端小伙伴的第一反应是这有什么难的setIntervalsubstring或者用第三方动画库几行代码搞定。但真正接到大模型接口那一刻你会发现完全不是那么回事。先说一个最容易被忽略的事实AI对话里的打字机效果本质上不是动画而是状态表达。它告诉用户两件事——第一模型还在生成没有卡死第二生成的内容是实时的、可被打断的。如果你把它当成一个纯视觉动画来做用定时器去播放一段已经完整到手的文本那你做的只是一个自欺欺人的假效果后端早就把一整段内容传回来了前端却还在一个字一个字地演首字延迟被人为拉长用户等待的体感反而更差。真正符合AI对话场景的打字机应该是流式渲染的副产物后端每吐出一个token或者一小段内容前端就把它追加到页面里。所以这个功能的前置条件不是动画库而是对SSEServer-Sent Events或WebSocket流的正确处理。1.2 为什么setInterval模拟打字机是条歪路我刚入行那会儿也犯过这个错。拿到一个对话接口后端一次性返回完整内容我天真地写了个定时器每隔30毫秒往displayText里多塞一个字// 错误示范用定时器模拟打字机 const interval setInterval(() { index.value 1 displayText.value fullText.value.slice(0, index.value) if (index.value fullText.value.length) clearInterval(interval) }, 30)看起来没问题实际跑起来四个坑等着你第一首字延迟被拉长。如果模型已经花了三秒生成全文前端拿到之后又花三秒播完用户实际看到第一个字的等待时间可能是五秒以上。而真正的流式体验是第一个token到达就立刻上屏用户感觉它开始说了。第二字符切开会造成视觉闪烁。AI返回的内容经常混着英文、中文、代码块和Markdown标记。slice(0, index)按字符硬切经常切到一个字的半个字节边界或者把、**这类标记暴露出来界面明显抖动。中文环境里这个问题尤其恶心字体渲染、字间距都会跳。第三状态管理灾难。用户点了停止生成之后定时器还在跑文本还在继续蹦你得额外记一个isStopped标志取消定时器再把剩余文本一次性灌进去。如果用户又发了新消息旧定时器没清干净新老内容互相覆盖Bug层出不穷。第四浏览器的定时器节流。页面切后台时setInterval会被浏览器限制到每秒一次甚至暂停恢复前台后文本瞬移体验很突兀。所以我现在的结论很直接在AI对话场景里凡是拿到全文再播放的方案都是歪路正路只有一条——让前端渲染节奏跟上游数据到达节奏对齐。1.3 一个合格的AI打字机应该满足的硬指标结合我做过的几个对话类项目我总结了五个硬指标大家可以拿来自查指标说明反例增量即达每个数据块到达后尽快上屏不人为积压setInterval拼接已完成的全文可中断取消生成后已渲染内容保留并立即补齐剩余文本停止后还在继续蹦字光标与文本解耦光标独立渲染不随文本重排光标和文本在同一个v-html里标记不闪断Markdown代码块、行内代码在渲染中不暴露原始语法渲染途中出现**加粗**字面量空状态有提示首token到达前显示正在思考占位用户发出消息后屏幕静止数秒这五个指标里前三个是功能层面的硬要求后两个是体验层面的软要求但都直接影响用户对这个AI好不好用的判断。接下来我先把流式渲染的原理讲透再给一套可以直接抄走的Vue 3实现。2. 流式渲染原理SSE数据管道与增量追加方案2.1 AI后端给前端的数据长什么样现在主流的大模型接口比如OpenAI兼容协议、各家国产模型厂商的OpenAI兼容接口大多数都支持流式输出。HTTP响应头一般是Content-Type: text/event-stream数据格式是SSEdata: {id:chatcmpl-xxx,choices:[{delta:{content:你好},index:0}]} data: {id:chatcmpl-xxx,choices:[{delta:{content:},index:0}]} data: {id:chatcmpl-xxx,choices:[{delta:{content:今天},index:0}]} data: [DONE]注意几点每条data:之间用空行分隔delta.content里的内容可能是一个字、一个词、一个英文token也可能是半个代码片段最后以data: [DONE]收尾。也就是说前端拿到的是一串增量包而不是完整的一句话。这正是打字机效果最好的驱动信号。但这里有个关键认知SSE协议本身不保证每条data:是完整的多字节字符。比如一个中文字你的UTF-8编码是三个字节如果这次recv只收到了两个字节直接TextDecoder.decode可能解出乱码。后面接入章节我会给出带流式解码缓冲的完整处理这里先记住这个坑。2.2 增量追加 vs 整体替换性能差异在哪很多Vue开发者会这么写watch(streamText, (val) { displayText.value val // 整体替换 })当流式数据每秒钟到达几十个chunk时这个写法会导致Vue对文本节点做几十次整体替换。在短文本上没什么感觉但对话窗口一长、同时存在多条消息时DOM diff和文本节点重建的开销会明显拉高低端手机尤其明显。我的做法是维护一个displayText的ref所有新内容一律用追加只有重置时才整体赋值。Vue对已存在的文本节点做文本追加性能开销远小于重建整个文本节点。同时把完整会话内容单独存在另一个变量里用来做停止生成后补齐和复制整段的操作避免显示文本和真实文本混用。// 显示文本增量追加 displayText.value chunk // 完整文本独立保存用于补齐和复制 fullText.value chunk这里有一个容易被忽略的细节如果你第三方的Markdown渲染组件需要接收完整文本千万不要在每个chunk到达时都重新解析一遍整段Markdown。要设计成增量节点机制或者只针对新增部分做解析否则CPU会爆。后面组件章节会细说。2.3 光标为什么必须独立成一个DOM节点AI对话的打字机光标不应该和文本混在一个节点里。很多人会把光标做成一个CSS类加在文本容器的::after伪元素上用背景闪烁来模拟。这在静态文本下没问题但在流式场景里文本容器每一次内容变化都会触发重排reflow伪元素的闪烁动画会被打断出现肉眼可见的指针卡顿。正确做法是把光标做成独立的span元素兄弟节点排在文本后面div classmessage-content v-htmlrenderedText/div span v-ifstatus streaming classcursor/span.cursor { display: inline-block; width: 2px; height: 1em; margin-left: 2px; background: currentColor; vertical-align: -0.15em; animation: cursor-blink 0.8s steps(1) infinite; } keyframes cursor-blink { 50% { opacity: 0; } }独立节点有三个好处光标闪烁动画不受文本内容变化干扰v-ifstatus streaming让光标的存在状态直接由流状态驱动暗黑模式下用currentColor自动跟随文字颜色不会出现黑色背景下光标看不见的尴尬。3. Vue实现从组合式函数到可复用对话组件3.1 useTypewriter状态机驱动的最小核心我建议把这个能力封装成一个组合式函数Composables这样无论是单个演示页面还是完整对话应用都能复用。核心是维护一个状态机idle空闲→streaming流式输出中→done完成或error异常外加一个interrupted被用户中断标志。// useTypewriter.ts import { ref } from vue export type TypewriterStatus idle | streaming | done | error export function useTypewriter() { const displayText ref() // 页面实际展示的内容 const fullText ref() // 完整内容用于结束时补齐 const status refTypewriterStatus(idle) const isInterrupted ref(false) function append(chunk: string) { fullText.value chunk displayText.value chunk // 增量追加避免整体替换 } function start() { status.value streaming isInterrupted.value false displayText.value fullText.value } // 用户主动停止立即补齐剩余文本让内容完整可读 function interrupt(chunk?: string) { if (chunk) { fullText.value chunk } displayText.value fullText.value isInterrupted.value true status.value done } function complete() { displayText.value fullText.value status.value done } function error() { status.value error } function reset() { displayText.value fullText.value status.value idle isInterrupted.value false } return { displayText, fullText, status, isInterrupted, append, start, interrupt, complete, error, reset } }interrupt()这个方法很关键。用户点击停止生成时后端连接断开了但此刻已经收到的内容可能还停在某个半截的状态。正确做法是把已收到的完整文本一次性赋予displayText保证界面不残缺。这个细节决定了停止生成功能是否专业。3.2 一个支持Markdown流式渲染的对话气泡组件组件层我一般这样组织streaming-text负责渲染单条消息chat-window负责消息列表和滚动管理。streaming-text的核心模板如下!-- StreamingText.vue -- template div classmessage-row div classmessage-bubble :classrole div v-ifstatus idle thinking classthinking正在思考/div div v-else classcontent-wrap div classmarkdown-body v-htmlrenderedHtml/div span v-ifstatus streaming classcursor/span /div /div /div /template这里有个核心抉择renderedHtml怎么算最简单的方案是把displayText丢给marked或markdown-it整套解析然后塞进v-html。但流式场景下每到一个chunk就全量解析一次代价太高而且Markdown本身是未完成的状态——你正在输出的内容可能正好是半个的代码块围栏全量解析会造成界面上出现**、这类符号闪烁。我实践的方案是段落级流式渲染把整段Markdown按空行切成段落数组只有完整段落才走Markdown渲染最后一个未闭合的段落保持纯文本显示。大概逻辑是这样function renderStreamingMarkdown(text) { const paragraphs text.split(/\n{2,}/) const complete paragraphs.slice(0, -1) // 完整段落 const last paragraphs[paragraphs.length - 1] // 可能未完成 let html complete.map(parseMarkdown).join(br/) if (last) { html br/ escapeHtml(last) // 未完成段不解析 } return html }这个方案在绝大部分场景下够用代码块的开始围栏已经出现时也不会闪烁因为未完成的段落只做HTML转义不做Markdown解析。不过要注意v-html的XSS风险如果你的模型输出可能包含用户输入的内容必须在渲染前对非标记文本做严格转义最好再用DOMPurify过滤一遍。3.3 节奏控制不是每个chunk都要立刻上屏后端吐token的速度并不均匀有的模型首token要等两三秒但一旦开始生成可能每10毫秒就吐一个tokenV8和浏览器的渲染帧率通常是60fps也就是每帧约16.7毫秒。如果你每个chunk到达都立刻改displayText.value一个16毫秒的帧周期内可能触发多次响应式更新Vue会把它们合并成一次DOM更新这倒是问题不大但如果你在watch回调里做了额外操作比如触发滚动就会高频抖动。我的做法是给append加一层requestAnimationFrame批处理把一帧内到达的多个chunk合并成一次追加。let rafId: number | null null let pendingChunks: string[] [] function scheduleAppend(chunk: string) { pendingChunks.push(chunk) if (rafId ! null) return rafId requestAnimationFrame(() { const merged pendingChunks.join() pendingChunks [] rafId null append(merged) }) }实测下来这个批处理能把高吞吐模型的渲染次数降低一个数量级而打字机的逐字感几乎不受影响因为人类感知的连续是由内容增长速率决定的不是由浏览器重绘次数决定的。如果产品要求更明显的逐字敲出效果可以人为在每帧追加后加一个极小的时间片阈值比如8ms但绝大多数情况下没必要。4. 接入真实大模型fetch流式消费与中断控制4.1 为什么不用EventSource很多教程会推荐用EventSource来接收SSE因为它自带自动重连代码也简单。但我在真实项目里几乎不用它原因很现实对比项EventSourcefetch ReadableStream请求方法仅支持GET支持POST可携带JSON body自定义Header不支持无法带Authorization完全支持中断控制只能close不能细分AbortController精准中断错误状态码只能靠onerror判断可以拿到status、statusText自动重连自带但有时会重复消费自己控制更可靠现在的大模型服务基本都要POST请求传消息列表、温度参数还要带API Key的HeaderEventSource连这些都做不了所以老老实实用fetch。4.2 手写fetch SSE客户端核心思路是用response.body.getReader()拿到底层字节流配合TextDecoder做流式解码再按SSE协议切出data:行。这里有两个必须处理的细节分帧边界和多字节字符边界。async function streamChat( url: string, body: unknown, onDelta: (content: string) void, signal: AbortSignal ): Promisevoid { const resp await fetch(url, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${apiKey} }, body: JSON.stringify(body), signal }) if (!resp.ok) { throw new Error(HTTP ${resp.status}: ${await resp.text()}) } const reader resp.body!.getReader() const decoder new TextDecoder(utf-8) let buffer while (true) { const { done, value } await reader.read() if (done) break // 流式解码第二个参数 stream: true 会保留未完成的多字节字符 buffer decoder.decode(value, { stream: true }) // 按空行切分SSE事件最后一个可能不完整留在buffer里 const events buffer.split(/\r?\n\r?\n/) buffer events.pop() || for (const event of events) { for (const line of event.split(/\r?\n/)) { if (!line.startsWith(data:)) continue const payload line.slice(5).trim() if (payload [DONE]) return try { const json JSON.parse(payload) const delta json.choices?.[0]?.delta?.content if (delta) onDelta(delta) } catch { // 单个事件解析失败时跳过不影响后续流 } } } } }这段代码里decoder.decode(value, { stream: true })那个参数特别重要。如果不加stream: true一个中文汉字的三字节被网络分两次送达时第一次解码会直接吐出一个第二次才是完整字符。加上之后解码器会把未完成的字节留在内部缓冲里拼上后续字节再输出。这个坑在中文AI对话里几乎必踩大家先记下来。4.3 AbortController让停止生成真正生效有了上面的客户端中断就很简单了。在useTypewriter里保存一个AbortController停止生成时调用abort()同时触发interrupt()把已收到的文本补齐const controller new AbortController() // 在消息发送时启动 async function sendMessage(content: string) { tw.start() controller.abort() // 若上一次未结束先打断 const ctrl new AbortController() controller ctrl try { await streamChat(/api/chat, { messages }, (delta) { tw.append(delta) autoScroll() }, ctrl.signal) tw.complete() } catch (err) { if (err.name AbortError) { tw.interrupt() // 停止生成不是错误补齐显示 } else { tw.error() } } } // 用户点击停止按钮 function stopGenerate() { controller.abort() }注意一个行为差异abort()抛出的AbortError不应当被当成错误处理否则界面上会出现红色的报错提示。应该调用interrupt()把当前已生成的内容完整呈现然后让用户自主选择复制还是重新生成。4.4 错误处理与自动重试流式接口的错误形态比普通接口复杂可能刚发出去就返回401鉴权失败、429限流、5xx也可能已经输出了几十个字连接突然断开。我的经验是区分两种错误连接前错误HTTP status非2xx直接展示错误提示同时给出重试按钮。对429要特别注意很多大模型服务的限流是按每分钟请求数算的疯狂重试只会把自己打得更死最好做一下退避——第一次等1秒第二次等3秒第三次等10秒。中途断流已经收到部分内容保留已生成的内容在末尾追加一个连接中断的轻提示并记录当前输入参数允许用户一键续问而不是让他重新粘贴整个上下文。这个细节对对话类产品特别重要因为用户往往已经等了十几秒你让他重来一次他大概率直接流失。5. 五个我踩过且必须避开的坑5.1 自动滚动失效滚动容器找错了AI对话窗口一般有两种布局整页滚动和内部overflow-y: auto的容器。自动滚动的直观写法是nextTick(() { window.scrollTo(0, document.body.scrollHeight) })但如果你的聊天气泡在一个height: calc(100vh - 60px)的div里window.scrollTo永远无效因为滚动的是那个div而不是页面。正确做法是先判断滚动容器或者统一用scrollIntoViewimport { nextTick } from vue async function autoScroll() { await nextTick() const el lastMessageEl.value if (!el) return el.scrollIntoView({ behavior: smooth, block: end }) }还有一个更隐蔽的坑Markdown里的图片加载完成之前scrollHeight是不准的导致滚动条停在半截。解决办法是给图片加一个固定的min-height占位或者监听图片的load事件后再滚动一次。另外如果用户主动往上翻看历史消息这时候自动滚动会强行把他拉回底部体验非常糟糕。我一般用一个userScrolled标志监听滚动容器的scroll事件如果用户离底部超过100px就暂停自动滚动直到他重新回到底部附近。5.2 中文与代码混排时的光标跳动流式渲染过程中光标会跟着文本增长跳动这本身是正常的。但如果你的CSS没有固定好line-height和vertical-align光标会出现上下乱跳、忽高忽低的现象。特别是中英文混排时英文的行高基线和中文字体不一样光标vertical-align: baseline会导致它在中英文切换的瞬间上下抖动。我的解法是给消息内容固定line-height: 1.6光标使用vertical-align: -0.15em而不是baseline并且把width: 2px改成border-right的实现这样光标会跟随文本流的最终位置稳定移动不会出现跳过去又跳回来的视觉错乱。如果你渲染的是代码块建议在代码块内部也使用等宽字体固定行高否则流式输出代码时每一行高度的变化都会导致整个页面抖动。5.3 长对话里的内存与渲染次数激增对话超过几十轮之后前端消息数组里每一条都存着完整的HTML字符串再加上流式渲染期间频繁的v-html更新内存和CPU都会明显上涨。我实测过一个50轮以上的对话不做任何优化时每次新的增量渲染整个消息列表都参与diff页面帧率掉到30fps以下。两个有效的优化手段第一给消息列表项加v-memo让只有内容变化的组件才重新渲染div v-formsg in messages :keymsg.id v-memo[msg.content, msg.status] StreamingText :messagemsg / /div第二历史消息做降级渲染——只渲染前20条消息的纯文本摘要完整内容等用户点击加载更多再渲染。AI对话是典型的长列表场景虚拟滚动库vue-virtual-scroller之类值得引入但要注意它和自动滚动的配合否则流式输出时滚动位置会错乱。5.4 暗黑模式下光标看不见这个坑很小但很影响观感。如果你把光标颜色写死成#333亮色主题下没问题一切暗黑模式就完全消失。前面我给的方案是background: currentColor让光标自动继承文本颜色。如果你用的是渐变色文字或者特别亮的主题色可以再给光标加一个opacity: 0.7既保证可见又不刺眼。另外一个相关细节当光标处于行尾时如果文字颜色和消息气泡背景色接近比如浅灰气泡浅灰光标也会看不见。建议用filter: invert(1)做一层保险或者在组件初始化时读取getComputedStyle计算当前文字颜色转成光标色。5.5 开发模式下热更新导致SSE连接残留Vite热更新HMR更新组件时如果模块在onUnmounted里没有主动abort()正在进行的SSE请求这条连接就会残留后端继续推流但前端已经没人消费。最典型的症状是你改了一行样式然后控制台里看到无数个[DONE]事件还在刷。这不是线上问题但会严重干扰开发调试。解决方法是在组合式函数和组件的卸载钩子里统一清理onUnmounted(() { controller.value?.abort() if (rafId.value ! null) cancelAnimationFrame(rafId.value) })这个动作看起来无关紧要但你的开发效率会因此好很多。顺便提一句生产环境也要在路由离开对话页时清理连接否则用户切走再切回来消息流还在后台跑白白浪费流量和额度。6. 性能实测与体验调优记录6.1 一组本机实测数据我在自己的项目里做了一组对照测试环境是Chrome 120、MacBook ProM1、Vue 3.4模拟了一个每秒输出60个token的中速模型。测试指标是页面帧率和一次增量渲染的耗时方案每帧渲染次数单次增量渲染耗时长对话60轮后帧率setInterval假打字机高频但内容假2.1ms24fps直接每个chunk整体替换文本约5-8次1.8ms18fps增量追加 无批处理约3次0.6ms35fps增量追加 rAF批处理约1次0.4ms56fps增量追加 rAF v-memo约1次0.3ms60fps这个数据说明两个问题第一setInterval假打字机在长对话中帧率最低因为动画逻辑和数据流完全脱节第二增量追加rAF批处理是最划算的组合代码量增加不大性能收益却很显著。v-memo在消息数量多时才体现出价值消息少于10条时差别不大。6.2 体验层面的最后三个细节第一个是首帧占位。大模型首token往往要等1-3秒如果这个时间里界面毫无反馈用户会以为系统卡死。我的做法是在发出消息后立即显示本地用户的提问气泡同时在助手气泡位置显示一个正在思考的动画占位首token到达后替换为流式文本。有条件的话还可以接一个预计等待时间的提示但这需要后端配合没有也不强求。第二个是停止生成后的可操作状态。流式输出的过程中复制重新生成这些操作我建议隐藏或禁用只有状态变成done或error之后才出现。否则用户正在看着内容滚动一抬手不小心点到复制复制的还只是半截内容容易造成误解。第三个是流式内容中的增量复制。如果你要给用户提供复制回答按钮注意在停止生成后复制fullText而不是displayText。这两者在正常情况下一致但在中断场景下displayText可能缺了最后一段没来得及输出的内容虽然界面看起来是完整的但复制出去的文字会缺尾巴用户对比之后会觉得是Bug。6.3 我现在的默认实现方案说了这么多最后分享一下我目前在正式项目里用的默认方案Vue 3 Composition APIuseTypewriter组合式函数管状态fetch ReadableStream手写SSE客户端管数据段落级Markdown渲染管视觉稳定v-memo rAF批处理管性能AbortController管中断。这套组合从最开始就是为了数据流驱动渲染设计的不是给已完成的文本做动画包装。如果你只是做一个小Demo从第3章的代码开始抄就够了如果做生产级对话产品第4章和第5章的内容才是真正决定用户体验的部分。打字机效果本身只是冰山一角水面下是流式协议、状态机、渲染性能和异常处理一整条链路。把这套链路跑通了你以后接任何AI模型的对话界面都会非常快。我个人的体会是这类功能最忌讳的就是先做个能看的因为一旦数据流和后端接上前面所有的能看都可能要推翻重写。