
做过AI对话页面的前端多半都经历过一个尴尬瞬间后端明明已经把流式数据吐回来了页面却像被冻住一样光标停在原地过了几秒突然“哗”地蹦出一大段文字。用户一边盯着转圈的loading一边已经把第二句话敲了出来。这个观感和“丝滑”差着十万八千里。这篇文章要聊的就是如何让AI“打字机”效果在浏览器里真正丝滑地跑起来。我会从渲染管线的瓶颈拆解、流式数据通道选型、渲染节奏控制、生产环境边界情况一直讲到性能验证方法每一块都给可落地的方案和代码。适合正在做语义对话页面、或者需要处理流式输出的前端同学参考里面不少结论也适用于日志流、行情流、实时字幕这类高频增量渲染场景。1. “打字机”卡顿的真正根源三个隐形瓶颈先说结论打字机效果卡顿绝大多数时候不是网速问题也不是模型响应太慢而是前端渲染策略出了问题。同样的数据交给不同的渲染方式体验差别非常大。想优化先得搞清楚瓶颈到底在哪。1.1 第一个隐形瓶颈高频DOM更新撞上主线程繁忙流式接口的数据到达频率很高一小段Token可能只有几十个字节网络侧可能几十毫秒甚至几毫秒就推一次。如果每收到一个chunk就直接操作DOM浏览器主线程会被大量微任务塞满渲染任务不断排队重排。这就像是厨房里接了个不断滴水的水龙头师傅每接一滴水就立刻重新摆一次锅碗瓢盆一顿饭做完大部分时间都耗在反复收拾上了。主线程一旦繁忙页面上的交互——点击、滚动、输入——都会被拖慢。尤其当用户一边看AI输出一边想在输入框里打字时这种卡顿极其明显。很多性能优化方案里提到的“合并写”“批量提交”本质都是为了让主线程从零碎劳动里解脱出来。1.2 第二个隐形瓶颈整段重排带来的代价逐级放大很多初学者写流式渲染最顺手的就是innerHTML 或textContent wholeText。这种写法在文本短的时候几乎感觉不到问题但随着页面上的内容越来越长每追加一个片段浏览器都要把整个文本内容重新解析、重新布局一遍。更隐蔽的是字符串本身不可变textContent 旧内容 新内容会先生成一份全新的完整字符串然后整个替换。旧字符串在内存里等GC回收新字符串又占一份。反复几十次之后内存峰值和GC压力都会肉眼可见地上涨表现在用户端就是滚轮一顿一顿页面像打着嗝。1.3 第三个隐形瓶颈一次性刷一段长文本的同步循环还有一种常见写法是先把流式数据全部收完等拿到完整内容之后再用for循环逐字往DOM里塞。这种方案其实把问题从“边来边渲染”变成了“一次性同步渲染大量节点”中间没有给浏览器交还控制权的机会。只要循环体本身超过几十毫秒主线程就会出现长任务用户的点击事件、滚动事件全部排队等待。更糟的是这会让“打字机”看起来像“倒水”——前面半天没反应最后哗啦一下全出来了完全失去了流式输出的可读价值。真正的流式项目必须放弃“等全量再播放”的思路换成增量上屏。2. 数据通道选型从“等全部返回”到“边生成边到货”解决了“为什么卡”的问题接着要看数据源。流式渲染的前提是后端真的愿意把内容分块推给前端。如果接口一次性返回完整JSON那前端做得再好也只能是“假流式”。2.1 两种流式方案的差异假流式与真流式假流式是等接口全部返回后客户端再按字符分批显示。优点是实现简单、对网络抖动免疫缺点是用户要等更久才能看到第一个字感知延迟高。真流式是后端把生成过程逐步推送前端边收边渲染用户的整体等待感会弱很多这也是现在大模型产品默认的交互形态。不过假流式不是一无是处。当网络不稳定、或者服务端代理层不支持流式转发时可以把它当作降级策略接口全量返回后前端用同样的缓冲队列做“打字机播放”。至少页面是动的用户不会以为系统挂了。工程上建议两种通道做抽象上层渲染逻辑保持一致底层数据源可以随时切换。2.2 SSE、fetch流式、WebSocket怎么选大模型场景最常用的是SSE也就是Server-Sent Events。服务端以text/event-stream格式持续推送文本格式清晰断线还有EventSource自带的自动重连。但原生EventSource有个硬伤不能自定义请求头。很多模型服务需要Authorization鉴权这时候还得退回fetch流式。fetch流式的本质是拿到res.body这个ReadableStream自己读、自己解析。灵活度最高可以随便带请求头也可以随时调用AbortController中断缺点是要手动处理重连和错误恢复。WebSocket一般用在后端需要双向持续通信的场景如果只是模型单向输出SSE或者fetch流式更轻量、更适合HTTP架构。数据通道方向鉴权自动重连适用场景EventSource服务端推送有限自带最简单的SSE场景fetch ReadableStream服务端推送灵活自行实现大模型流式主力方案WebSocket双向灵活自行实现需要客户端持续上报的场景2.3 增量解析的编码陷阱中文和多字节字符截断流式解析不只有JSON解析还有一个特别容易踩的编码坑。网络层每次传过来的chunk是字节流多字节字符可能被拦腰截断。比如一个中文字符在UTF-8中占三个字节第一次chunk收到了前两个字节第二次才收到最后一个字节。如果直接把每段字节转字符串就会在边界处解出半个字符出现乱码。正确做法是用TextDecoder的stream: true模式它会自动缓存未终结的字节序列。下面这段是fetch流式读取的通用骨架我项目里一直在用const controller new AbortController(); async function streamChat(messages) { const res await fetch(/v1/chat/completions, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, }, body: JSON.stringify({ messages, stream: true }), signal: controller.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; // stream: true 是关键防止多字节字符被截断 buffer decoder.decode(value, { stream: true }); const lines buffer.split(\n); buffer lines.pop(); for (const line of lines) { if (!line.startsWith(data:)) continue; const data line.slice(5).trim(); if (data [DONE]) return; const chunk parseChunk(data); if (chunk?.delta) { enqueue(chunk.delta); } } } }如果你后续要对流式内容做SSE格式的严格解析建议用状态机处理事件边界而不是单纯按行切分。不过在大多数大模型接口里每一行就是一个完整data事件按行处理已经够用。注意buffer不能乱清空它负责保存跨行残留内容。3. 渲染节奏控制核心优化思路与可落地的代码数据到前端以后真正的战争才开始。流式渲染的黄金法则是数据到达频率和DOM更新频率解耦。网络什么时候把数据送来我们控制不了但DOM什么时候更新、更新多少必须由前端说了算。3.1 为什么说“拿到chunk就append”是性能毒药有人觉得既然流式数据到了为什么不能立即插入DOM因为chunk到达是不均匀的有时候几十毫秒来一两个字符有时候突然来一大段。紧跟数据节奏更新DOM用户看到的画面就会忽快忽慢还伴随频繁的布局计算。更好的思路是给渲染层加一个缓冲队列。网络数据先放队列里渲染调度器按照屏幕刷新率每帧从队列里取固定量的文本去更新DOM。这就像输液管上的调速器药水速度由调速器控制而不是由瓶子里液体的多少决定。缓冲的存在天然把高频到达和低频渲染隔离开。3.2 缓冲队列加帧调度每帧只做有限的工作用requestAnimationFrame做帧调度比setInterval更贴合浏览器渲染机制。RAF在每次屏幕刷新前执行浏览器后台标签页会自动暂停RAF也不会空转消耗CPU。每帧里只消耗固定的字符预算超出的内容留到下一帧这样主线程每次的工作量都很小。const output document.querySelector(#output); const MAX_CHARS_PER_FRAME 12; const buffer []; let flushing false; function enqueue(text) { buffer.push(text); if (!flushing) { flushing true; requestAnimationFrame(flush); } } function flush() { let budget MAX_CHARS_PER_FRAME; while (budget 0 buffer.length 0) { const part buffer[0]; if (part.length budget) { buffer.shift(); output.insertAdjacentText(beforeend, part); budget - part.length; } else { // 只消费预算内的字符剩余的放回队列头部 buffer[0] part.slice(budget); output.insertAdjacentText(beforeend, part.slice(0, budget)); budget 0; } } if (buffer.length 0) { requestAnimationFrame(flush); } else { flushing false; } }预算数值需要结合文本类型调整。纯中文场景每帧12个字符已经接近自然阅读速度英文按token组合可以放宽到20~30个字符。宁可稍慢一点也要保证每一帧不超时。这里还有一个易漏的细节如果页面切到后台再切回来RAF积累的队列会在恢复后密集消费。可以给flush增加单次最多连续执行的判断或者监听visibilitychange时降低预算避免恢复标签页瞬间出现长任务。3.3 控制“打字”速度字符预算的边界问题字符预算不是简单数String.length就完事了。JS的字符串长度统计的是UTF-16码元一个emoji在length里可能占两个位置直接slice(0, 12)可能把emoji从代理对中间劈开渲染成乱码。要稳妥处理的话可以把字符串先转成码点数组按可见字符计数。比如Array.from(你好)能得到按码点切分的数组。不过Array.from对服务端推送的增量字符串来说成本不高可以放心用。如果对中文、emoji混排有更高精度要求可以引入Intl.Segmenter按语言感知切分只是性能开销相对大一些流式场景通常没必要。3.4 DOM与CSS层容易被忽略的细节选对更新方式比想象中重要。innerHTML 会让浏览器把现有内容当HTML重新解析textContent 完整内容会破坏原有的文本节点引用。用insertAdjacentText或者Range.insertNode把新文本作为独立文本节点追加代价最小也不会误解析标签字符。很多项目还会顺手在打字机输出区用contenteditable这会给光标管理和选区同步带来一堆麻烦。输出区应该是只读容器输入区单独维护。光标闪烁效果不要做成文本的一部分用绝对定位元素加CSS动画就够别频繁操作光标DOM。另外给流式内容区域设置contain: content可以通知浏览器该区域内部的样式和布局不会影响外部页面减少不必要的重排范围。.stream-area { contain: content; } .caret { position: absolute; width: 2px; height: 1em; background: currentColor; animation: blink 1s step-end infinite; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }4. 生产环境的边界情况中断、并发、水合与长对话做一个Demo容易做好生产环境很难。流式渲染上线后最先被挑战的一定是各种边界情况用户点击停止、多个输出并行、断线重试、首屏渲染衔接。这些场景没处理好体验会比卡顿还糟糕。4.1 用户点击“停止生成”之后发生了什么用户点了停止前端第一件事是调用AbortController.abort()随后fetch流会抛出一个AbortError。这个错误不应该被当成普通异常上报要在代码里单独捕获。很多产品在这时候会粗暴清空页面内容这是体验事故。用户停下来是想“别再继续生成”不是“把你已经说的都删掉”。正确的流程是先中止网络读取再把缓冲区里已经收到但还没渲染的文本一次性刷完最后补一个“已停止生成”的状态标记。你可以简单记录一条元信息但不要把已有的输出搞丢。4.2 多个流并发时的调度与优先级现在不少页面支持忘记回复的语义一个问题可以触发多个专家模型同时生成答案或者一个结果卡片一次起多个并行流。如果每个流各自开一套RAF调度器互相之间会争抢帧预算最终还是一样卡。更好的做法是全局只保留一个调度器每个数据流都注册自己的渲染队列。调度器在每帧里按优先级从各个队列取内容优先级高的流先渲染。这就像机场塔台统一指挥多架飞机降落而不是每架飞机自己冲过来。工程实现上给每个队列附带一个priority字段每帧按优先级排序按比例消费。浏览器对同一域名的并发连接也有上限别一口气开太多流超过连接数后其他流会排队等待。4.3 断线重试与内容去重长文本生成过程中网络闪断很常见。断线后用户已经看到了一部分输出。如果直接重发请求可能把之前的内容重复生成一遍。前端能做的是在连接断开瞬间先把已渲染内容保留下来然后带着同一个messageId发起重试请求由服务端判断是否支持从断点继续续传。如果服务端不支持续传可以选择重新生成但要让用户明确知道这是一个新的生成过程而不是无声无息地把旧内容替换掉。重试要用指数退避第一次等1秒第二次等2秒间隔递增不能变成无脑死循环。这个环节的文案和状态比技术实现更容易被忽略。4.4 服务端渲染与水合时的增量衔接有些页面为了让首屏更快会在服务端先渲染出一部分AI回答内容。这时候最尴尬的问题就来了浏览器加载完JS后为了让流式DOM接管如果直接从头渲染页面上的文字会闪一下——前半段是服务端渲染的后半段又由客户端重复输出一遍。我见过一种实用的方案服务端在输出根节点上写一个>const observer new PerformanceObserver((list) { for (const entry of list.getEntries()) { if (entry.duration 50) { // 上报长任务可附带发生的页面阶段 reportLongTask(entry.duration, entry.startTime); } } }); observer.observe({ entryTypes: [longtask] });如果页面里有AI流式输出建议把上报数据里带上“当前是否在流式渲染”的标记。这样可以把普通页面卡顿和流式渲染引起的卡顿分开统计排查问题更有针对性。INP指标近年已经成为核心体验指标代表用户交互到页面响应的延迟流式渲染如果拖累主线程INP会明显变差。5.3 一次模拟对比实验的实测数据我自己在内部模拟流式输出Demo上做过一组对比。同样的文本内容相同的网络模拟条件只改渲染策略。优化前是每收到chunk立刻append到容器优化后是缓冲队列加RAF帧预算控制每帧最多12个字符。指标优化前优化后单个长任务最大时长约270ms约35ms5分钟内长任务数量403单帧DOM操作耗时峰值90ms8ms页面卡顿感明显基本无感数值不一定代表所有环境但方向非常明确瓶颈不在“收到数据太慢”而在“每次更新得太重”。把单次更新压到几毫秒以内页面自然就稳了。这套结论也解释了一个常见反直觉现象为什么网络明明很快打字机还是卡——因为瓶颈根本不在于传输而在于渲染策略。5.4 从“不卡”到“丝滑”的工程化演进性能达标之后可以把渲染逻辑抽象成相对独立的模块方便复用和替换。我习惯拆三层数据接入层负责流解析调度层负责帧预算和队列渲染层只负责把文本片段挂到DOM上。这样即便后端从SSE换成WebSocket前端也只需要改接入层。测试流式页面时不建议每次都连真实模型不然数据不可控还费钱。可以用一个本地模拟源按固定时间间隔往接入层emit不同的文本块方便反复验证不同字符预算下的视觉效果。我自己实测下来会把每帧12个中文字符作为一个起始值再根据业务文案风格微调。丝滑不是无脑打印得更快而是让页面始终有节奏地呈现内容。最后分享一个我踩过几次的坑千万不要等整体优化完再统一测性能。先接监控再逐步优化每一步都用长任务数据验证。那些自以为能省掉的细节比如字符预算、代理对截断、水合偏移量几乎都是实测中暴露出来的。把这些细节打磨好AI打字机才能真正从“能跑”变成“丝滑”。