news 2026/9/16 1:59:03

SSE浏览器端全解析:EventSource重连、解析与中止机制

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
SSE浏览器端全解析:EventSource重连、解析与中止机制

老读者都知道,我写前端协议相关的系列已经写到第105篇了。这个系列里聊过HTTP/1.1、HTTP/2、WebSocket,也聊过gRPC-Web,今天终于轮到SSE。说实话,我挺早就想单独写一篇浏览器端的SSE,但一直觉得这东西"看起来简单、用起来全是坑"。前两年我负责过一个实时告警平台,SSE连接一到早上高峰期就集体断开,前端各种报错,后端查了半天说是"机房网络抖动",最后我发现真正的问题出在浏览器对重连时机的处理上,而这块机制,EventSource的API文档里一个字都没写。

很多人把"SSE到了浏览器"理解成"服务器往浏览器推数据"六个字就完了。但实际上,数据从网线进到浏览器,要经过Content-Type校验、要走过EventStream解析器逐行拆包、要经过事件循环触发出message事件,中间任何一环出问题,结果往往不是"连接失败",而是一堆更加诡异的现象:连接建立成功但一条消息收不到、收到半截数据然后突然断流、服务端明明在发消息前端却触发重连、页面切后台回来连接已经凉透。这些问题都聚焦在三个关键词上:解析、重连、中止。这篇文章就从浏览器内核的视角,结合我在生产环境踩过的坑,把这三件事拆开讲透。

1. 先纠正一个错误预期:SSE 不是简单的"长连接",而是一条回推消息流

1.1 浏览器把"网络字节流"变成"事件"的瞬间发生了什么

先看一段最基础的用法:

const source = new EventSource('/api/events'); source.onmessage = (event) => { console.log('收到消息:', event.data); };

这个代码跑起来,很多人以为EventSource就是一个"会持续的WebSocket轻量版"。但浏览器内部做的事情远比这个复杂得多。new EventSource(url)发出的是一个普通HTTP GET请求,服务端返回200并且Content-Type: text/event-stream之后,TCP连接保持打开,通常不会在短时间内关闭,只是这个连接上会持续不断地吐出数据。

关键在于:EventSource对象本身你看不到任何"响应体"。你只能在上面监听事件,而浏览器内部其实维护了一个按行解析的流解析器。服务端发过来的任何字节,都会按照UTF-8解码,然后逐行读取,遇到空行就触发一次事件派发。整个过程用一张流程表可以看得更清楚:

服务端发来的内容浏览器解析后的结果
data: 你好加空行触发一次message事件,data为你好
event: updatedata: xxx加空行触发一次update事件,data为xxx
id: 100data: xxx加空行触发message事件,并且内部记录 lastEventId 为100
retry: 3000不触发事件,只更新下一次重连的时间间隔
: heartbeat整行被忽略,不产生任何事件

所以你在前端拿到的event.data,本质上是一个已经过浏览器解析器处理过的字符串。如果服务端发的是JSON,你需要自己在回调里JSON.parse。我在项目里见过不少新人以为event.data会自动反序列化,结果前端一直报错。实际上浏览器只负责按SSE协议格式把消息裁剪出来,不会帮你做JSON解析。

1.2 EventSource 对数据格式的严格校验:为什么 Content-Type 必须一字不差

浏览器对SSE响应有个非常严格的校验——响应头里的Content-Type必须是text/event-stream,否则浏览器会直接判定这个资源不是SSE流,然后触发onerror,连接建立失败。

这里有两个细节值得注意。

第一,text/event-stream; charset=utf-8这种带charset的写法是允许的,浏览器不会因为带了charset就拒绝。但如果你在CDN或网关层做了一些"智能识别",把Content-Type改成了application/octet-streamtext/plain,那么前端必然会报错。我在实际排查中见过一次:Nginx反向代理配置里有一条规则把所有响应头里的Content-Type都强制加了一个版本号后缀,直接把SSE流搞挂了。

第二,如果你做了跨域请求,还必须在响应头里带上Access-Control-Allow-Origin。EventSource的跨域规则跟fetch比较像,服务端如果不返回正确的CORS头,浏览器报的就是那个经典的跨域错误。如果需要携带凭证(Cookie),前端构造EventSource时要传入第二个参数,并且服务端必须响应Access-Control-Allow-Credentials: true

