老读者都知道,我写前端协议相关的系列已经写到第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: update加data: xxx加空行 | 触发一次update事件,data为xxx |
id: 100加data: 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-stream或text/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部分,本身不复杂。每一行基本结构是字段名: 值,字段名支持data、event、id、retry这四种。服务端最常见的发送方式是这样的:
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会在CONNECTING、OPEN、CLOSED三个状态之间切换。正常情况下连接处于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 on但gzip_types里没有把text/event-stream排除,有些版本Nginx会对流式响应做压缩缓冲,也会导致SSE消息延迟或断流。稳妥的做法是把text/event-stream从gzip_types里排除,或者干脆对流式接口关闭压缩。
还有一个点,如果你用了HTTP/2反向代理,要特别注意HTTP/2的流帧和SSE之间可能存在缓冲交互,实测时用Chrome的Network面板看Content Download时间,如果时间不停的增长但前端没收到事件,优先怀疑代理缓冲。
6. fetch 流式读取模拟 SSE:当你不满足于 EventSource 的时候
6.1 EventSource 与 fetch 流的真实差异:一个自动重连,一个全手动
既然EventSource这么方便,为什么还有那么多人用fetch去读SSE?核心原因是EventSource有三大限制,而大部分AI流式接口都触发了这些限制:
| 能力 | EventSource | fetch流式读取 |
|---|---|---|
| 自定义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里的行为差异。老规矩,遇到具体报错贴到评论区时,最好带上你用的库、代理层配置和复现步骤,这种问题我回复得最快。