前端做 SSE 流式处理时必须有 buffer 缓冲区,原因是浏览器读到的字节块与 SSE 事件边界天然不对齐:一次 reader.read() 可能拿到半个事件、也可能一口气包含好几个事件,没有缓冲区按 \n\n 切分并保留尾部残片,就会出现解析错乱、JSON 解析崩溃、中文乱码这类只在生产环境复现的故障。我们在 AI 爆款文章创作器的多阶段生成和 AI 网关的对话流里,都靠这个缓冲区撑住了逐 token 渲染。下面把原理和实现完整拆开。
一、SSE 数据在浏览器侧的真实到达方式
SSE(Server-Sent Events)协议里,每条事件以 data: 开头、以空行 \n\n 结束。服务端持续向连接写入字节,浏览器通过 response.body.getReader() 按块读取,每个 chunk 是任意大小的字节段,与事件边界没有任何约定关系。
这就产生两种典型情况:
| 情况 | 表现 | 后果 |
|---|---|---|
| 半包 | 一条事件被拆进两个 chunk | 单独解析第二个 chunk 必然失败 |
| 粘包 | 一个 chunk 含多条完整事件 + 半条 | 只解析第一条会漏掉后面所有数据 |
事件协议字段和网络分片是两套边界,buffer 的作用就是把两者对齐。
二、buffer 缓冲区的三个职责
2.1 拼接残片,按事件边界切分
核心写法是先拼后切:每次把新 chunk 解码后追加到 buffer,用 split('\n\n') 分出完整事件,把最后一段”可能不完整的尾部”留在 buffer 里等下一次。这段逻辑是整个流式渲染的地基:
async function consumeSSE(response: Response, onEvent: (data: string) => void) {
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const events = buffer.split('\n\n')
buffer = events.pop() ?? '' // 尾部残片留给下一轮
for (const frame of events) {
const line = frame.split('\n').find((l) => l.startsWith('data:'))
if (line) onEvent(line.slice(5).trim())
}
}
}
没有这一层,直接在 chunk 上 split 并 JSON.parse,半包发生时立刻抛错,粘包发生时漏掉后续 token,两种故障都是偶发的、本地复现不出来的。
2.2 用 TextDecoder 的 stream 模式兜住多字节字符
SSE 输出中文、表情符号时,一个 UTF-8 字符可能被拆在相邻两个 chunk 里。decoder.decode(value, { stream: true }) 会缓存未完成的字节序列,等下一块补齐再输出,避免把半个中文字符解码成替换符。去掉这个参数,长文生成时页面里会随机出现乱码。
2.3 给增量渲染留出稳定入口
buffer 解析出的每条完整事件交给回调,页面每收到一个 token 就追加一次渲染,实现打字机效果。渲染层只依赖”完整事件”这个稳定的数据单元,与网络分片完全解耦,后面换传输方式也不影响 UI 逻辑。
三、有缓冲区与无缓冲区的差异
| 场景 | 无 buffer | 有 buffer |
|---|---|---|
| 事件被拆成两半 | 解析报错,流中断 | 拼接后正常解析 |
| 一个 chunk 含多条事件 | 只处理第一条,丢数据 | 逐条完整消费 |
| 中文被拦腰截断 | 出现替换字符乱码 | stream 模式自动补齐 |
| 渲染粒度 | 依赖 chunk 大小,不可控 | 每事件一次渲染,稳定 |
四、配套的取消与结束处理
流式对话必须能主动停止,用 AbortController 而不是 state 标志位,前者能真正断开连接。结束标志约定为 [DONE] 或服务端发送的 done 事件,读到即关闭循环。完整读取循环与取消逻辑配合,才能覆盖”用户点停止””用户切换对话”这类高频操作。
五、常见故障排查清单
缓冲区没写对时,故障有固定的特征。按下面顺序排查:
- 偶发 JSON 解析崩溃,本地很难复现——检查是否在原始 chunk 上直接 split 而不是走 buffer;
- 长文中途出现乱码——确认 TextDecoder 是否传了
{ stream: true }; - 首屏只显示一半内容——检查
split('\n\n')后是否把尾部留在 buffer 里; - 内容一次性全部出现而非逐字——确认是否把每次 read 的结果实时交给渲染,而不是等 done 才处理。
常见问题(FAQ)
Q1:buffer 会越积越大吗?
不会。完整事件解析后即从 buffer 移除,只保留尾部残片,常驻内存很小。
Q2:为什么不能直接在 chunk 上切分事件?
chunk 边界与事件边界不对齐,直接切分必然遇到半包和粘包。
Q3:EventSource 需要 buffer 吗?
EventSource 内置解析器无需手写 buffer;换 fetch 读流时则必须自己维护。