const source = new EventSource('https://api.example.com/events', { withCredentials: true });

这个最简单的基础环节,恰恰是很多SSE故障的起点。你后端接口明明用Postman测试是通的,浏览器里却一直触发onerror,十有八九就是Content-Type没配对。

2. 解析细节里的那些坑:多行 data、注释行、BOM 和 UTF-8

2.1 EventStream 协议的最小示例与字段规则

SSE的协议格式出自HTML标准里的event-stream部分,本身不复杂。每一行基本结构是字段名: 值,字段名支持dataeventidretry这四种。服务端最常见的发送方式是这样的:

data: 你好

注意最后必须有一个空行,这个空行是消息的终止符。如果没有这个空行,浏览器会一直等待,不会触发message事件。很多新手第一次写SSE服务端,输出完数据没打空行,前端等半天一条消息都没有,就是这个原因。

字段名是不区分大小写的,但实际使用中大家约定俗成用小写。一行里面如果有多个冒号,以第一个冒号作为分隔符,后面的值里如果还有冒号,原样保留。比如:

data: {"type": "heartbeat", "time": 1700000000}

这行数据中,data:后面的值就是{"type": "heartbeat", "time": 1700000000},冒号不会被额外处理。

2.2 最容易被误解的 data 拼接行为:每行末尾都藏着一个换行符

这是SSE解析里最反直觉的一个点。标准规定,一个字段可以分成多行,浏览器解析时会把同一个字段的多个data行拼接起来,拼完之后,每一行末尾其实都有一个换行符。

看这个例子:

data: 第一行 data: 第二行

浏览器内部的拼接逻辑是:先给data字段追加第一行的内容,并在末尾补一个换行符,得第一行\n;再追加第二行内容并补换行,得第一行\n第二行\n;最后,解析器在派发事件之前,会把这个累积数据的最后一个换行符去掉。

所以最终event.data得到的结果是第一行\n第二行,也就是两行之间是有一个换行符的。这个行为在你手写SSE客户端解析器时极其容易踩坑,因为很多SDK在拼data行时不会去末尾换行符,导致前端拿到的数据末尾莫名其妙多了一个\n

如果你需要自定义命名事件,可以这样发:

event: userlogin data: {"name": "张三"}

浏览器端监听:

source.addEventListener('userlogin', (event) => { console.log(event.data); });

如果没有匹配的event事件监听器,这条消息会被浏览器丢弃。

2.3 注释行不是摆设:它是防超时的合法心跳

SSE协议里有一类特殊的行,以冒号开头,例如:

: this is a comment

这种行浏览器解析器会直接忽略,不会触发任何事件。很多人不知道注释行的用途,其实这是SSE协议里官方预留的心跳机制。因为HTTP连接长时间没有数据流动时,中间的网络设备(网关、负载均衡、NAT)可能认为连接空闲,直接把连接掐掉。服务端如果周期性地发送一个注释行,连接上就有数据在流动,但前端又不会产生任何事件,完美实现了"保活但不打扰"。

我强烈建议所有SSE服务端都把心跳封装成一个独立函数,每15到30秒发一个注释行。这比发空的data:更干净,因为空data行会在前端触发一次message事件,业务代码每次都得判断"这条消息是不是心跳",时间长了很容易埋雷。

另外提一个比较冷门但实际存在的情况:如果字节流开头带了UTF-8的BOM(EF BB BF),部分浏览器解析第一行时会出现异常。Nginx做静态代理时如果给文件加了BOM,SSE流的第一个字段可能解析不到。生产环境如果出现"第一条消息偶尔丢失",可以检查一下服务端输出的字节流是否干净。

3. 重连机制:浏览器没有你想的那么聪明,但它会一直试

3.1 断线重连的触发条件与 readyState 状态变迁

EventSource内置了自动重连机制,readyState会在CONNECTINGOPENCLOSED三个状态之间切换。正常情况下连接处于OPEN;一旦网络层断开、服务端主动关闭连接、或者是代理层掐断了连接,浏览器会自动把状态改为CONNECTING,然后发起重新连接。整个过程你什么都不用做,这就是EventSource相比fetch流式读取最大的优势。

