← 返回文章列表
026 — 前端 · #SSE #流式解析 #fetch #前端架构 · 2026-08-17 · 7 MIN READ

EventSource 不支持 POST,我用 fetch + 流式解析 手写了 SSE 客户端

浏览器原生就有 SSE 客户端:new EventSource(url) 一行搞定,自动重连、自动分帧,很香。但我的聊天接口用不了它——请求要带对话历史和 conversation_id,这些必须放在 body 里,而 EventSource 只会发 GET

GET 接口硬塞历史也能做(query string 编码),但历史一长相位就爆,而且语义上「发起一轮对话」就不该是 GET。所以最后是自己写的客户端,核心就三样东西:fetch 拿到响应、res.body.getReader() 拿流、一个状态机负责把字节流切成事件帧。

这篇讲这个客户端的完整设计。源码在 blog-frontend/src/components/chat/useChatStream.ts,一百多行。

整体形状:回调而不是状态

这个 hook 不持有任何 React state,只暴露 send(params, callbacks)cancel()。解析出事件后通过回调通知上层:

ts
export interface StreamCallbacks {
  onFirstToken: () => void;
  onToken: (acc: string) => void;
  onDone: (full: string, sources: Source[], degraded: boolean) => void;
  onFail: () => void;
}

为什么用回调而不是把 streamingText 放进 hook 的 state?因为「收到一个 token」和「界面要不要重渲染」是两回事。流式期间 token 以几十毫秒一个的速度到达,上层可以选择攒着渲染或节流逝染,hook 不该替它决定。回调把节奏权交出去,这个 hook 只管一件事:把字节流变成语义事件。

核心:帧缓冲状态机

流式解析 给的是不定长的字节块,和 SSE 帧的边界没有任何关系。一个 chunk 可能是半帧、一帧、或者三帧半。所以解析必须是一个有状态的累积过程:

ts
buf += decoder.decode(value, { stream: true });
// sse-starlette 以 CRLF(\r\n\r\n) 分帧,必须兼容 \n\n 与 \r\n\r\n
const frames = buf.split(/\r?\n\r?\n/);
buf = frames.pop() ?? "";   // 最后一段可能不完整,留给下一次
for (const frame of frames) {
  const line = frame.split("\n").map(l => l.trimEnd())
                    .find(l => l.startsWith("data:"));
  if (!line) continue;
  let data;
  try { data = JSON.parse(line.slice(5).trim()); } catch { continue; }
  // 按 content / done / sources / degraded 分派
}

三个细节值得说:

  1. frames.pop() 是整个解析器的灵魂。split 出来的最后一段永远是「半个帧」,放回缓冲区,等下一段字节来了拼上再切。丢了这个,帧边界全乱。
  2. decoder.decode(value, { stream: true }) 里的 stream: true 不能省。多字节 UTF-8 字符(比如中文)可能正好被 chunk 边界拦腰切断,stream: true 让 decoder 记住未完成的字节序列,下次接着解。省了它,中文会随机变成乱码。
  3. JSON 解析失败直接 continue。帧不完整或被截断时跳过这一帧,而不是整个流判死刑。流式协议里局部损坏不该扩大成整体失败。

至于 /\r?\n\r?\n/ 这个分帧正则——它修掉了我两天才定位的一个 bug:服务端用 CRLF 分帧,最初的版本用 "\n\n" 切,永远切不开。那个故事很长,单独写在上一篇里,这里不重复。只留一句结论:手写协议解析,分隔符一律按最宽松的标准写。

首字超时:15 秒的兜底

ts
const firstTimer = window.setTimeout(() => {
  if (!gotFirst) {
    ctrl.abort();
    cb.onFail();
  }
}, FIRST_TOKEN_TIMEOUT);  // 15000

发出请求起计时,收到第一个有内容的帧置 gotFirst = true。15 秒没等到首字,主动 abort 并判失败。这个兜底防的是「后端卡住,用户对着转圈等一辈子」。15 秒这个值后来还救过场也误伤过人(DeepSeek 默认开思考模式那次,见这篇),但机制本身是对的。

断流的哲学:已流出的内容要留下

catch 分支是整个客户端里我最满意的设计:

ts
} catch {
  if (!gotFirst) cb.onFail();
  else cb.onDone(acc, [], true); // 已流出部分内容后断流:保留并标记降级
}

网络断流分两种:一个字都没流出就断,和流到一半断。前者当然是失败;后者如果也判失败,界面上已经逐字显示了半分钟的回答会「砰」地消失,换成一句「暂时休息中」——这个体验太粗暴了。所以流出一半断掉时,把已得内容当成最终回答保留,只打一个 degraded: true 标记,界面上在回答末尾加一条小提示。用户读到的是不完整的真话,好过没有。

AbortController 管取消

每次 sendcancel() 上一个请求,cancel()abortRef.current?.abort()。场景有三个:用户连点发送、用户在流式中途关闭聊天窗、首字超时主动拉闸。AbortController 一箭三雕——fetch 会 reject,reader.read() 会结束,清理逻辑只有一处。

?mock=1:没有后端的联调模式

前端是先于后端写完的。URL 里带 ?mock=1 时,sendsendMock:从本地 mock 知识库里取答案,用 setTimeout 每 24 毫秒吐一个字符,模拟流式。

这个东西的收益远超预期。第一,后端没写完时前端就能全流程开发验收;第二,所有降级路径(超时、断流、限流提示)都可以用 mock 稳定复现,不用真去拔后端网线;第三,演示给别人看的时候不需要服务器。mock 开关后来留在了线上,当调试入口用。

复盘

手写协议客户端的隐性成本在于:EventSource 帮你挡住的所有边界情况——分帧、字符编码、断线、取消——你都得自己重新想一遍。但也正是这个成本逼着我想清楚了「断流时该给用户看什么」这种产品问题。如果当时用了 EventSource,大概率就接受了它的默认行为,顺手把「流出一半断掉」变成了「回答消失」。

协议本身不值钱,围绕协议的那圈状态处理才值钱。这也是为什么这个一百多行的文件,注释比代码还密。

Comments