ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

AI对话打字机效果:从SSE到渲染优化的前端实践

AI对话打字机效果:从SSE到渲染优化的前端实践 1. 打字机效果的底层逻辑与核心难点先说结论AI对话里的打字机效果本质是流式数据驱动的增量渲染它跟早年那种用setInterval把一句话逐字打出来的玩具实现完全是两码事。前者要处理的是网络传输层不断涌入的文本块、渲染层的性能瓶颈、以及用户对“丝滑”的主观感知后者只是定时器驱动的字符串切片游戏。做了几年AI应用前端我最大的感受是打字机效果做得好不好直接决定用户觉得你这个AI产品“聪不聪明”。数据明明已经到浏览器了但页面一个字一个字往外蹦得像PPT翻页用户会下意识觉得模型响应慢反过来如果渲染太激进页面疯狂重绘交互卡顿用户又会觉得产品很廉价。所以不要小看这个“打字机”它背后牵扯到浏览器渲染管线的理解、异步并发模型的设计、还有对长文本场景的预判。1.1 数据形态SSE与WebSocket的两难选择想要打字机跑起来第一步是拿到“持续到达”的文本流。目前业界主流的接入方式有两种SSE和WebSocket。很多人一上来就纠结选哪个我的建议是如果是纯文本生成场景优先选SSE如果要双向高频交互、或者要传二进制数据比如语音、视频再考虑WebSocket。SSE的优势在于它跑在HTTP协议之上天然适配服务端到客户端的单向流推送浏览器原生支持EventSource实现成本极低而且自带断线重连机制。要注意的是EventSource只支持GET请求而且不能自定义Header。很多团队的鉴权习惯放在Header里这时候要么让服务端改成query参数要么放弃EventSource改用fetch配合ReadableStream手动解析。我强烈推荐后者用fetch流式读取手动解析SSE数据。原因有两点一是能自定义Header二是能更好地控制连接生命周期方便中途AbortController打断生成——AI对话场景里用户的“停止生成”按钮是标配功能EventSource处理取消连接就相对别扭。const controller new AbortController(); const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token} }, body: JSON.stringify({ messages }), signal: controller.signal }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 按SSE的协议格式数据以 data: 开头以 \n\n 分隔 const chunks buffer.split(\n\n); buffer chunks.pop() ?? ; for (const chunk of chunks) { const data chunk.replace(/^data: /, ); if (data [DONE]) return; const json JSON.parse(data); onMessage(json.choices?.[0]?.delta?.content ?? ); } }这段代码的核心逻辑并不复杂但有个很容易踩的坑TextDecoder必须开启stream: true模式。如果不开遇到多字节UTF-8字符被TCP分包截断的情况比如一个中文字符的编码被拆到两个数据包里就会解码出乱码。后面在问题排查部分我会细说。1.2 增量渲染的三种策略与取舍拿到增量文本之后接下来要回答的问题是渲染层怎么处理这些不断涌入的字符串我把市面上常见的方案分成了三类第一类叫全量替换——每次拿到新文本把之前所有内容拼接起来用innerHTML整体重绘。这种方案最简单但性能最差。假设生成了1000个Token每次更新都重绘整个消息体浏览器的布局和绘制开销随文本量线性增长到了一定长度页面就开始肉眼可见地卡。实测下来超过2000字就会明显感觉到输入框和滚动条不跟手。第二类叫尾部追加——只把增量部分插入到容器末尾而不是全部重绘。element.insertAdjacentHTML(beforeend, delta)或者直接创建TextNode往appendChild。这种方式把DOM操作控制在最小范围避免了全量重绘的浪费是实际项目里性价比最高的默认选择。第三类叫虚拟滚动——只渲染可视区域内的内容超出视口的用占位符撑高度。这个方案适合超长文本的极端场景比如让AI一次性生成一篇万字长文但实现复杂度高很多而且要配合固定行高或者精确的高度测量工程成本不算低。做聊天产品通常用不上更适合做AI笔记、AI文档这类重度阅读场景。我的经验是默认用尾部追加策略配合批量提交机制把高频触发的DOM操作合并成低频批次丝滑程度基本能满足绝大多数场景。别一上来就搞虚拟滚动先把基础方案跑通再考虑极端优化。在这一步还需要想清楚一个问题文本节点怎么建。如果是纯文本用document.createTextNode()追加就很好如果消息体里需要支持Markdown渲染就要分情况处理。很多AI产品的消息里有代码块、加粗、表格这些富文本结构直接在文本流上实时跑Markdown解析器很容易出现“半截语法”被解析成脏HTML的问题——你以为代码块闭合了其实还没完。通用的做法是维护一个安全边界只增量渲染最后一个“完整”的段落比如从文本末尾往前找最近的换行符作为分界点分界点之前的内容跑一次Markdown解析分界点之后的半截内容用纯文本兜底。function getSafeRenderRange(fullText) { // 从末尾倒数找到最近的完整行边界 const lastNewline fullText.lastIndexOf(\n); // 如果连换行都没有说明可能正在输出第一个段落全部视为不安全 return lastNewline -1 ? { safe: , pending: fullText } : { safe: fullText.slice(0, lastNewline), pending: fullText.slice(lastNewline) }; }这个思路在很多成熟产品里都能看到影子核心原则就一条宁可让最后一段不渲染样式也不能把错误的中间态渲染给用户看。2. 丝滑体验的关键工程细节聊完了策略层面现在进入细节实操。很多时候方案选对了但代码写法粗糙照样跑不出丝滑感。这一节我要讲的是那些直接把体验拉开差距的“隐形工程”。2.1 requestAnimationFrame节流从源头控制渲染频率在流式场景里服务端推送的频率是不均匀的有时候一个数据包几十个字有时候连推好几个包中间间隔几毫秒。如果每次收到数据都立刻更新DOM就会出现渲染堆积。浏览器虽然会合并同一帧内的多次DOM修改但如果你在同一个事件循环里做了大量同步DOM操作主线程依然可能被拖垮。更稳妥的做法是引入requestAnimationFrame做节流把要追加的文本先推入一个队列然后注册一帧回调在回调里统一刷新DOM。这样渲染频率就跟屏幕刷新率对齐了——一般设备是60Hz也就是大约16.7毫秒一次人眼看起来最平滑也不存在过度渲染问题。let pendingContent []; let rafId null; function scheduleRender() { if (rafId) return; rafId requestAnimationFrame(() { flush(); rafId null; }); } function flush() { if (pendingContent.length 0) return; const fragment document.createDocumentFragment(); for (const text of pendingContent) { fragment.appendChild(document.createTextNode(text)); } container.appendChild(fragment); pendingContent []; // 配合滚动逻辑 scrollToBottomIfNeeded(); }这里有个技巧用DocumentFragment把多个文本节点先聚合再一次性挂载到DOM能减少多次appendChild带来的重排开销。实测在高频推送场景下节流加片段聚合可以降低约30%到50%的布局计算时间。2.2 不要每次更新都触发布局很多开发者在打字机效果里会用scrollTop scrollHeight来实现自动滚动到底部。如果生成节奏快这个赋值操作会强制浏览器同步计算布局而且触发频率可能比帧率还高。优化方案是把滚动检测也放进requestAnimationFrame的流程里并且只在消息长度超过某个阈值后才激活自动滚动。更强的优化手段是使用content-visibility: auto把屏幕外的历史消息跳过渲染。这个CSS属性对超长聊天记录特别有用它能让浏览器只渲染视口附近的内容远离视口的区域直接跳过布局和绘制。要说明的是这个属性对容器内部高度计算有一定影响在滚动跟随场景下需要额外处理占位高度所以不要无脑用。.message-list-item { content-visibility: auto; contain-intrinsic-size: auto 300px; /* 预估每条消息的高度 */ }这段CSS的意思是列表项在视口外时跳过渲染视口内正常渲染contain-intrinsic-size给浏览器一个高度估计值避免滚动条长度跳动。2.3 光标闪烁与整体节奏感的营造打字机效果之所以让人觉得“有生命”靠的不只是逐字输出还有视觉细节的包装。最典型的是光标一个闪烁的竖线符号▍让人感知到当前输出位置。我的做法是在消息容器的最末尾固定放一个光标节点用CSS动画做闪烁文本增量进来后光标自动往后“退”——其实就是它永远位于容器最后一个子元素的末尾新文本插入到它前面就行不需要额外写逻辑。.typing-cursor { display: inline-block; width: 2px; height: 1em; background: currentColor; margin-left: 2px; animation: blink 0.8s steps(1) infinite; vertical-align: text-bottom; } keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }除了光标我还会做一个节奏控制如果单帧内需要追加的文本特别长比如超过50个字符就适当退让不要在一个帧里全部塞进去而是拆成多个帧分批渲染。实际体验上字符的输出速度控制在每秒30到60个字符之间用户的阅读舒适度是最高的。这里有个细节需要拿捏如果你控制得太严格每个帧固定只渲染几个字那生成速度和渲染速度就脱节了用户看到的就是“AI早回答完了但字还在慢慢蹦”反而比一次性出现的观感更差。正确的做法是渲染速度尽量跟随数据到达速度只在数据爆发式到达时做轻微限速保证看到的是“流”而不是“波”。3. 实操落地从零搭一个完整的打字机消息组件这一节我拿一个具体例子演示完整的落地流程。假设我们正在做一个AI问答页面后端通过SSE推送增量内容前端要在一个消息气泡里跑打字机效果同时支持用户停止生成。3.1 整体代码结构组件拆成三块一是fetch流式请求模块负责拉数据、解析SSE、抛给上层二是状态管理模块维护当前消息文本、是否生成中、错误状态三是视图渲染模块负责增量写入DOM、光标管理、滚动跟灯。class StreamingChat { constructor(options) { this.container options.container; // 消息容器DOM this.apiUrl options.apiUrl; this.headers options.headers || {}; this.controller null; this.isGenerating false; this.aborted false; this.pendingText []; this.rafId null; this.cursorEl this.createCursor(); this.messageEl null; // 当前正在生成的消息体 } async sendMessage(userText) { // 1. 创建新的消息气泡 this.ensureMessageBubble(); // 2. 发起流式请求 this.controller new AbortController(); this.isGenerating true; try { const response await fetch(this.apiUrl, { method: POST, headers: { Content-Type: application/json, ...this.headers }, body: JSON.stringify({ message: userText }), signal: this.controller.signal }); const reader response.body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; while (true) { const { done, value } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); const events buffer.split(\n\n); buffer events.pop() ?? ; for (const event of events) { this.handleSSEEvent(event); } } } catch (err) { if (err.name AbortError) { // 用户主动停止不算错误 } else { this.handleError(err); } } finally { this.isGenerating false; this.removeCursor(); } } handleSSEEvent(event) { if (!event.startsWith(data:)) return; const data event.replace(/^data:\s*/, ).trim(); if (data [DONE]) return; const json JSON.parse(data); const delta json.choices?.[0]?.delta?.content ?? ; if (!delta) return; // 推入队列并调度渲染 this.pendingText.push(delta); this.scheduleRender(); } stop() { this.aborted true; this.controller?.abort(); } }3.2 渲染层与滚动跟随的正确配合渲染层需要一个ensureMessageBubble方法如果当前没有正在生成的消息体就新建一个div把光标节点放进去如果已经存在就继续往里追加。这里的关键点是在flush里处理追加顺序新文本要插到光标节点之前。ensureMessageBubble() { if (this.messageEl) return; this.messageEl document.createElement(div); this.messageEl.className assistant-message; this.container.appendChild(this.messageEl); this.messageEl.appendChild(this.cursorEl); this.scrollToBottom(); } flush() { if (this.pendingText.length 0) return; const fragment document.createDocumentFragment(); for (const text of this.pendingText) { fragment.appendChild(document.createTextNode(text)); } this.messageEl.insertBefore(fragment, this.cursorEl); this.pendingText []; this.scrollToBottom(); } scrollToBottom() { // 用requestAnimationFrame包裹避免强制同步布局 requestAnimationFrame(() { this.container.scrollTop this.container.scrollHeight; }); }滚动跟随这里有一个被很多人忽略的体验问题如果用户正在往上翻看前面的对话自动滚动会把他的阅读位置强行拉回底部非常恼人。所以在真实项目里要加一个“距离底部阈值”的判断只有滚动条已经处于底部附近比如距离底部小于80像素时才触发自动滚动否则说明用户在看历史不能打扰。scrollToBottom() { const { scrollTop, scrollHeight, clientHeight } this.container; const distanceToBottom scrollHeight - scrollTop - clientHeight; if (distanceToBottom 80) return; // 用户正在向上翻阅不打扰 this.container.scrollTop scrollHeight; }这个80像素的阈值不是拍脑袋定的经验值是小于40像素太敏感稍微滚动一点就触发自动拉底大于150像素又会让人感觉自动滚动很迟钝。80左右是比较舒服的区间。3.3 停止生成与生命周期管理停止生成的实现核心是AbortController。这里有个隐藏问题fetch被中断后reader的循环会被抛异常打断如果你在catch里没有做区分就会把“用户主动停止”误判为“网络错误”界面上弹出错误提示这不行。所以在上面的代码里我通过err.name AbortError做区分。前端的问题解决了但后端同样要处理abort事件客户端断开连接后服务端应该联动取消大模型的任务否则推理还在继续烧算力。在Node.js里可以用req.on(close, () { /* 取消LLM任务 */ })这样才能做到全链路“真停止”。生命周期管理还有一层含义组件卸载时必须清理未完成的流式请求和溢出的事件队列。不然用户从聊天页跳转到其他页面组件已经销毁了但异步的回调还在执行控制台就会报错甚至出现内存泄漏。在destroy()方法里要干三件事取消fetch请求取消未执行的requestAnimationFrame回调清空引用。destroy() { this.controller?.abort(); if (this.rafId) cancelAnimationFrame(this.rafId); this.container null; this.messageEl null; this.pendingText []; }4. 性能优化与常见问题排查实录打字机效果表面看着是个小功能但实际踩坑的深度能排到所有前端功能的前三名。这一节不绕弯子直接把我遇到过的问题和排查思路整理出来。4.1 中文乱码问题90%是解码姿势不对流式场景里中文乱码几乎是人人都要碰一次的问题根源在于UTF-8是一个变长编码一个汉字占3个字节。如果服务端推送的数据包刚好把一个汉字的三个字节切成了两半——前一个包收到2个字节后一个包收到1个字节——那么解码器会因为没有足够的字节去解码一个完整字符而失败展示出来就成了。错误的写法是每次decoder.decode(value)都不传stream参数这样解码器会默认“这一包数据是完整的多字节序列”遇到被切开的情况就直接输出替换符。正确做法就是我前面写的decoder.decode(value, { stream: true })它的意思是这次没解完的字节先放进内部缓冲区等下一次数据到达再继续解。还有一个坑如果每个数据包你都用新的TextDecoder实例去decode同样的乱码问题依然会出现因为缓冲区不共享。所以decoder实例必须在循环外创建一次。排查方法很简单在服务端控制台看原始字节流看中文切包的位置在前端把收到的每个value的byteLength打出来如果发现某次读取的字节数不是3的整数倍基本就能锁定是这里的代码逻辑问题。4.2 消息内容越来越少丢失更新的罪魁祸首有段时间我们的聊天窗口经常出现“AI回复的内容突然少了一截好像被吞了”。排查很久发现问题出在并发追加的顺序错乱上。当时有个同事把onMessage回调直接在异步循环里调用了messageEl.textContent delta而不是走队列调度。如果两个数据包几乎同时到达异步事件的执行顺序无法保证后到的数据反而先渲染前面的就被覆盖了。解决办法就是统一走“先push到队列再统一flush”的单线程模型。这里还有个更隐蔽的问题如果flush里把pendingText []放在遍历之前之后又有人向数组里push新数据就可能出现“这次渲染的文本丢了”。所以代码里清空数组的操作一定要放在遍历完成之后。4.3 页面逐渐变卡重排与重绘的累积效应长对话场景下页面的DOM节点数量会持续增长如果不做处理最终会突破浏览器的性能临界点表现为消息加载越来越慢、滚动越来越卡、输入框输入都有延迟。我在实际项目里做过一次压测纯文本消息体累积到1万字符时flush耗时大约2到3毫秒这个体量还算能承受但如果是带Markdown渲染的消息体每次追加都要重新parse整段文本耗时能飙到15毫秒以上用户就能明显感觉到按键有延迟了。两个优化方案结合用效果最好一是分段渲染而不是全程增量解析拿最后的分界点缓存已渲染的DOM片段新文本只解析增量段落二是对历史消息用content-visibility: auto让远离视口的DOM节点跳过布局和绘制。还有一个很实用的技巧当一条消息生成完成收到[DONE]后把这条消息的DOM从“动态渲染模式”切换为“静态定型模式”——比如把光标节点移除、把不再变化的临时类名清理掉这样浏览器对这条消息的样式计算就再也不需要动态参与了。4.4 高亮闪烁与白屏闪烁样式注入时序问题很多人在支持Markdown的聊天窗口里遇到过内容明明已经在DOM里了但页面上会出现一段白屏闪烁或样式“闪一下”才恢复正常。这个问题的原因通常是样式表加载的时机晚于内容注入。如果你的Markdown样式是打包时异步加载的chunk那么流式渲染期间解析出来的HTML标签可能还没有匹配的CSS规则浏览器会先以默认样式渲染等CSS加载完再重新计算视觉上就产生了跳动。解决办法有二要么把聊天消息的Markdown样式抽到主chunk里保证首屏同步加载要么在渲染前用document.fonts.ready和CSS.supports做一次样式就绪检测等样式ready了再输出带标签的HTML片段。4.5 乱序滚动的玄学滚动监听与回流自动滚动的“反直觉”问题也很常见明明调用了scrollTop scrollHeight但页面就是停在半截不动。排查方向有两个一是滚动容器选错了设置了overflow: auto的可能是外层容器但你赋值的是内层二是滚动代码执行的时候DOM还没来得及更新布局scrollHeight还是旧值所以算出来的滚到底的位置也是错的。解决办法很直接把滚动赋值放进requestAnimationFrame里而且要用{ passive: true }的滚动监听器做性能适配。还有一个经验不要在scroll事件里做复杂计算如果要做“距离底部判定”用requestAnimationFrame配合节流不要每次scroll回调都读取scrollTop、scrollHeight这些强制同步布局的属性。5. 进阶路线从够用到好用还要注意什么打字机效果做好之后我一般会建议团队再往两个方向打磨一下因为这两个方向直接决定产品给用户留下的“高级感”。一是Markdown与代码块的增量安全渲染。市面上成熟方案通常会做一个流式Markdown解析器内部维护“分块状态”只对完整块做样式转换对未闭合的段落做纯文本兜底。自己实现的话原则就是我前面说的以最后一个换行符为安全边界。如果想做到更细可以进一步处理代码块统计文本中出现了多少次三个反引号奇数次说明代码块还没闭合那最近的一个代码块区域就全部走纯文本。二是与前端框架的融合问题。如果是React项目千万别直接操作DOM去appendChildReact的虚拟DOM和真实DOM会分叉。正确方式是把增量文本维护在state里用useEffect监听变化后追加到ref节点。但要避免一个经典性能陷阱每来一个token就setState一次这会让React在很短的周期内反复进入render-commit流程长文本时帧率立刻掉下来。我的建议是把**“token流”和“UI渲染”两层解耦**用一个自定义hook管理token队列每帧最多触发一次setState视图层再依赖useMemo做优化。这样既利用框架的数据流管理又保住了手动DOM操作的高性能边界。有兴趣的团队还可以研究一下useSyncExternalStore做外部store与React的同步不过这是进阶玩法了。根据我的经验把打字机效果做到位对AI产品前端的整体体验提升是十倍级别的。因为用户和AI的每一次对话都在跟这块“动起来”的文本打交道它的每一帧流畅度、每一个光标闪烁的细节都在潜意识里塑造用户对产品的判断。先跑通基础版再逐步把滚动、Markdown、长文本性能、生命周期管理这些细节填满这个功能的深度是足够吃一段时间的。
返回列表