但注意一个容易误解的点:不是所有错误都会触发自动重连。如果服务端返回的是404、500这类HTTP错误码,或者Response的Content-Type不是text/event-stream,浏览器执行的是"fail the connection"逻辑,直接触发onerror并且停止重连。也就是说,EventSource的自动重连只对"连接建立成功之后中途断掉"这一类情况有效,对握手阶段就失败的情况,它不会一直重试。

从实际现象上看:

触发场景浏览器行为
服务端正常返回但中途断流自动重连
网络断开自动重连
服务端返回500触发onerror,不重连
Content-Type错误触发onerror,不重连
前端调用close()停止一切重连

3.2 retry 和 Last-Event-ID 到底怎么配合服务端做到"断点续传"

重连也有一个默认的间隔时间。按照规范,如果服务端没有明确告诉浏览器,浏览器会按自己实现来,Chrome等主流浏览器默认大概在2到3秒左右。如果想要控制这个间隔,服务端可以在任意位置发一行:

retry: 5000

这个数值的单位是毫秒,表示下一次重连前等待的时间。不过要注意,retry只影响下一次重连,不是发送完立刻生效,也不是把当前连接重置。比如你在某个消息里带了retry: 10000,前端会更新内部的重建计时器,等下次断线重连时用10秒。

重连之后怎么保证消息不丢?这是SSE的核心能力,靠的是Last-Event-ID。浏览器内部会记录最后一次收到的消息的id,重连时自动在请求头里带上:

Last-Event-ID: 1024

服务端读取这个请求头,就能知道客户端最后收到的是哪条消息,然后从1024之后的消息开始续传。这套机制使得SSE天然具备"断点续传"的能力,不需要前端自己做消息队列补偿。

但这也是一个双刃剑。正因为浏览器自动带了Last-Event-ID重连,服务端如果对重连请求处理得不好,客户端可能收到重复消息。比如一条消息的id是100,浏览器在收到它之后网络断了,重连时带Last-Event-ID: 100,如果服务端直接从100开始发,客户端就会重复收到第100条。稳妥的做法是服务端根据Last-Event-ID定位到该ID之后的消息,即从下一条开始发。

3.3 一个从热搜里反复出现的问题:为什么很多 SSE 客户端"重连 5 次"就放弃

最近我在看技术社区热搜时,发现一个很有意思的现象:关于"codex重连5次""chatgpt重连五次"这类问题特别多,几乎成了AI工具类应用的标配bug。为什么是5次?很明显,这些应用没有用EventSource,而是用fetch去读流式接口,然后在业务层写了一个循环重试逻辑,最多尝试5次,5次都失败就放弃。

let maxRetries = 5; for (let attempt = 1; attempt <= maxRetries; attempt++) { try { const response = await fetch('/api/stream', { headers: { Authorization: `Bearer ${token}` } }); await readStream(response.body); break; } catch (err) { console.log(`第 ${attempt} 次重连`); if (attempt === maxRetries) throw err; await sleep(1000 * attempt); } }

这段代码看起来没什么毛病,但其实问题很大。第一,重连间隔用sleep(1000 * attempt)做线性退避,对于瞬时网络抖动还可以,但如果服务端需要超过5秒才能恢复,应用直接抛错。第二,循环逻辑没有处理"部分消息已读到但流中断"的情况,断点续传完全没做。第三,这类客户端大多是Node环境或者移动端SDK,它们没有浏览器EventSource的"自动带Last-Event-ID"能力,服务端也没有基于Last-Event-ID做续传设计,所以每次重连,AI对话的上下文都会出问题。

这种"固定5次、每次隔1秒"的重连策略,本质上是在"无限重连拖垮服务端"和"用户一断就失败"之间选了一个中间值,但不能算一个健壮的方案。比较合理的做法是把重连拆成两级:第一级快速重试3次,间隔1秒、2秒、4秒;第二级慢速重试,最多5次,间隔递增到30秒封顶,同时每次重连都带上已经收到的最后一条消息ID,服务端基于这个ID恢复流。这篇文章后面我会给出一个更完整的fetch版SSE客户端示例,里面会把重连策略写进去。

4. 中止连接的四条路径:close、断网、服务端断流与页面生命周期

4.1 主动 close() 之后连接真的马上没了吗

前端的主动关闭非常简单:

source.close();

