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()。解析出事件后通过回调通知上层:
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 可能是半帧、一帧、或者三帧半。所以解析必须是一个有状态的累积过程:
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 分派
}
三个细节值得说:
frames.pop()是整个解析器的灵魂。split 出来的最后一段永远是「半个帧」,放回缓冲区,等下一段字节来了拼上再切。丢了这个,帧边界全乱。decoder.decode(value, { stream: true })里的stream: true不能省。多字节 UTF-8 字符(比如中文)可能正好被 chunk 边界拦腰切断,stream: true让 decoder 记住未完成的字节序列,下次接着解。省了它,中文会随机变成乱码。- JSON 解析失败直接
continue。帧不完整或被截断时跳过这一帧,而不是整个流判死刑。流式协议里局部损坏不该扩大成整体失败。
至于 /\r?\n\r?\n/ 这个分帧正则——它修掉了我两天才定位的一个 bug:服务端用 CRLF 分帧,最初的版本用 "\n\n" 切,永远切不开。那个故事很长,单独写在上一篇里,这里不重复。只留一句结论:手写协议解析,分隔符一律按最宽松的标准写。
首字超时:15 秒的兜底
const firstTimer = window.setTimeout(() => {
if (!gotFirst) {
ctrl.abort();
cb.onFail();
}
}, FIRST_TOKEN_TIMEOUT); // 15000
发出请求起计时,收到第一个有内容的帧置 gotFirst = true。15 秒没等到首字,主动 abort 并判失败。这个兜底防的是「后端卡住,用户对着转圈等一辈子」。15 秒这个值后来还救过场也误伤过人(DeepSeek 默认开思考模式那次,见这篇),但机制本身是对的。
断流的哲学:已流出的内容要留下
catch 分支是整个客户端里我最满意的设计:
} catch {
if (!gotFirst) cb.onFail();
else cb.onDone(acc, [], true); // 已流出部分内容后断流:保留并标记降级
}
网络断流分两种:一个字都没流出就断,和流到一半断。前者当然是失败;后者如果也判失败,界面上已经逐字显示了半分钟的回答会「砰」地消失,换成一句「暂时休息中」——这个体验太粗暴了。所以流出一半断掉时,把已得内容当成最终回答保留,只打一个 degraded: true 标记,界面上在回答末尾加一条小提示。用户读到的是不完整的真话,好过没有。
AbortController 管取消
每次 send 先 cancel() 上一个请求,cancel() 里 abortRef.current?.abort()。场景有三个:用户连点发送、用户在流式中途关闭聊天窗、首字超时主动拉闸。AbortController 一箭三雕——fetch 会 reject,reader.read() 会结束,清理逻辑只有一处。
?mock=1:没有后端的联调模式
前端是先于后端写完的。URL 里带 ?mock=1 时,send 走 sendMock:从本地 mock 知识库里取答案,用 setTimeout 每 24 毫秒吐一个字符,模拟流式。
这个东西的收益远超预期。第一,后端没写完时前端就能全流程开发验收;第二,所有降级路径(超时、断流、限流提示)都可以用 mock 稳定复现,不用真去拔后端网线;第三,演示给别人看的时候不需要服务器。mock 开关后来留在了线上,当调试入口用。
复盘
手写协议客户端的隐性成本在于:EventSource 帮你挡住的所有边界情况——分帧、字符编码、断线、取消——你都得自己重新想一遍。但也正是这个成本逼着我想清楚了「断流时该给用户看什么」这种产品问题。如果当时用了 EventSource,大概率就接受了它的默认行为,顺手把「流出一半断掉」变成了「回答消失」。
协议本身不值钱,围绕协议的那圈状态处理才值钱。这也是为什么这个一百多行的文件,注释比代码还密。