前言
在前端接入AI、OCR或大模型 能力时,流式返回 几乎已经成为标配:服务端一边计算,一边通过text/event-stream把结果逐步推送给前端,用户也能实时看到内容生成的过程。看起来这是一个已经被"标准化"的技术场景,但真正落到工程实践中,很多人都会遇到一个非常隐蔽、却极其致命的问题:
同样是流式请求,有的实现稳定可靠,有的却总是少字、断句、内容丢失,甚至完全无法复现 Bug。
最常见的对比,就是:
- 使用
fetch + response.body.getReader():问题频发,却很难定位 - 使用
fetchEventSource:几乎不出问题
乍一看,两者都基于fetch,都能"边收边处理数据",甚至服务端返回的数据完全一致,但实际效果却天差地别。很多人会把原因归结为"网络不稳定"或"浏览器bug",却忽略了一个更关键的事实:
问题并不出在网络层,而出在前端对"流"的理解层级上。
本文将基于一次真实的工程实践,对比fetchEventSource与fetch手动读取流这两种实现方式,从协议层、传输层到前端解析层逐层拆解它们的本质区别,重点分析:
- 2种方式在设计目标上的根本差异
- 为什么手写流解析极易出现"看不见的丢数据"
- 以及这些"丢包问题"究竟是如何在前端解析阶段悄然发生的
希望这篇文章能帮助你在下次面对流式接口时,不再只停留在"能跑就行",而是真正站在正确的抽象层上做选择。
一、先看项目中这两段代码在"做什么"
那开始这次的主题,先来看看项目中曾写过的两段代码,首先来看看使用fetch + reader来实现流式读取的代码。
1️⃣ fetch + reader:站在「字节流」工作
ini
const res = await fetch(url, { method: "POST", body: formData })
const reader = res.body!.getReader()
const decoder = new TextDecoder("utf-8")
while (!done) {
const { value } = await reader.read()
let chunk = decoder.decode(value)
// 处理RECORD_ID
if (chunk.includes('RECORD_ID') || chunk.match(/^\d+:end/)) {
const match1 = chunk.match(/^data:RECORD_ID:(\d+):end(.*)$/s)
const match2 = chunk.match(/^RECORD_ID:(\d+):end(.*)$/s)
const match3 = chunk.match(/^(\d+):end(.*)$/s)
const match4 = chunk.match(/^RECORD_ID:(\d+)(.*)$/s)
const match = match1 || match2 || match3 || match4
if (match) {
const idMatch = match[1]
if (idMatch) {
recordId.value = parseInt(idMatch)
}
}
}
// 去除data:
chunk = chunk.replaceAll('data:', '')
// 去除RECORD_ID:xxx
chunk = chunk.replace(/RECORD_ID:\d+/g, '')
// 去除:end
chunk = chunk.replaceAll(':end', '')
// 去除换行
chunk = chunk.replace(/\n+/g, '')
// 处理STREAM_COMPLETE(结束标记)
if (chunk.includes('STREAM_COMPLETE')) {
chunk = chunk.replaceAll('STREAM_COMPLETE', '')
isStreaming.value = false
done = true
}
inputValue.value += chunk
}
这段代码的特点非常明显:
- 拿到的是不定长Uint8Array
- 每次
read():- 可能是半条消息
- 也可能是多条消息拼在一起
- 所有协议解析逻辑都在前端自己写
👉 本质上是在 "自己实现一个SSE解析器" 。
2️⃣ fetchEventSource:站在「事件层」工作
然后再来看看使用fetchEventSource插件优化过的代码,如下:
javascript
await fetchEventSource(`${baseURL4}/ai/imgToStr`, {
method: "POST",
headers: {
Accept: "text/event-stream",
"access-token": getToken()
},
body: formData,
openWhenHidden: true, // 页面离开再回来也会继续请求
onmessage: (e) => {
const data = JSON.parse(e.data)
if (data.data.event === "recordId") {
recordId.value = data.data.content
} else if (data.data.event === "message") {
inputValue.value += data.data.content
}
},
onerror: (err) => {
throw err
}
})
可以注意到几个关键点:
- 没有自己处理
chunk - 没有自己拆分换行
- 直接拿到
e.data,就是一条完整事件
👉 因为:fetchEventSource并不是"读字节流" ,而是:严格按照 SSE 协议,解析完一整条事件后,才回调onmessage
二、什么是SSE协议?
SSE(Server-Sent Events) 是一种基于HTTP的单向推送协议,浏览器通过一次普通的HTTP请求,与服务端建立一条长连接,之后服务端可以持续向客户端推送事件数据。
它的几个关键特征非常重要:
- 基于 HTTP/1.1 或 HTTP/2
- 服务端主动推送,客户端只接收
- 数据格式是纯文本
- 使用
text/event-stream作为Content-Type
在前端代码中,可以看到这个请求头:
vbnet
Accept: text/event-stream
这并不是随便写的,而是明确告诉服务端:我期望你用SSE协议格式,持续向我推送事件。
SSE并不是"随便吐字符串"
我们很多人误以为SSE就是"服务端不断res.write()字符串",这是导致后续各种解析问题的根源。
SSE是有严格文本格式规范的。 一条最基本的SSE事件长这样:
kotlin
data: hello world
注意两个非常关键的点:
- 每一行以字段名开头(最常见的是
data:) - 两个换行符
\n\n才表示一条事件的结束
完整一点的事件可能是:
makefile
id: 123
event: message
data: hello
data: world
浏览器在解析时,会把多行data:自动拼接成一条完整消息:
hello
world
最终交给上层的,就是一条完整的事件数据。
到这里,SSE协议也介绍的差不多了,同时我们也大致透析了一点在项目中为啥会选择使用fetchEventSource的原因了,下面再来看看这两种实现方法的本质区别。
三、两者最核心的本质区别(不是API,而是层级)
| 对比维度 | fetchEventSource | fetch + reader |
|---|---|---|
| 工作层级 | 事件层(SSE协议) | 字节流层(TCP chunk) |
是否理解data: / \n\n |
✅ 内置 | ❌ 需要手写 |
| 是否保证一条消息完整 | ✅ 是 | ❌ 否 |
| 是否自动缓冲 | ✅ 是 | ❌ 否 |
| 是否处理拆包/粘包 | ✅ 是 | ❌ 否 |
| 丢数据风险 | 极低 | 很高 |
一句话总结:
fetchEventSource消费的是"语义完整的事件"
fetch + reader消费的是"还没成形的字节碎片"
三、重点:为什么fetch方式更容易"丢数据"?
通过上面的对比,我们清楚了两者最本质的区别,但是还是要回到代码本身,来看看到底是什么原因导致fetch方式"丢数据"→
1️⃣ 首先明确一点:TCP不会丢包,但你会"读丢"
很多人说"fetch会丢包",这是不严谨但工程上成立的说法。
准确讲是:TCP层不丢,但前端的"解析方式"导致了语义丢失
2️⃣ 丢数据的第一个原因:SSE消息被拆成多个chunk
在上面介绍SSE协议中,我们清楚知道一条最基本的SSE事件是啥样的,这里带入一下真实请求再介绍一下一条SSE事件的完整过程:
kotlin
data:xxx
data:yyy
但在网络层,它可能会被拆成这样到达前端:
scss
第1次 read(): "data:xxx\ndata:"
第2次 read(): "yyy\n\n"
而在代码里:
ini
chunk = decoder.decode(value)
chunk = chunk.replaceAll('data:', '')
chunk = chunk.replace(/\n+/g, '')
inputValue.value += chunk
问题来了:
- 假设
chunk一定是"完整语义" - 实际上
chunk只是 任意切割的字节片段 replace + 拼接是破坏性操作
👉 一旦拆在data:、RECORD_ID:、:end中间,
这一段语义就再也还原不回来了
这就是看起来没报错,但内容少字、乱字的根源。
3️⃣ 第二个原因:chunk中可能包含「多条消息」
反过来,服务端一次flush,也可能被浏览器合并成一个chunk:
kotlin
data:hello\n\n
data:world\n\n
一次read()拿到:
kotlin
data:hello\n\ndata:world\n\n
而在代码里:
javascript
chunk.replaceAll('data:', '')
chunk.replace(/\n+/g, '')
会直接变成:
helloworld
到这里可以发现已经丢掉了"事件边界"这个最重要的信息。
4️⃣ 第三个原因:TextDecoder使用方式本身就有坑
php
decoder.decode(value, { stream: false })
stream: false表示每次都是一个"独立字符串"- 如果
UTF-8字符被拆成2次Uint8Array - 多字节字符会直接损坏
正确方式其实是:
php
decoder.decode(value, { stream: true })
但即便如此,语义边界问题依然存在。
四、为什么fetchEventSource几乎不会出现这些问题?
因为它已经帮你做完了这些脏活
fetchEventSource内部做了什么?
- 持续读取
response stream - 内部维护一个
buffer - 按
SSE规范解析:data:id:event:\n\n作为事件结束标志
- 只有在一整条事件完整后
- 才触发
onmessage - 并且保证
e.data是完整字符串
- 才触发
👉 所以拿到的永远是:
css
{
data: "{...完整JSON...}"
}
而不是碎片。
五、结论(非常重要)
✅ 什么场景必须用 fetchEventSource?
SSE / text-event-streamAI流式输出OCR/LLM/长文本逐步返回- 需要严格保证内容不丢、不乱、不少
👉 首选 fetchEventSource
⚠️ 什么时候才该用fetch + reader?
- 明确知道这是:
- 二进制流
- 文件流
- 自定义协议
- 或者:
- 自己实现完整buffer
- 自己按
\n\n拆事件 - 明确处理拆包、粘包、UTF-8边界 否则:
fetch + reader ≠ SSE客户端
只是一个"字节读取工具"
最后,一句话总结:fetchEventSource是"协议级消费",天然防丢包;而fetch流读取是"字节级消费",极易在解析阶段丢语义