SSE 流式解析与切帧处理
流式解析的坑几乎全在「边界」上:一个 chunk 可能包含半个事件,也可能包含三个事件。不缓存拼接一定会丢字或乱码。
一、事件、字段与空行
SSE 的响应头是 Content-Type: text/event-stream,正文是纯文本,由若干个「事件」组成。每个事件由若干字段行构成,字段之间用换行分隔,事件之间用空行分隔。
字段行的形式是 字段名: 值,常见字段有四个:
data:—— 事件载荷,可以出现多行,解析时要拼接;event:—— 事件类型,对应EventSource监听时的类型名,默认是message;id:—— 事件 ID,用于断线重连时的续传定位;retry:—— 告诉客户端重连间隔(毫秒)。
以冒号开头的行是注释行,客户端应当忽略。服务端常拿它当心跳——长时间没有真实数据时发一行 : ping\n\n,既能保活,也能让中间设备知道连接还活着。
两个容易踩的格式细节:
data:value(冒号后无空格)同样合法,解析时应当只去掉冒号后的一个空格,而不是无脑slice(2)之后再trim(),否则会把载荷里本来存在的空格吃掉;- 行尾可能是
\n、\r\n,少数实现甚至只用\r。按\n切分后要把残留的\r去掉,否则字段值末尾会挂一个看不见的字符,JSON.parse直接失败。
一帧标准报文长这样:
id: 42
event: delta
data: {"content":"你"}
id: 43
event: delta
data: {"content":"好"}
注意最后那个空行——它是事件结束的标志,也是很多 bug 的起点。
二、按事件而非按 chunk
浏览器给你的不是「一个事件一个事件」,而是网络缓冲块。一个 chunk 里可能装半个事件,也可能装三个半事件;一个多字节的汉字还可能被切成两半。所以解析必须以「事件」为单位,不能以「chunk」为单位。
标准写法是维护一个 buffer 字符串:
- 每次拿到解码后的文本,追加到 buffer 尾部;
- 找第一个空行
\n\n,它之前的部分是一个完整事件,取出来处理; - 剩下的部分留在 buffer 里等下一次数据;
- 用
while而不是if——因为一个 chunk 里可能同时存在多个完整事件。
async function* parseSSE(response) {
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 })
let sep
while ((sep = buffer.indexOf('\n\n')) !== -1) {
const raw = buffer.slice(0, sep)
buffer = buffer.slice(sep + 2)
const payload = raw
.split('\n')
.filter((line) => line && !line.startsWith(':'))
.map((line) => line.replace(/^data:\s?/, ''))
.join('\n')
if (payload && payload !== '[DONE]') yield JSON.parse(payload)
}
}
}
这段里有两个细节值得单独说。
为什么必须循环。 写成 if 的话,当服务端在一瞬间吐出三帧时,你只会处理第一帧,剩下两帧要等下一个 chunk 到达才被取走。网络顺畅时表现为「偶发延迟」,网络拥塞时表现为「最后几句话要等很久才出现」——非常难复现,也非常容易被误判成模型慢。
为什么要识别结束标记。 很多服务在流结束时发一个 data: [DONE](各家写法不同,也有用自定义 event 的)。它不是内容,直接渲染会让对话末尾多出一行 [DONE]。结束标记的形式要和服务端约定,别靠猜。
还有一处常被忽略:流的最后一段。如果服务端结束时没有以空行结尾,buffer 里会残留一个不完整的事件。循环退出前要决定怎么处理它——通常丢掉(它本来就不完整),但如果每条数据都重要,就要和服务端约定「最后一帧必须带结束标记」。
三、包成异步生成器
把解析逻辑包成 async generator 之后,上层代码就变成了最自然的形态:
for await (const chunk of parseSSE(response)) {
appendToUi(chunk.content)
}
这个包装带来的好处不止是「好看」:
- 解析与渲染解耦:切帧的归切帧,渲染的归渲染,各自可以独立修改;
- 可测试:async generator 不依赖真实网络,喂一个构造出来的响应对象就能断言事件序列,比在组件里 mock fetch 干净得多;
- 可组合:想加日志、加重试、做采样,包一层 generator 即可,不用改解析函数本身。
一个实用的做法是给它加一层超时与取消:
async function* withTimeout(source, ms) {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), ms)
try {
for await (const item of source) {
if (controller.signal.aborted) break
yield item
}
} finally {
clearTimeout(timer)
}
}
取消要配合 AbortController 一起用:请求本身带上 signal,组件卸载时 abort(),否则读取循环会一直挂在内存里。
四、多字节与 BOM
这一节的两条都属于「线上偶发、本地复现不了」的典型。
必须用 TextDecoder 的 stream 模式。 UTF-8 里一个汉字占三个字节,网络分块完全可能从中间切开。如果每次都调用 decoder.decode(value)(不带 stream: true),被切断的那半个汉字会被解码成替换字符,界面上就是偶尔蹦出一个乱码方块。
// 错:多字节字符被切断会出乱码
buffer += decoder.decode(value)
// 对:未完成的字节序列会被保留到下一次
buffer += decoder.decode(value, { stream: true })
不要把半截字符串丢给 JSON.parse。 服务端逐字吐出时,你拿到的常常是 {"content":" 这种开头。正确做法是攒够一个完整事件再解析——这也是第二节「按空行切分」的意义所在。担心单帧过大可以加长度上限与超时,但不要尝试「边收边 parse」。
还有一处隐蔽的坑:BOM。如果响应体开头带了 UTF-8 BOM,它会被当成第一个事件的一部分,导致第一帧解析失败。TextDecoder 默认会在首次解码时剥离一次 BOM;但如果你的实现是自己按字节处理的,必须显式处理掉它。
五、Last-Event-ID 与部署坑
SSE 自带重连机制:服务端给每个事件带上 id:,客户端记住最后一个 id,重连时通过 Last-Event-ID 请求头告诉服务端「我从这儿之后开始要」,服务端据此从断点继续,而不是重放全部历史。
三个实现细节:
Last-Event-ID只在重连时出现,首次连接没有这个头。服务端必须把它当可空值处理,为空就从最新(或从头)开始,不能直接当成 0 去查;- 续传应当从「该 id 之后」开始,不是从该 id 开始,否则用户会看到重复的一条;
- 用
EventSource时这些由浏览器代劳;用 fetch 手动解析时,续传逻辑要自己实现——这也是手动方案的代价之一。
不是所有服务都支持续传。续传不了时,产品解法通常比技术方案更有效:保留已经生成的内容,给用户一个「继续生成」的入口,而不是清空重来。用户真正反感的是「刚才那段白等了」,而不是「中间断了一次」。
最后提醒一句部署侧的事:反向代理与 CDN 默认会缓冲响应体,攒够了才往下发,流式会退化成「一次性返回」。Nginx 需要关掉 proxy_buffering,或在响应上加 X-Accel-Buffering: no。这是流式上线失败率最高的原因之一,而且它的表现是「本地好好的、线上不流式」,排查时很容易怀疑错方向。
六、解析错了也不会报错
按 chunk 解析是不够的——chunk 边界与事件边界没有任何关系,不缓冲必然丢字或乱码。
多行 data 也不是异常,协议允许它出现,必须拼接后再整体解析,拼接符是换行。
断线后也不只能重新生成:可以用 Last-Event-ID 续传;做不到时至少保留已生成内容,并提供「继续生成」。
最要命的是——格式写错了不会报错。SSE 的解析器几乎从不抛错,格式问题一律表现为「数据不对」,所以要主动比对事件数与内容。
流式解析的全部 bug 都在边界上——把 buffer 写对,剩下的都是小事。
事件流的格式与重连语义见 MDN:Server-sent events,last-event-id 那节是断线续传的关键。