ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

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

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 对象切成两半、在流里混入空行和错误数据,看解析器会不会崩。这个习惯帮我提前排掉了大量线上才暴露的问题。协议和解析器稳定了,前面那些网络和渲染问题就只是一个一个填坑的过程,而不是一边填一边改地基。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进