调用之后,readyState立即变为CLOSED,浏览器停止一切自动重连的念头,并且会尝试关闭底层TCP连接。实际测试中,调用close()后浏览器会发一个断开信号给服务端,服务端通常能立刻感知到onclose事件。在Chrome的DevTools Network面板里,这条请求会显示为canceled状态。

有一点需要提醒:如果服务端有一个保活连接池,希望在前端断开时马上释放对应的服务端资源,那么依赖TCP的关闭信号并不完全可靠。因为如果前端进程被强杀、系统休眠、或者网络闪断,服务端可能要等很久才能感知到连接失效。所以服务端还是应该自己设心跳超时校验,不要完全依赖浏览器的close行为。

4.2 网络层断开的两种表现:FIN 和 RST 对前端感知的差异

连接断开在网络层有几种表现,前端感知到的差异很大。

最常见的正常断开是服务端调用res.end()或者response.end(),TCP会发FIN包,浏览器收到FIN后知道流结束了,触发onerror,然后走自动重连。如果服务端进程崩溃,或者被负载均衡强杀,TCP栈可能来不及发FIN而是发RST包,浏览器同样会触发错误和重连,但表现上会有细微差别:发RST时,前端可能来不及读取到积压的缓冲区数据,实测中有时候会体现在"消息读到一半就断了"。这也是为什么我建议SSE的每条消息尽量短小,避免长数据被拆分到多个TCP包,导致中途断流时前端只拿到半个JSON。

另外,移动端网络切换(比如Wi-Fi切到4G)是生产中触发SSE异常重连的高频场景。移动网络切换会导致IP地址变化,原来的TCP连接自然失效,浏览器会触发onerror并重连。但如果应用层没有做好状态恢复,重连之后整个页面可能处于"看起来活着,但消息丢了"的状态。所以监听onerror时,最好同步记录一个状态标志,等onopen触发后做一次数据补偿请求。

4.3 页面被切后台、浏览器休眠对 SSE 连接的隐性影响

还有一个很容易被忽略的地方:浏览器的后台标签页节流(Throttling)机制。当用户把页面切到后台,或者笔记本合盖进入睡眠,浏览器会大幅度降低定时器和网络活动的频率。对EventSource意味着什么?

先说结论:EventSource在后台标签页依然能接收消息,但消息的派发会被节流。也就是说,你从后台切回页面时,可能一次性收到积压的好几条消息。这些消息本身不会丢,但如果你的前端代码假设"每收到一条消息就更新一次UI时间轴",那么切回来时UI可能瞬间跳变,体验上像卡住了一样。

更加麻烦的是,如果系统休眠了很长时间,TCP连接通常已经被中间设备掐断,但浏览器不一定会立刻感知到,因为它不主动发数据,就一直等在那里。等你切回页面,浏览器才发现连接死了,触发重连。这个过程用户感知到的就是"页面从后台切回来,SSE断线重连了",如果你的重连没有断点续传,那这段时间的消息就全部丢失了。

解决思路有两个层面:一是服务端做历史消息缓存,客户端重连后主动拉取断档消息;二是前端监听visibilitychange事件,在页面重新可见时主动检查SSE状态,必要时主动close()再重建连接。

document.addEventListener('visibilitychange', () => { if (document.visibilityState === 'visible' && source.readyState !== EventSource.OPEN) { // 重建连接,或拉取增量数据 } });

我在真实项目里验证过这个方案,配合服务端消息ID机制,后台切回后的丢消息率从肉眼可见的丢失降到了零。

5. 浏览器视角倒逼出的服务端设计:鉴权、心跳与代理超时

5.1 EventSource 不能带自定义 Header,SSE 鉴权只能走三条路

这里必须说一个很多从WebSocket转过来的人会踩的大坑:EventSource的构造函数只接受URL和withCredentials,你不能像fetch那样直接传自定义Header。

// 这是做不到的: new EventSource('/api/events', { headers: { Authorization: 'Bearer xxx' } });

那么SSE带Token怎么办?实际生产环境主要三条路。

最常用的是URL Query参数带Token。实现简单,但Token会出现在服务端访问日志、代理日志、浏览器历史里,泄露风险相对高。适合短期、低敏感场景,而且Token要设置较短的过期时间。

第二种是Cookie鉴权。EventSource默认同源请求会携带Cookie,只要登录态是Cookie体系的,直接能用。跨域时需要withCredentials: true,服务端返回相应的CORS头。这是安全的做法,但如果服务端是纯API鉴权体系(比如只认Authorization: Bearer),就得改造。

第三种是一次性凭证交换。先通过fetch调用一个鉴权接口,拿到短期有效的临时凭证,然后拼接在SSE的URL上。这个方案兼顾了安全和EventSource的局限性,很多AI流式接口就是这么做的。

以我个人的实践,如果是内部系统且统一用Cookie做登录态,直接用Cookie最省心。如果是开放平台API,优先选一次性凭证换URL的方式,别把长期Token直接拼查询参数。

5.2 空闲超时与那条知名报错:idle timeout waiting for sse 的完整排查链路

最近搜索热度很高的一条报错是:

TypeError: stream disconnected before completion: idle timeout waiting for sse

如果你在用AI SDK这类封装库测SSE,很容易撞上这条。它的本质是:读取方设置了空闲超时,但在超时时间内一个字节都没收到,于是主动断开了流。上游(服务端、代理层)有数据没发过来,下游的读取超时却被触发,形成了断流。

我教你一套完整的排查链路,照着走就行。

第一步,先用curl裸测服务端流:

curl -N --max-time 90 https://your-api.example.com/events

-N是禁用缓冲,--max-time 90是设置总超时。如果curl也断了,说明断点发生在服务端或者服务端之前;如果curl能一直挂着,问题就在应用层客户端或网关层。

第二步,确认服务端有没有周期性心跳。如果服务端超过30秒不发任何字节,很多网关默认就在60秒把连接断了。标准做法是服务端每15到20秒发一个注释行:

: heartbeat

前端解析器会忽略注释,但底层TCP栈和网关确实收到了字节,连接就不算"空闲"了。

第三步,检查代理层配置。我见过最典型的是Nginx的proxy_read_timeout默认60秒,服务端60秒内没有新消息,Nginx直接把连接掐了。解决办法是把这个值调大,比如proxy_read_timeout 3600s;,同时加上proxy_buffering off;,避免Nginx攒缓冲。

第四步,如果前面都排除了,去看客户端库自身的读取超时设置。很多SDK会默认设一个较为保守的读超时(比如30秒),这个参数经常被忽略。你可以在SDK初始化时把读取超时调大或关闭,但这只是兜底,真正的根治还是服务端发心跳。

5.3 从 Nginx 到应用层:代理缓冲和超时必须配合 SSE 输出节奏

SSE对代理层的配置要求其实比普通接口苛刻得多。这里把常见的Nginx反向代理SSE配置贴出来,有需要的可以直接抄:

location /events { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ''; proxy_buffering off; proxy_cache off; proxy_read_timeout 3600s; proxy_send_timeout 3600s; chunked_transfer_encoding off; }

几个参数解释一下。proxy_buffering off是最关键的,如果开着,Nginx会攒够一定量的响应体再发给客户端,SSE一旦没有填满缓冲区,前端收到的数据就会有明显延迟。proxy_read_timeout必须大于服务端两次心跳的最大间隔,如果心跳15秒一次,这个值至少要设到60秒以上。proxy_set_header Connection ''是为了避免Nginx默认使用HTTP/1.0的Connection: close行为导致连接被提前关闭。

另外说一个容易被忽略的:如果你全局开了gzip ongzip_types里没有把text/event-stream排除,有些版本Nginx会对流式响应做压缩缓冲,也会导致SSE消息延迟或断流。稳妥的做法是把text/event-streamgzip_types里排除,或者干脆对流式接口关闭压缩。

还有一个点,如果你用了HTTP/2反向代理,要特别注意HTTP/2的流帧和SSE之间可能存在缓冲交互,实测时用Chrome的Network面板看Content Download时间,如果时间不停的增长但前端没收到事件,优先怀疑代理缓冲。

6. fetch 流式读取模拟 SSE:当你不满足于 EventSource 的时候

6.1 EventSource 与 fetch 流的真实差异:一个自动重连,一个全手动

既然EventSource这么方便,为什么还有那么多人用fetch去读SSE?核心原因是EventSource有三大限制,而大部分AI流式接口都触发了这些限制:

能力EventSourcefetch流式读取
自定义Header不支持支持
请求方法仅GET任意
自动重连内置无,需手写
Last-Event-ID自动携带
按SSE协议解析内置手写
精确控制取消时机close()即可AbortController控制

尤其是鉴权问题,EventSource不能设置Authorization头,而很多AI服务端的鉴权都依赖这个Header。所以这些工具的SSE客户端基本都是fetch实现,结果就是重连、解析、断点续传这些原本浏览器已经替你做了的事,全部要自己重新实现一遍。这也是为什么社区里"codex老重连""chatgpt重连5次"这类问题反复出现——不是没有人尝试解决,而是fetch版SSE客户端的重连逻辑天生比EventSource复杂一个量级。

6.2 手写一个 fetch 版 SSE 客户端时最容易漏掉什么

如果你因为实际需求必须用fetch模拟SSE,下面的代码是经过生产验证的基础骨架,关键是看注释里的坑:

class FetchSSEClient { constructor({ url, headers = {}, maxRetries = 5, onMessage, onError }) { this.url = url; this.headers = headers; this.maxRetries = maxRetries; this.onMessage = onMessage; this.onError = onError; this.controller = null; this.retryCount = 0; this.lastEventId = ''; this.closed = false; } async start() { while (!this.closed && this.retryCount <= this.maxRetries) { try { await this.connectOnce(); this.retryCount = 0; // 连接正常结束后重置重试计数 } catch (err) { this.retryCount += 1; if (this.retryCount > this.maxRetries) { this.onError?.(new Error(`重连超过 ${this.maxRetries} 次:${err.message}`)); return; } const delay = Math.min(30000, 1000 * 2 ** this.retryCount); await new Promise((resolve) => setTimeout(resolve, delay)); } } } async connectOnce() { this.controller = new AbortController(); const headers = { ...this.headers }; if (this.lastEventId) { headers['Last-Event-ID'] = this.lastEventId; } const response = await fetch(this.url, { headers, signal: this.controller.signal }); if (!response.ok || !response.body) { throw new Error(`HTTP ${response.status}`); } 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 }); const chunks = buffer.split('\n\n'); buffer = chunks.pop(); for (const chunk of chunks) { this.parseChunk(chunk); } } } parseChunk(chunk) { let data = ''; let event = 'message'; let id = ''; const lines = chunk.split('\n'); for (const line of lines) { if (line.startsWith(':')) continue; // 注释行忽略 const index = line.indexOf(':'); const field = index === -1 ? line : line.slice(0, index); const value = index === -1 ? '' : line.slice(index + 1).replace(/^ /, ''); if (field === 'data') data += value + '\n'; else if (field === 'event') event = value; else if (field === 'id') id = value; else if (field === 'retry') { /* 这里可以解析重试时间 */ } } if (data) { data = data.replace(/\n$/, ''); if (id) this.lastEventId = id; this.onMessage?.({ data, event, lastEventId: id }); } } abort() { this.closed = true; this.controller?.abort(); } }

这个骨架里藏着几个容易漏掉的点。

第一,TextDecoder必须用{ stream: true }模式。否则当SSE消息被拆成两个chunk到达时,第二个chunk里可能包含半个UTF-8字符,解码会出乱码。

第二,\n\n切分消息时要保留剩余部分chunks.pop()拿到的最后一段不是完整消息,要拼到下一次读取的buffer里。我用这段代码在真实项目中接过OpenAI兼容接口,如果没有这个处理,流式接口偶尔会丢最后一条消息。

第三,多行data拼接的末尾换行要去掉。参考前面第二节讲的解析规则,data += value + '\n'是为了多行data拼接,最后data.replace(/\n$/, '')是为了去掉末尾那个换行符。这两步缺一不可。

第四,重连策略要带指数退避。我代码里用的是1000 * 2 ** this.retryCount,也就是1秒、2秒、4秒、8秒、16秒,封顶30秒。这比"每1秒重试一次"的固定间隔要稳得多,能有效避免服务端还没恢复时客户端反复撞墙。同时注意,retryCount只有连上并正常结束才重置为0,只要中途异常,计数器就会持续累加,这样最多重试5次后停止,不会无限重连拖垮服务端。

第五,主动关闭要用 AbortController 而不是简单break。如果只是break掉循环,底层的fetch请求实际上还挂着,会继续占用连接资源,直到服务端超时才释放。abort()会立即取消底层HTTP请求,这是真正的"切断"。

6.3 实操建议:什么场景该坚持用 EventSource,什么场景必须换 fetch

我把最近几年做流式功能、AI应用的经验总结成一张选型对照:

如果你的场景符合以下几条,优先用EventSource,别折腾fetch:

  • 只需要GET请求
  • 鉴权能走Cookie或URL参数
  • 需要浏览器自动处理重连和断点续传
  • 不需要在请求里加自定义Header

如果你的场景属于以下情况,才必须上fetch流式读取:

  • 需要在请求头里带Authorization Token
  • 需要POST请求体
  • 需要精细控制每个chunk的到达时间(比如做打字机效果时需要节流)
  • 需要在非浏览器环境下复用同一套代码(Node.js跑SSE客户端时没有EventSource对象)

特别说明一点:非浏览器环境中,Node.js从18开始也有全局的EventSource,但实现完整度在不同版本有差异,如果你的Node服务要消费SSE,直接用fetch流方案反而更可控,因为你能拿到原始的响应体控制权。

最后再分享一个我实际项目里的经验:如果你在浏览器里选用了EventSource,但服务端的鉴权又非得用Header,可以考虑在前面套一个轻量的同源BFF层(Backend For Frontend)。浏览器连BFF走EventSource,BFF再往真正的上游服务发带Authorization的fetch流请求。这样既保留了EventSource的自动重连能力,又能把Token安全地留在BFF层。这个架构我在两个生产项目里都验证过,可靠性比"纯fetch直接连上游"高很多,也省掉了自己维护重连状态机的成本。

第105篇到这里差不多把浏览器端的SSE解析、重连、中止闭环讲完了。后面如果大家感兴趣,我可以接着写一篇SSE在HTTP/3和QUIC下的表现,或者是EventSource在Service Worker里的行为差异。老规矩,遇到具体报错贴到评论区时,最好带上你用的库、代理层配置和复现步骤,这种问题我回复得最快。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/16 1:58:55

PyVSR视频超分原理与工程实践:帧间建模、光流对齐与硬件调度

简介&#xff1a;本资源是一套基于Python实现的PyVSR视频超分辨率算法开源工程&#xff0c;面向数字图像处理、计算机视觉方向的学习者与开发者&#xff0c;解决低分辨率视频质量提升的实际问题&#xff0c;适用于视频增强、监控画质优化及边缘设备轻量超分等场景。压缩包共34个…

作者头像 李华
网站建设 2026/9/16 1:57:12

Rhino 3D入门攻略:从NURBS曲面核心逻辑到实战建模

很多人第一次听说Rhino 3D&#xff0c;是在工业设计或者建筑行业的朋友那里。但真正接触后你会发现&#xff0c;这软件根本不是“某个行业的专用工具”&#xff0c;而是一个能把脑子里那些不规则的、流畅的、异形的想法&#xff0c;直接变成可加工数据的建模平台。我最早拿它做…

作者头像 李华
网站建设 2026/9/16 1:57:08

STP生成树协议核心机制详解:从环路成因到RSTP快速收敛

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 1:56:44

RDKX5开发板与ARM64交叉编译实战指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/16 1:54:28

GPT-5.4与Claude 4.6:AI编程助手深度对比评测

1. 项目概述2026年的AI助手领域已经发展到一个令人惊叹的水平&#xff0c;GPT-5.4和Claude 4.6作为两大主流AI助手&#xff0c;在程序员群体中引发了广泛讨论。作为一名长期使用各类AI工具进行开发的工程师&#xff0c;我花了三周时间对这两个系统进行了全面实测&#xff0c;从…

作者头像 李华
网站建设 2026/9/16 1:53:46

SDRSharp x86版实用指南:插件机制与RTL-SDR部署调优

简介&#xff1a;这套面向Windows 32位平台的SDRSharp软件无线电开发套件&#xff0c;以C#编写并集成Win32RTLSDR驱动&#xff0c;兼容RTL-SDR、Airspy、HackRF等多类SDR硬件&#xff0c;适合无线电通信爱好者、信号分析人员及软件无线电初学者进行频谱观测、信号接收与解码实验…

作者头像 李华