一次真实的流式踩坑: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流读取是"字节级消费",极易在解析阶段丢语义

相关推荐
崔庆才丨静觅2 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby60613 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了3 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅3 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅4 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅4 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment4 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅4 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊4 小时前
jwt介绍
前端
爱敲代码的小鱼5 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax