Skip to main content

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 字符串:

  1. 每次拿到解码后的文本,追加到 buffer 尾部;
  2. 找第一个空行 \n\n,它之前的部分是一个完整事件,取出来处理;
  3. 剩下的部分留在 buffer 里等下一次数据;
  4. 用 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 里会残留一个不完整的事件。循环退出前要决定怎么处理它——通常丢掉(它本来就不完整),但如果每条数据都重要,就要和服务端约定「最后一帧必须带结束标记」。

TCP 交付的是 chunk,它和事件边界毫无关系chunk 1chunk 2chunk 3…data: "你好" 事件的半个next: event: delta 接上一段 + data: "好"以空行结束id: 43 + data: "呀"以空行结束按空行切出的事件(buffer 累积后才完整)事件 1内容为「你」事件 2内容为「好」事件 3内容为「呀」buffer 残留等下一次数据要点:一个 chunk 里可能同时有 3 个完整事件(要用 while 循环取),也可能只有一个事件的半个(要留在 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 那节是断线续传的关键。