AI Chat 前端流式接收:SSE、ReadableStream 与增量渲染
发布时间:2026/10/1 18:57:38来源:尧图网络
前端接流式数据这事儿说穿了就一句话把服务端一个字一个字吐出来的内容尽可能快地、不丢字节地、不乱顺序地贴到屏幕上。但真做过 AI Chat 的人都知道这句话背后藏着一堆琐碎到让人抓头的细节——字节流被切成两半的多字节字符、横跨两个 chunk 的 JSON、疯狂触发重渲染的高频 delta、用户点了停止但请求还在跑、内容往上滚和用户手动上滑打架。这篇东西就是把我这几年在 AI Chat 场景里做流式接收与消费的经验从头到尾摊开讲一遍协议长什么样、解码怎么防坑、代码怎么写、状态怎么管、性能怎么调。适合已经被ReadableStream折磨过一轮、或者正准备第一次接流式接口的前端同学也适合只想搞清楚 SSE 到底怎么回事的读者。内容全部围绕「接收流数据 处理数据」这一条主线不扯别的。1. 为什么 AI Chat 的前端必须走流式接收1.1 从一次「转圈八秒」说起阻塞式请求的体验断点早期的 AI 对话产品基本都是这样做的前端把用户的问题 POST 到后端后端等模型把整段回答生成完一次性返回一个完整 JSON前端再渲染出来。逻辑简单、错误处理也简单但体验是灾难级的。模型生成 500 个 token 大概要 5 到 10 秒这期间用户看到的就是一个转圈动画啥也干不了甚至不知道请求到底有没有发出去。人机对话的节奏感很微妙。人在打字的时候对方哪怕只是「嗯」一声你也会觉得对话在进行反过来一片安静超过两三秒人就会开始怀疑是不是掉线了。流式接收解决的正是这个问题——首字节到达时间通常只有几百毫秒用户立刻就能看到第一个字蹦出来剩下的内容边生成边渲染主观等待感直接砍掉一大半。这里有个关键认知流式接收不是「优化」而是 AI Chat 这类产品的体验底线。你可以在很多地方省成本但这一块省不了因为它直接决定了用户觉得这个产品是「活的」还是「卡的」。还有个很容易被忽略的收益提前失败。非流式请求下后端要等模型全部生成完才知道出错了前端的错误提示会延迟到十几秒后。流式下第一帧就是错误事件的话前端 300 毫秒内就能弹提示用户重试的成本也随之降低。1.2 SSE、Fetch ReadableStream、WebSocket 三种姿势的取舍做 AI Chat 的流式接收主流就三条路各有各的适用面。我先把结论摆上再讲原因。方案底层形态适用场景主要短板SSEtext/event-stream单向服务端推送基于 HTTP绝大多数文本生成场景单向、部分环境下有连接数限制Fetch ReadableStream自己读裸字节流需要自定义协议、自定义分帧需要手写解析器边界处理靠自己WebSocket全双工长连接双向实时交互、多模态低延迟运维复杂、断线重连要自己写这里要澄清一个常见误解很多人以为 SSE 是EventSource的专属能力。其实不是。EventSource只是浏览器内置的一个 SSE 客户端实现它有几个硬伤——只支持 GET、不能自定义请求头、不能带 body。而 AI Chat 场景几乎必然是 POST 带一坨消息历史所以绝大多数项目真正的做法是服务端按 SSE 格式输出前端用fetchReadableStream自己读、自己解析。这个组合的好处非常明显。首先它复用普通 HTTP 请求的全部能力鉴权头、自定义 header、请求体都能用。其次fetch返回的response.body是一个ReadableStream你可以精确控制读取节奏、暂停、取消。第三你有AbortController这个统一的中断入口用户点「停止生成」时直接abort()连接立刻断开比 WebSocket 里自己去协商一个「停止」消息干净得多。那 WebSocket 什么时候值得上我的判断标准是如果你需要在同一条连接上做「模型输出中用户还能打断并插入新指令」这种双向高频交互或者要同时推音频、推中间状态、推工具调用进度WebSocket 才划算。纯粹一问一答的文本生成上 WebSocket 属于把简单问题复杂化——你还得自己设计心跳、重连、消息序号、粘包处理收益却很小。提示不管选哪条路前端都要假设「流随时会断」。网络切换、服务端超时、网关空闲断开都是常态而非异常。1.3 流式给了前端哪些新的责任非流式时代前端拿到的是一份已经完整、已经合法的数据。流式之后你拿到的是一段一段、可能不完整、可能被切断、需要你自己拼装的半成品。责任转移得非常明显。第一完整性责任。一个 UTF-8 的中文字符占 3 个字节网络分片完全可能在中间切断你拿到的可能是半个汉字。如果直接decode就会出现那个经典的乱码方块。第二边界责任。SSE 的事件分隔符是空行但一个 chunk 里可能包含两个半事件也可能半个事件都没有。你需要自己维护 buffer。第三顺序与状态责任。流式输出意味着 UI 是「渐进构建」的你得清楚当前处于「思考中」「生成中」「已停止」「出错」哪个状态否则会出现「用户点了停止内容又蹦出来两个字」这种诡异现象。第四性能责任。模型吐 token 的速度可能是每秒几十个 delta如果每个 delta 都触发一次完整的 Markdown 解析 高亮 全量重渲染页面会肉眼可见地卡。把这四条责任理清楚后面的实现就是按图索骥了。2. 把字节流拆成可用数据协议格式与解码细节2.1 SSE 数据帧长什么样为什么必须有 bufferSSE 的协议本身非常简单它是纯文本、以行为单位的结构。一个完整的事件大概长这样event: message data: {id:c1,delta:你好} data: {id:c1,delta:世界} data: [DONE]规则记四条就够用了每个事件由若干行组成行内以第一个冒号分隔字段名和值冒号后面那个空格会被吃掉。以data:开头的行可以有多个它们会用换行符拼成同一份数据。空行代表一个事件的结束。以:开头的行是注释常见于心跳保活直接忽略。关键点在于空行是唯一的分隔符而网络 chunk 的切分和事件边界毫无关系。你完全可能收到一个 chunk 内容为data: {delta:你下一个 chunk 是好}\n\n。所以解析器的第一原则就是永远不要把当前 chunk 当成完整事件来解析先往 buffer 里追加再按分隔符切。这里有个特别容易被漏掉的细节换行符不一定是\n某些服务端和中间层会输出\r\n。如果你只split(\n\n)遇到\r\n\r\n的时候永远切不开表现就是「内容全卡在缓冲区一个字都不渲染」而且日志还看不出错。稳妥做法是先把\r\n归一化成\n再处理。2.2 TextDecoder 的 stream 选项与被截断的多字节字符这是我觉得最值得单独讲的一个坑因为它极其隐蔽而且在中文场景下几乎必然触发。TextDecoder有一个很多人不知道的选项叫stream。它的默认值是false意思是「我给你的这段字节是完整的放心解码」。但流式场景下你每次read()拿到的value是Uint8Array它极可能在多字节字符中间断开。举个具体的例子「好」这个字的 UTF-8 编码是E5 A5 BD三个字节。如果第一个 chunk 的末尾只有E5 A5new TextDecoder().decode(chunk)会直接吐出一个替换字符乱码而且这个字符已经错了后面的 chunk 无法挽救。正确写法是把stream打开const decoder new TextDecoder(utf-8); // 读取循环中 buffer decoder.decode(value, { stream: true }); // 流结束后必须再调一次 flush buffer decoder.decode();{ stream: true }的含义是告诉解码器「这段可能不完整请把尾部没凑齐的字节先缓存起来等下次一起解」。循环结束后再调一次不带参数的decode()把残留字节吐出来这是标准的收尾动作漏掉它的话如果最后几个字节刚好构成一个多字节字符末尾就会少字。注意不要用String.fromCharCode.apply(null, chunk)这种老写法处理流式字节。它按字节硬转多字节字符必错而且大 chunk 下还可能爆栈。2.3 JSON 跨 chunk 断裂的三种典型现场字节层的问题解决后下一层是 JSON。由于你已经按空行切出了完整事件理论上data:后面的 JSON 是完整的。但实际项目里我遇到过三种断裂现场都值得提前防。第一种服务端没有严格遵守 SSE 分帧。有些自研网关为了省事直接把裸 JSON 流推出来每条之间只加个\n。这时候你按空行切就永远切不出东西。应对办法是先做一个探测连接建立后看前 2KB 内容里有没有data:前缀没有就走「按行切 JSON」的降级解析器。这套探测逻辑我建议封装成独立函数因为一旦服务端改协议你只需要改一处。第二种单个事件的数据被拆在多行data:里。比如某些实现会这样输出data: {id:c1, data: delta:你好}按规范这两行应该用\n拼起来再解析。如果服务端拼的是 JSON 片段拼完其实是一个合法 JSON但如果你逐行JSON.parse第一行必然抛错。所以解析的时候要先把同一事件的所有data:行收集成数组再用\njoin最后 parse。第三种[DONE]标记与业务数据混在同一个 chunk。你的解析器必须能容忍「这一帧不是 JSON」的情况用try/catch包住JSON.parse解析失败就静默跳过并记一条 debug 日志绝不能让它冒泡成全局错误弹窗——否则用户会看到「生成到一半突然报错」的假故障。function safeParse(raw) { try { return JSON.parse(raw); } catch (e) { if (process.env.NODE_ENV ! production) { console.warn([stream] 非法 JSON 帧已跳过:, raw.slice(0, 200)); } return null; } }3. 代码落地一个能直接抄的流式读取与增量渲染方案3.1 请求层fetch AbortController 的完整写法请求层要做三件事发起一个带Accept: text/event-stream的 POST、拿到response.body的 reader、暴露一个可随时触发的中断入口。我习惯把它写成一个独立模块和 UI 框架完全解耦。export function createStreamChat() { let controller null; async function start(payload, handlers) { // 每次开始前先把上一次残留的请求干掉 controller?.abort(); controller new AbortController(); const signal controller.signal; let resp; try { resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json, Accept: text/event-stream, }, body: JSON.stringify(payload), signal, // 如果你的鉴权走 cookie这一行不能少否则跨域下 cookie 不带 credentials: include, }); } catch (err) { if (err.name AbortError) return; handlers.onError?.(err); return; } // 关键HTTP 层面成功不代表业务成功服务端可能用 200 包了一个错误体 if (!resp.ok) { const text await resp.text().catch(() ); handlers.onError?.(new Error(HTTP ${resp.status} ${text.slice(0, 200)})); return; } if (!resp.body) { handlers.onError?.(new Error(当前环境不支持流式响应)); return; } return readStream(resp.body, handlers, signal); } function stop() { controller?.abort(); controller null; } return { start, stop }; }有三处细节我想强调。第一abort()之后fetch的 promise 会抛AbortError这是正常流程不是异常必须单独识别出来静默处理否则用户每点一次停止监控上就多一条错误。第二!resp.ok时不要直接读 body 当 JSON很多网关在错误时返回的是 HTML 错误页resp.json()会二次抛错把真正的状态码信息盖掉用text()更稳。第三controller要挂在闭包外这样组件卸载、切换会话时能拿到同一个引用去中断避免出现「切换了会话旧会话的内容还在往新会话里灌」的串台问题。再补一个超时保护。fetch本身没有超时但流式场景下你更需要的是首字节超时和空闲超时两个指标前者超过 15 秒说明服务端可能挂了后者超过 30 秒没有新数据说明连接已经僵死。实现方式是写一个setTimeout每收到一次数据就 reset 一次。3.2 解析层手写 SSE parser 的边界处理解析层的核心就是 buffer 循环切分。这段代码我改过很多版现在这版是比较稳的。async function readStream(body, handlers, signal) { const reader body.getReader(); const decoder new TextDecoder(utf-8); let buffer ; try { while (true) { const { value, done } await reader.read(); if (done) break; buffer decoder.decode(value, { stream: true }); // 统一换行符防止 \r\n\r\n 切不开 buffer buffer.replace(/\r\n/g, \n); let idx; // 循环切出所有完整事件 while ((idx buffer.indexOf(\n\n)) ! -1) { const rawEvent buffer.slice(0, idx); buffer buffer.slice(idx 2); handleEvent(rawEvent, handlers); } } // flush 残留字节 buffer decoder.decode(); if (buffer.trim()) handleEvent(buffer, handlers); handlers.onDone?.(); } catch (err) { if (err.name AbortError || signal.aborted) { handlers.onDone?.({ aborted: true }); return; } handlers.onError?.(err); } finally { // 释放锁否则同一 body 无法再次读取 reader.releaseLock?.(); } } function handleEvent(rawEvent, handlers) { const dataLines []; let eventName message; for (const line of rawEvent.split(\n)) { if (!line || line.startsWith(:)) continue; // 空行和注释 const colon line.indexOf(:); const field colon -1 ? line : line.slice(0, colon); let val colon -1 ? : line.slice(colon 1); if (val.startsWith( )) val val.slice(1); if (field data) dataLines.push(val); else if (field event) eventName val; } if (!dataLines.length) return; const data dataLines.join(\n); if (data [DONE]) { handlers.onDone?.(); return; } if (eventName error) { handlers.onError?.(new Error(data)); return; } const parsed safeParse(data); if (parsed) handlers.onDelta?.(parsed); }这段代码里有几个容易写错的点。while循环切分必须写成「切一个、移一个」的形式不能一次split完再遍历因为split会把最后一个不完整的片段也当一个元素给你你没法区分它是真事件还是半截数据。indexOf(\n\n)比正则更可靠正则的g标志带lastIndex状态在跨调用复用时极易出错。releaseLock不能忘尤其在 React 的严格模式或者热更新下同一个ReadableStream可能被二次读取不释放锁会直接抛TypeError。另外提一句onDone的幂等性问题。[DONE]标记会触发一次onDonereader.read()返回done: true又会触发一次。如果你的onDone里有「保存会话到本地」这类副作用就会执行两遍。我通常在外部状态机里加一个finished标志位重复调用直接 return。3.3 渲染层增量更新与打字机效果的取舍拿到 delta 之后最朴素的写法是每来一帧就setState(prev prev delta)。这个写法在低频场景每秒几个 token下没问题但在高速输出下会出大事——React 18 虽然会自动批处理但跨await边界的更新不一定被合并几十次 setState 就是几十次渲染长对话下直接掉帧。我的做法是解耦「数据累积」和「UI 刷新」用一个ref做真实的数据累积用requestAnimationFrame做节流刷新。function useStreamingText() { const bufferRef useRef(); const [, forceRender] useState(0); const rafRef useRef(null); const push useCallback((delta) { bufferRef.current delta; if (rafRef.current) return; // 本帧已经排好队了 rafRef.current requestAnimationFrame(() { rafRef.current null; forceRender((n) n 1); }); }, []); const reset useCallback(() { if (rafRef.current) cancelAnimationFrame(rafRef.current); rafRef.current null; bufferRef.current ; forceRender((n) n 1); }, []); useEffect(() () { if (rafRef.current) cancelAnimationFrame(rafRef.current); }, []); return { text: bufferRef.current, push, reset }; }requestAnimationFrame节流的含义是不管这一帧内来了多少个 delta最多只触发一次渲染。按 60fps 算上限就是每秒 60 次渲染而人眼根本分辨不出「一次蹦三个字」和「三次各蹦一个字」。实测下来在每秒 80 个 delta 的压力测试下加节流后的帧率稳定在 55 到 60不加节流会掉到 20 左右。至于「打字机效果」——就是让文字按固定速度一个个蹦出来——我的建议是别做或者只在服务端输出极快时做一个很轻的缓冲队列。原因很简单服务端本来就是按 token 逐个吐的你再加一层匀速动画等于人为拖慢。用户要的是尽快看到完整内容不是看动画。真要做的话用「目标文本 逐帧追赶」的方式允许内容多的时候一次性补上别死守固定速率。3.4 Markdown 增量渲染与代码块高亮的坑AI 的回答基本都是 Markdown这就带来一个流式特有的难题Markdown 是上下文相关的而流式内容在任意位置都是「未闭合」的。最典型的三个现场**加粗只来了前半截渲染器可能把剩下的整段都吞进粗体。js代码块开了没关后面的所有内容都会被当成代码高亮全乱。表格、列表在行未结束时解析结果会跳动出现「先渲染成段落、再变成列表」的闪烁。我试过几种处理方式最后采用的是补全 容错的组合策略。补全渲染前扫描一遍文本统计未闭合的标记数量。具体做法是按行遍历遇到翻转一个代码块开关遇到**且计数为奇数就补一个表格和列表不做补全因为它们没有明确的闭合符。补全后的文本交给渲染器能避免 90% 的错乱。容错渲染器本身要宽容。我用过的几个 Markdown 库对未闭合语法的处理策略不一样有的会把**原样输出可接受有的会把整段变成粗体不可接受有的直接抛异常最糟。选型时一定要用「半截 Markdown」做一遍测试别只看完整文档的渲染效果。延迟高亮语法高亮是最耗时的环节一次highlight可能要 5 到 10 毫秒。如果每个 delta 都重跑一遍性能立刻崩。我的策略是流式进行中只做纯文本代码块渲染等宽字体 灰底等onDone之后再做一次全量高亮。用户在生成过程中对配色并不敏感但对卡顿极其敏感。还有一个小优化给流式区域加contain: content或content-visibility让浏览器把这块的布局计算范围限制住。长回答几千字 表格 代码块时这个属性对滚动流畅度的提升相当明显。4. 状态管理与中断控制被低估的工程细节4.1 停止生成、重新生成、切换会话的状态机流式 UI 的状态比一般页面复杂因为「正在生成」这个状态是可以被多个来源打断的用户主动点停止、用户切换会话、用户关闭页面、网络断开。如果这些问题靠零散的isLoading布尔值来管最后一定会出 bug。我的做法是定义一个明确的状态机状态之间只允许特定转移当前状态可转移到触发条件idleconnecting用户提交connectingstreaming收到首帧 deltaconnectingerror非 2xx / 首字节超时streamingdone收到[DONE]或流结束streamingaborted用户点停止streamingerror连接中断abortedconnecting用户点重新生成一旦把状态写死很多诡异问题会自己消失。比如「停止后内容又蹦出两个字」——因为aborted状态下的onDelta直接丢弃不再写入 buffer。再比如「重新生成时新旧内容混在一起」——因为regenerate会先 reset buffer 再转connecting不会复用旧数据。aborted到idle的收尾也要留意。中断后服务端其实可能还在生成HTTP 断开不代表模型停止所以如果你有「重新生成」的功能最好带上parentMessageId之类的幂等标识避免后端重复计费或者产生重复节点。4.2 错误、超时、断流的三层兜底流式请求的失败模式比普通请求多得多我通常按三层来防。第一层是连接层。fetch抛错、非 2xx 状态码、resp.body为空这三种在start阶段就要捕获。这一层的错误通常是「服务不可用」「鉴权失败」应该给用户明确提示并提供重试按钮而不是静默失败。第二层是传输层。流已经开始但中途断了。判据是reader.read()抛错或者你在onDone之前就收到了done: true。这两种情况都要做半截内容的保留——不要因为出错就把已经渲染的文字清空那会让用户非常恼火。正确做法是保留已有内容在末尾追加一条「生成中断」的提示并提供「继续生成」的按钮。第三层是内容层。有些服务端在流中间返回错误事件比如event: error加一段错误描述。这一层要在handleEvent里识别出来走独立的错误分支同样保留已有内容。// 空闲超时守卫的简化写法 function withIdleTimeout(ms, onTimeout) { let timer setTimeout(onTimeout, ms); return { tick() { clearTimeout(timer); timer setTimeout(onTimeout, ms); }, clear() { clearTimeout(timer); }, }; }注意abort()触发的AbortError绝不能算作业务错误上报否则你的错误率监控会被用户的「停止」操作污染得完全没法看。务必在 catch 的第一行就把它识别出来。4.3 滚动跟随与用户手动上滑的冲突处理这是一个纯体验问题但极其影响观感。默认逻辑是「内容变长就滚到底部」问题是用户想在生成过程中回看前文时一滚动就被自动拽回底部根本没法看。解决办法是加一个「是否贴底」的判断。实现很便宜监听滚动容器的scroll事件计算scrollHeight - scrollTop - clientHeight小于某个阈值我一般用 48px大概是两行文字的高度就认为用户正在看最新内容允许自动跟随大于阈值就把自动跟随关掉直到用户手动滚回底部再重新开启。阈值的选择有讲究。太小比如 10px会因为内容高度计算的微小抖动误判成「用户上滑了」导致自动跟随莫名其妙失效太大比如 200px会变成「用户明明在看中间还是被拽走」。48px 左右是我在移动端和桌面端都测过比较稳的值。移动端还要额外处理用户手指拖拽的场景滚动事件在惯性滑动期间会持续触发如果你在scroll里直接操作 DOM 滚动会产生明显的抖动。稳妥做法是给程序化的自动滚动加一个「用户正在交互」的抑制标志在touchstart时置位、touchend后延迟 300ms 复位。5. 常见问题速查与性能调优5.1 高频 chunk 引起的重渲染风暴这是流式 AI Chat 最典型的性能问题表现是短回答很流畅长回答越到后面越卡最后变成「文字一秒一秒往外蹦」。根因有三个而且经常同时出现。其一全量 Markdown 重解析。每来一个 delta 就把整段文本重新交给解析器复杂度是 O(总长度) × delta 次数一段 3000 字的回答会被解析上百次累计成本巨大。其二全量语法高亮。高亮比 Markdown 解析更贵因为要做词法分析。其三父组件重渲染波及。如果流式内容的状态提升到了页面顶层每帧渲染都会把整个消息列表、侧边栏一起重算。对应的三板斧节流刷新用requestAnimationFrame把渲染次数压到每秒 60 以内前面已经给过代码。分离渲染层级把正在流式输出的那条消息抽成独立组件buffer 放在组件内部的ref只有它自己重渲染。父级只在onDone时收到一次「最终文本」。延迟昂贵计算Markdown 解析可以按「段落边界」做增量——只在遇到\n\n时才重新解析这一段前面的段落结果缓存起来高亮则统一推迟到onDone。我做过一次对比一段 2000 字的回答未优化时总渲染耗时约 2400ms、掉帧明显加上rAF节流后降到 900ms再加上组件隔离和延迟高亮降到 320ms全程帧率稳定。这个提升幅度在真机上是肉眼可辨的。还有一个容易被忽视的点不要给流式内容加 CSS 过渡动画。transition: height 0.2s这类属性会在每次内容变化时触发额外的布局计算长列表下成本不低。想要平滑用scroll-behavior: smooth就够了而且只在用户主动触发滚动时才开。5.2 常见问题速查表现象大概率原因排查方向一个字都不渲染日志里 buffer 一直在涨换行符不是\n\n检查是否\r\n\r\n加归一化偶尔出现方块乱码TextDecoder没开stream确认decode(value, { stream: true })末尾少一两个字没做 decoder flush循环结束后补decoder.decode()报「非法 JSON 帧」服务端非标准分帧 / 多行 data 未拼接收集全部data:行再 join长回答越写越卡每帧全量解析 高亮上 rAF 节流、组件隔离、延迟高亮点了停止内容还在出状态机没拦住onDelta加aborted状态并丢弃后续 delta切换会话内容串台旧请求没 abort / 组件卸载未清理在useEffect清理函数里stop()移动端滚动抖动程序化滚动与手指滑动打架加交互抑制标志位错误率监控异常偏高把AbortError上报成错误catch 首行识别并 return流结束时onDone执行两次[DONE]和done: true都触发加finished幂等标志这张表基本覆盖了我实际项目中 90% 的线上问题。建议在写代码之前先扫一遍很多坑是可以提前规避的。5.3 我在真实项目里踩过的坑与心得先说一个最容易翻车的代理层缓冲。有一次本地开发一切正常部署到测试环境后变成了「转圈十秒然后整段文字一次性出现」。查了很久才发现是网关做了响应缓冲把流攒起来一次性发。判断方法很简单在浏览器 Network 面板里看这个请求的 EventStream/Response 分帧如果只有一个大帧那就是链路中间有人在缓冲。这种情况下前端怎么改都没用得去调服务端和网关的配置。第二个是关于Content-Type的。前端发的Accept: text/event-stream更多是语义表达真正决定服务端行为的是它自己返回什么。但如果你用的是某些 HTTP 客户端封装库它可能对text/event-stream有特殊处理或者干脆不支持流式响应这时候要么绕过它直接用原生fetch要么找它的流式开关。我在一个项目里就因为包装层默认把响应.text()了白白浪费了半天排查时间。第三个心得是关于测试的。流式代码一定要写「分片注入」的单元测试——把一段完整的 SSE 文本按各种刁钻的位置切开切在多字节字符中间、切在\n和\n中间、切在 JSON 大括号里面然后喂给你的解析器断言输出和整体喂入一致。这个测试写起来只要二十分钟但能挡掉后面无数个「偶现乱码」的工单。我自己维护的那份用例里有 23 个切片位置其中至少 5 个是线上真实出过问题的。最后分享一个关于「停止」的小技巧。用户点停止后服务端很可能还在生成如果立刻允许「重新生成」两条流会并存一小段时间。我的做法是在stop()之后给 UI 加一个极短的冷却大约 200ms再允许重新提交同时后端用请求 ID 做幂等。这个 200ms 用户完全感知不到但能避免一类很难复现的竞态问题。还有一个我一直在用的小习惯在开发环境下把每个 delta 的到达时间、字节长度打到一张环形缓冲区里页面上放一个隐藏的调试面板快捷键呼出。当用户反馈「卡」的时候看一眼 delta 间隔的分布就能立刻判断是服务端吐得慢、网络抖动还是前端渲染慢——这三种情况的曲线形态完全不同。服务端慢是稀疏且均匀网络抖动是有长间隔的尖刺前端渲染慢表现为 delta 密集到达但 DOM 更新滞后。有了这张图定位问题的速度会快一个数量级。
网站建设高端定制企业官网