一次真实的流式踩坑:fetchEventSource vs fetch流读取的本质区别

前言

在前端接入AIOCR大模型 能力时,流式返回 几乎已经成为标配:服务端一边计算,一边通过text/event-stream把结果逐步推送给前端,用户也能实时看到内容生成的过程。看起来这是一个已经被"标准化"的技术场景,但真正落到工程实践中,很多人都会遇到一个非常隐蔽、却极其致命的问题:

同样是流式请求,有的实现稳定可靠,有的却总是少字、断句、内容丢失,甚至完全无法复现 Bug。

最常见的对比,就是:

  • 使用fetch + response.body.getReader():问题频发,却很难定位
  • 使用fetchEventSource:几乎不出问题

乍一看,两者都基于fetch,都能"边收边处理数据",甚至服务端返回的数据完全一致,但实际效果却天差地别。很多人会把原因归结为"网络不稳定"或"浏览器bug",却忽略了一个更关键的事实:

问题并不出在网络层,而出在前端对"流"的理解层级上。

本文将基于一次真实的工程实践,对比fetchEventSourcefetch手动读取流这两种实现方式,从协议层、传输层到前端解析层逐层拆解它们的本质区别,重点分析:

  • 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

注意两个非常关键的点:

  1. 每一行以字段名开头(最常见的是data:
  2. 两个换行符\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内部做了什么?

  1. 持续读取response stream
  2. 内部维护一个buffer
  3. SSE规范解析:
    • data:
    • id:
    • event:
    • \n\n作为事件结束标志
  4. 只有在一整条事件完整后
    • 才触发onmessage
    • 并且保证e.data是完整字符串

👉 所以拿到的永远是:

css 复制代码
{
  data: "{...完整JSON...}"
}

而不是碎片。

五、结论(非常重要)

✅ 什么场景必须用 fetchEventSource?

  • SSE / text-event-stream
  • AI流式输出
  • OCR/LLM/长文本逐步返回
  • 需要严格保证内容不丢、不乱、不少

👉 首选 fetchEventSource

⚠️ 什么时候才该用fetch + reader?

  • 明确知道这是:
    • 二进制流
    • 文件流
    • 自定义协议
  • 或者:
    • 自己实现完整buffer
    • 自己按\n\n 拆事件
    • 明确处理拆包、粘包、UTF-8边界 否则:

fetch + reader ≠ SSE客户端
只是一个"字节读取工具"

最后,一句话总结:fetchEventSource是"协议级消费",天然防丢包;而fetch流读取是"字节级消费",极易在解析阶段丢语义

相关推荐
AI前端老薛7 分钟前
CSS实现动画的几种方式
前端·css
携欢11 分钟前
portswigger靶场之修改序列化数据类型通关秘籍
android·前端·网络·安全
前端小L12 分钟前
专题二:核心机制 —— reactive 与 effect
javascript·源码·vue3
GuMoYu12 分钟前
npm link 测试本地依赖完整指南
前端·npm
代码老祖12 分钟前
vue3 vue-pdf-embed实现pdf自定义分页+关键词高亮
前端·javascript
未等与你踏清风13 分钟前
Elpis npm 包抽离总结
前端·javascript
代码猎人13 分钟前
如何使用for...of遍历对象
前端
秋天的一阵风15 分钟前
🎥解决前端 “复现难”:rrweb 录制回放从入门到精通(下)
前端·开源·全栈
林恒smileZAZ15 分钟前
【Vue3】我用 Vue 封装了个 ECharts Hooks
前端·vue.js·echarts
颜酱16 分钟前
用填充表格法-继续吃透完全背包及其变形
前端·后端·算法