资讯详情

资讯详情

H5流式AI聊天开发指南:从ReadableStream解析到增量渲染避坑

1. 先说结论H5里跑流式聊天核心难点不是“流”而是“流转”大概一年前我第一次接到一个要在 H5 页面里做 AI 聊天框的需求。当时我对流式数据的理解停留在“后端会一点一点返回前端等全量再展示”真正上手后才发现H5 里处理 AI 聊天流式数据远不是把 fetch 的 response.body 读完那么简单。这篇就把我从协议设计、前端解析、增量渲染到线上踩坑的全过程捋一遍希望能给你省掉几晚上排查问题的时间。先给结论如果你只把流式聊天当成“网络层面的事情”项目大概率会在联调阶段反复返工。因为数据从网络包变成字符从字符变成完整事件再从事件变成页面上的文字每一步都有边界情况。我们团队最初只用res.text()拿结果后来切到流式连续三天在处理“吐到一半乱码”“JSON 半包解析失败”“滚动位置乱跳”这类问题。这些问题不是浏览器 API 不会用而是缺少一套完整的“流转”链路设计。适合看这篇内容的读者有两类一类是第一次接 AI 聊天流式接口的前端开发想搞清楚 fetch 流式读取、SSE、增量渲染到底怎么落地另一类是自己搭聊天工具、需要对接各种模型服务的独立开发者。不管你是哪种下面我尽量把每个关键点背后的“为什么”也讲清楚方便你迁移到自己的场景里。1.1 流式数据的两种常见形态现在大部分 AI 聊天接口返回流式内容主流有两种形态。第一种是标准 SSE也就是text/event-stream服务端按data: {...}\n\n格式不断推送事件。第二种是 JSON Lines over fetch也就是 HTTP 响应体本身是流式的每行一个完整 JSON 对象行与行之间用\n分隔Content-Type 一般是application/x-ndjson或者直接application/octet-stream。这两种形态在 H5 里都能处理区别在于实现复杂度。SSE 有现成的EventSource但它有两个硬伤第一EventSource 只能发 GET 请求想把当前上下文放在 POST body 里发给模型服务得绕路第二EventSource 原生不支持自定义请求头很多模型的鉴权偏偏就依赖自定义头这就非常难受。JSON Lines over fetch 看起来更“原始”但因为 fetch 完全受我们控制POST、Header、AbortController 都能用实际接起来反而更顺手。所以我更推荐后一种方案接口走普通 POST前端用 fetch 拿response.body这个ReadableStream自己按行解析。后面所有代码示例也以这个方案为基础。1.2 为什么H5处理起来比Native更麻烦同样一个流式聊天功能Native 端可以用原生网络库的字节回调、多线程解析、跨线程更新 UI处理起来相对顺手。H5 是单线程的 JS 环境所有网络读取、文本解析、DOM 渲染都在同一套事件循环里天然要多考虑“别阻塞主线程”“别频繁渲染”。更实际的问题是 H5 的项目往往要跑在多个容器里PC Chrome、iOS Safari、Android WebView还有各种自带浏览器的 H5 容器。不同内核里ReadableStream的行为细节会有差异比如后台定时器被冻结、TLS 连接保活策略不同、Service Worker 介入后响应体是不是还能正常读取。这些都会直接影响流式体验。还有一层容易被忽略聊天页不是只有“展示文字”这一件事。流式输出的同时用户可能还在输入框里打字、在历史消息列表里滚动、点停止按钮甚至切后台。这些交互和流式数据的叠加才是真正的复杂度所在。Native 可以和 H5 一样踩这些坑但 H5 因为历史包袱和容器差异踩坑概率明显更高。2. 熟悉这些API后面才不会懵处理 AI 聊天流式数据前端绕不开几个 API。第一个是fetch返回的response.body它其实是一个ReadableStream第二个是TextDecoder负责把二进制字节流变成字符串第三个是AbortController负责取消请求。把这几个 API 用顺后面解析协议就轻松一半。2.1 fetch ReadableStream 的最小骨架我先给你一个可以直接跑通的最小骨架。假设你已经发出 POST 请求后端开始流式返回async function streamChat({ messages, onToken }) { const response await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ messages }) }); if (!response.ok) { throw new Error(HTTP ${response.status}: ${await response.text()}); } 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 }); // 到这里 buffer 可能是几个不完整行下一步再按行解析 } buffer decoder.decode(); // 冲刷剩余字节 }这里最关键的是decoder.decode(value, { stream: true })。不理解它的人会在中文被拆成两个网络包时看到乱码。原因很简单多字节字符的某个字节可能在上一个 chunk 末尾剩下的字节在下一个 chunk 开头如果每次 decode 都当成“最终片段”就会按替换符处理下一个 chunk 来了也救不回来。stream: true表示本次解码还没结束内部会缓存不完整的多字节序列。2.2 SSE 和 fetch 流式响应的取舍如果你的服务端已经按 SSE 格式返回也不一定要用EventSource。我建议还是用 fetch 去读流然后自己解析 SSE 块。这样既能保留自定义 Header又能用 POST同时还能复用同一套取消逻辑。SSE 的每一帧长这样data: {type:token,content:你好}\n\n用 fetch 读的时候处理逻辑非常简单function parseSSEChunk(text, onData) { const lines text.split(\n); for (const line of lines) { if (!line.startsWith(data:)) continue; const payload line.slice(5).trim(); if (payload [DONE]) return; onData(payload); } }这里注意 SSE 帧的结束符是空行但网络 chunk 不一定恰好按帧切分所以同样要维护一个 buffer把不完整的帧等完整了再解析。这个 buffer 逻辑和 JSON Lines 几乎一样区别只在行分隔符和是否处理data:前缀。2.3 兼容性现状与降级思路现在的现代浏览器对ReadableStream支持已经不错但移动端的低版本 WebView 仍然可能出现response.body为 null或getReader不存在的情况。我的建议是不要假设所有人都在最新版 Chrome 里打开入口做一个能力检测更稳妥if (!window.ReadableStream || !response.body || !response.body.getReader) { // 降级走非流式接口或者退化成“等全量回来后一次性展示” }降级不一定是坏事。很多内部管理系统压根不需要流式效果用户能看到最终答案就行。与其在低版本内核里强行兼容不如让产品接受“老设备上显示 loading新设备上流式输出”。这个取舍要在项目初期就说明白否则后面前端自己临时做兼容会非常被动。还有一个常见坑如果服务端做了全局 gzip 压缩并且压缩缓冲足够大小 token 可能被攒成一个大包才发出来流式就变成“假流式”。所以联调时最好先确认网络层是不是真的逐 token 推送。按下 F12 看网络面板的时序图如果内容是一块块跳出来的说明服务端或中间层有缓冲不只是前端的问题。3. 实操从接口返回到流式吐字一个可复用的H5实现光看懂 API 还不够真正要做的是把一段“字节流”变成 UI 上动态显示的文字。我这里给出一套自己反复用的方案包含协议约定、解析器设计、增量渲染、取消重试四个部分你可以直接抄到项目里改改字段名就能用。3.1 数据协议设计服务端到底该返回什么这是最容易在项目启动时忽视的环节。很多后端第一次接流式会直接返回一个多行 JSON但每行结构不固定。前端解析起来很痛苦。我强烈建议协议统一为 JSON Lines每条独立事件占一行字段固定为type和content有需要再加extra{type:meta,content:,messageId:abc123} {type:token,content:你好} {type:token,content:很高兴认识你} {type:done,content:}type是事件类型。token表示增量片段done表示结束error表示服务端主动报错meta可以放 messageId、耗时、用量等信息。这样前端只需要一个switch(type)就能处理遇到token把 content 追加到当前消息遇到done结束流式状态遇到error展示错误提示并停止。协议一定要和代码实现同步维护。我见过最多的问题不是协议复杂而是“刚开始大家一起拍脑袋定了个字段后来后端改了名前端还不知道”所以在代码仓库里放一份protocol.md比在群里发通知可靠得多。3.2 前端解析按行切开再 JSON.parse避免半包错乱网络传输不可能保证每个 chunk 正好是一行。一个 chunk 可能包含多行也可能只有一行的一半。所以前端必须维护一个“行缓冲区”把不完整的行留给下一次读取。下面是我常用的解析器function createLineParser(onEvent) { let buffer ; return function push(chunk) { buffer chunk; let index; while ((index buffer.indexOf(\n)) 0) { const line buffer.slice(0, index).trim(); buffer buffer.slice(index 1); if (!line) continue; try { const data JSON.parse(line); onEvent(data); } catch (err) { // 这里不直接抛异常先记日志避免中断整个读取循环 console.warn([stream] parse error:, line, err); } } return buffer; }; }配合 fetch 的读取循环用法是const onEvent (event) { switch (event.type) { case token: appendToken(event.content); break; case done: finishStream(); break; case error: showError(event.content); break; } }; const parser createLineParser(onEvent); while (true) { const { done, value } await reader.read(); if (done) break; parser(decoder.decode(value, { stream: true })); } parser(decoder.decode()); // 最后冲刷一次注意我在解析循环里加了try/catch。理论上协议正确时不会出现解析错误但真实环境里代理服务器、网关都有可能往流里插入空行或日志文本。一旦某一行解析失败不能把整个流停掉否则用户会看到一个永远停到一半的回答。正确做法是跳过坏行、记日志继续读后续内容。3.3 增量渲染与并发控制解析完 token 之后接下来就是“把文字显示出来”。这一步最容易犯的错是每个 token 都直接操作 DOM。AI 聊天流式接口一秒可能返回几十个 token每次都改textContent和滚动位置页面会明显卡顿尤其是长回答。我的做法是“攒一批再更新”。可以用一个简单的节流函数let textBuffer ; let rafId null; function appendToken(content) { textBuffer content; if (rafId) return; rafId requestAnimationFrame(() { renderText(textBuffer); rafId null; }); } function renderText(text) { answerNode.textContent text; // 这里再控制滚动见第4节 }requestAnimationFrame能让每次渲染至少间隔一帧也就是最多 60 次每秒。对聊天场景来说16 毫秒刷新一次足够顺滑。更极限一点的做法是用定时器控制固定 50ms 刷新一次减少无效渲染。但如果是后台标签页requestAnimationFrame可能被暂停所以还需要监听页面可见性回来时强制刷一次。如果你用的是 React不要在每个 token 里都调setState。React 虽然会批量处理同一事件循环里的更新但流式场景里异步更新很难预测。我更推荐把内容先放在 ref 里然后通过一个节流setState把内容批量交给渲染层。核心思路和上面纯 DOM 版本一致不要让“网络事件频率”直接等于“UI 更新频率”。3.4 取消、重试、超时这些“非功能项”流式聊天最容易被忽略的是请求管理。用户刚发出去就后悔了点停止结果服务器还在继续生成页面还在继续刷新。所以从第一天开始就要接入AbortController。const controller new AbortController(); const timer setTimeout(() controller.abort(), 60_000); try { const response await fetch(/api/chat, { method: POST, body: JSON.stringify({ messages }), signal: controller.signal }); // 正常读取流... } catch (error) { if (error.name AbortError) { console.log(用户取消或超时); } else { console.error(网络错误, error); } } finally { clearTimeout(timer); }停止按钮的事件里只要controller.abort()就能让前端停止接收数据。但要注意前端 abort 不等于服务端取消生成。很多模型服务在 HTTP 连接断开后并不会立即终止推理尤其是第三方网关。如果这是付费 API前端的停止按钮其实可能只是“假装停了”账单还在继续。更完善的方案是停止时再发一个独立的/cancel请求把当前消息 ID 传给服务端让服务端主动中断生成。重试也要讲究策略。不要在报错后立刻无脑重发可能服务端正在重启或限流。最简单的退避策略是第一次失败等 2 秒第二次等 4 秒最多重试两三次。这个逻辑看起来简单但能显著减少接口雪崩。4. 踩坑实录我在真实项目里遇到的5个问题这节是我最想写的内容。很多问题不实际跑过一遍真的意识不到。我把它们按“现象-原因-解法”的方式列出来供你排查时对照。4.1 汉字被拆在两次网络包里JSON.parse直接报错模型输出“你好”第一次网络 chunk 是{type:token,content:你第二次是好}。如果你直接用chunk做JSON.parse第一段就会因为字符串没有闭合而报错。这是因为网络包不是按行切的一个 JSON 对象可能被拆成一段不完整的内容。解法就是前面写的“行缓冲”。每次收到 chunk 之后先拼到 buffer 里只有遇到\n才把整行拿出来解析。不管这次到没到完整的 JSON先留着下次 chunk 到了再判断。这个思路是所有流式解析的通用底层逻辑。4.2 TextDecoder 不传 stream:true 导致的乱码这个问题比半包更隐蔽。decoder.decode(value)默认会把传入的字节视为“最终结果”如果刚好有一个中文被切成两半第一次 decode 会把前半段按无效字节处理输出替换字符。等后半段到了即使拼对也已经丢信息了。正确写法是循环里始终传{ stream: true }整个流读完再执行一次不带参数的decoder.decode()冲刷剩余字节。我在项目里特别喜欢拿“你”这个字做测试因为它在 UTF-8 里恰好是 3 个字节很容易被网络层拆开。4.3 AbortController 在移动端的假取消在 PC Chrome 上点击停止流确实停了页面也不再更新。但在部分 Android WebView 和 iOS Safari 上abort 之后服务端可能还在持续把数据推到网络栈里只是前端不再读。如果再配合 WebView 的预连接或缓存机制用户可能会在下次进入页面时收到“上次未读内容”。更麻烦的是abort 事件不一定立刻让reader.read()返回异常有些 WebView 里会挂起一段时间。所以在 UI 层做停止状态时不能完全依赖AbortError最好同时设置一个前端状态点击停止后即使还有数据进来也不再追加到消息内容里。4.4 切后台/锁屏后连接中断回到页面时内容卡在中间移动端 H5 经常碰到用户正在等模型回答切到微信回了个消息锁屏等再回到页面时内容只显示了一半连接已经断了。这是因为 WebView 在后台会暂停 JS 执行网络读取循环不动连接被系统回收。我的经验是监听visibilitychange在页面回到前台时先检查当前流的结束状态。如果流没有正常 done就提示用户“连接已中断可点击重试”不要假装还在继续。有条件的话把服务端生成的内容做成可续传按消息 ID 存储已经生成的 token重试时让服务端从断点继续返回。这个续传能力最好由后端提供前端只需要传lastTokenIndex之类的断点参数。4.5 滚动跟随和超长回答冲突流式输出时页面要自动滚到底部否则用户看不到新内容。但每次 token 都执行scrollTop scrollHeight超长回答会变成一场灾难滚动条疯狂跳动用户想往上翻历史消息立刻被强行拉回底部。我的建议是只做“条件自动滚动”如果用户当前距离底部小于 80px就自动滚到底部如果用户已经在往上翻就停止跟随。判断距离可以用const threshold 80; const distanceToBottom content.scrollHeight - content.scrollTop - content.clientHeight; if (distanceToBottom threshold) { content.scrollTop content.scrollHeight; }这个 80px 的阈值不是拍脑袋定的太大会导致用户明明想读上面内容还一直拉回底部太小又会错过新内容。你可以根据消息卡片的高度调但基本都是这个量级。4.6 快速排查速查表现象可能原因优先排查点文字乱码TextDecoder 没传 stream:true每次 decode 都传{ stream: true }JSON 半包报错按网络 chunk 直接 JSON.parse引入行缓冲按\n切分页面卡顿token 频率太高UI 更新太频繁节流/合并渲染不要 token 级 setState停止后还在更新服务端没收到取消信号单独发 /cancel前端同步置状态切后台回来内容断了连接被系统回收监听 visibilitychange提供重试/续传自动滚动不听使唤每次都 scrollTop 到底改成“距离底部小于阈值才跟随”5. 性能优化和体验细节当你会处理流式数据以后接下来要思考的是体验。同样一套接口为什么有的产品流式打字感很爽有的让人感觉“一顿一顿”差距往往在细节上。5.1 更新DOM的节流策略前面提过一次requestAnimationFrame这里再展开讲一下。流式聊天的渲染策略我推荐三级选择如果 token 频率很低比如每秒不到 10 次直接更新 DOM 也没问题如果 token 频率高用 rAF 或 50ms 定时器合并更新如果是到了“整段文字几千字”的极限场景还要考虑只渲染可视区域附近的内容。长回答下整个聊天容器里的 DOM 节点会越来越多每次都重排整棵树开销越来越大。一个大的回答消息可以先分成多个段落节点随着流式更新最后一段保留为空节点专门接收新内容前面已经生成的段落固定下来这样能显著减少重排范围。5.2 用光标动画提升“正在生成”的感知流式输出本身就是为了减少等待焦虑但如果 UI 上只有一个静态文本块用户往往不确定内容是不是真的还在继续。我习惯在消息末尾加一个闪烁的“光标”样式大概是这样.stream-cursor { display: inline-block; width: 8px; height: 18px; background: currentColor; vertical-align: text-bottom; animation: blink 1s steps(2, start) infinite; } keyframes blink { to { opacity: 0; } }这个小东西成本极低但会让用户直观感受到“这里还在生成”。在done事件到来之后把光标移除即可。不要小看这个细节很多同事第一次接入时没做光标测试反馈都是“好像卡了”加了光标之后同样的接口体验立刻变好。5.3 没有流式接口时的降级演法大概率你会遇到一个情况后端暂时不能改造成流式产品又想要“流式效果”。这时可以做一个渐进式展示的“伪流式”方案。接口仍然一次性返回完整文本前端拿到后按句号、逗号、空格切分成片段用定时器每隔 80-120ms 追加一个片段模拟打字效果。这样做的重点是不要让定时器在后台继续跑。检测到页面不可见时直接把剩余文本全部渲染完用户切回来就能看到完整答案。否则会出现在后台卡了半天回来还要等它一个字一个字蹦的情况。伪流式的代码其实很便宜但它只能救急真正的流式接口该推还是要推。我自己现在接任何流式项目第一件事不是写组件而是先用手工构造的几行文本跑一遍解析器。我会故意把“你好”拆成两个 chunk、把一个 JSON 对象切成两半、在流里混入空行和错误数据看解析器会不会崩。这个习惯帮我提前排掉了大量线上才暴露的问题。协议和解析器稳定了前面那些网络和渲染问题就只是一个一个填坑的过程而不是一边填一边改地基。
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →