三亩地 三亩地SAN MU DI · CODE DIARY
ARTICLE DETAIL

日记详情

真实记录编程学习的某一天,欢迎挑你感兴趣的翻一翻。

前端对接 SSE 的两种常见方式

前端对接 SSE 的两种常见方式

前端对接 SSE 的两种常见方式

LLM 流式输出、进度推送、长任务状态更新,后端经常会用SSE(Server-Sent Events):一条 HTTP 长连接,服务端持续往下推事件,客户端边收边渲染。

协议本身不复杂,事件大致长这样:

data: {"event":"content","data":"你好"} data: {"event":"end","traceId":"abc"}

关键点:

  • 响应头是Content-Type: text/event-stream
  • 一条事件通常以空行\n\n结束
  • 业务数据多放在data:后面(常见再包一层 JSON)

前端真正要选的,是怎么连上这条流。常规有两种:

  1. EventSource(浏览器原生)
  2. fetch+ReadableStream(手动读流)

一句话对比

EventSourcefetch + ReadableStream
怎么连new EventSource(url)fetch(url)后读response.body
HTTP 方法基本只有GETGET / POST / PUT…都行
自定义请求头基本不行(难带Authorization随便带
请求体没有可以发 JSON body
自动重连浏览器自带要自己写
解析成本低,浏览器帮你拆事件要自己按行/按段解析
适合场景公开订阅、简单通知要登录、要 POST、要精细控制

业务 API 往往需要Bearer Token + POST body,所以第二种更常见;监控面板、公开进度页,第一种更省事。


方式一:EventSource

基本用法

constes=newEventSource('/api/notifications/stream');es.onmessage=(event)=>{// event.data 就是 data: 后面的字符串constpayload=JSON.parse(event.data);console.log(payload);};es.onerror=()=>{// 默认会自动重连;不需要时可 es.close()console.error('SSE error');};// 主动断开// es.close();

如果服务端用了命名事件(event: progress),可以这样听:

es.addEventListener('progress',(event)=>{constpayload=JSON.parse((eventasMessageEvent).data);console.log(payload);});

优点

  • API 短,上手快
  • 断线自动重连(对「订阅型」推送很友好)
  • 不用自己处理字节流和粘包

限制(也是很多人最终换掉它的原因)

  1. 基本只能 GET
    复杂任务参数不好塞进 URL(还可能暴露在日志/代理里)。

  2. 很难带自定义 Header
    标准EventSource不能方便地加:

    Authorization: Bearer <token>

    于是常见歪招是把 token 塞进 query:?access_token=...,既丑也不安全。

  3. 错误与状态不好细控
    HTTP 401/403、业务error事件、主动取消,都不如fetch直观。

什么时候用它

  • 不需要登录,或鉴权已靠 Cookie(同源自动带上)
  • 接口本身就是 GET 订阅
  • 你需要浏览器自带的断线重连

方式二:fetch+ReadableStream

思路:

  1. fetch发起请求(可 POST、可带 Header)
  2. response.body.getReader()读二进制块
  3. TextDecoder转成文本
  4. 按 SSE 规则拆出data:
  5. JSON.parse后分发给业务回调

发起带鉴权的 SSE 请求

asyncfunctionrequestAuthorizedSse(url:string,init:{method:string;body?:string;signal?:AbortSignal},onDataLine:(dataLine:string)=>void){consttoken=localStorage.getItem('token')||'';constresponse=awaitfetch(url,{method:init.method,headers:{'Content-Type':'application/json',Authorization:`Bearer${token}`,},body:init.body,signal:init.signal,});if(!response.ok){consterrorBody=awaitresponse.text().catch(()=>'');thrownewError(errorBody||`SSE request failed:${response.statusText}`);}awaitreadSseSegments(response,onDataLine,init.signal);}

按「空行分段」解析(推荐)

SSE 一条事件以\n\n结束,按段切最稳:

asyncfunctionreadSseSegments(response:Response,onDataLine:(dataLine:string)=>void,signal?:AbortSignal){if(!response.body){thrownewError('SSE response has no body');}constreader=response.body.getReader();constdecoder=newTextDecoder();letbuffer='';try{while(true){if(signal?.aborted){thrownewDOMException('Aborted','AbortError');}const{done,value}=awaitreader.read();if(done)break;buffer+=decoder.decode(value,{stream:true});constsegments=buffer.split('\n\n');buffer=segments.pop()||'';for(constsegmentofsegments){consttrimmed=segment.trim();if(!trimmed.startsWith('data:'))continue;constdataPart=trimmed.replace(/^data:\s*/,'');if(dataPart)onDataLine(dataPart);}}}finally{reader.releaseLock?.();}}

业务侧:把data解析成事件

awaitrequestAuthorizedSse('/api/generate/draft',{method:'POST',body:JSON.stringify({projectId,task}),signal:abortController.signal,},(dataLine)=>{try{constdata=JSON.parse(dataLine);switch(data.event){case'start':console.log('开始',data.traceId);break;case'content':appendText(data.data);// 打字机效果break;case'end':finish(data);break;case'error':showError(data.data);break;}}catch{// 忽略半包/脏行}});

取消流:AbortController

constabortController=newAbortController();// 用户点「停止生成」abortController.abort();

signal传给fetch,并在reader.read()循环里检查signal.aborted,就能干净停掉。

优点

  • 支持 POST + JSON body(复杂生成任务很常见)
  • 能带Authorization等自定义头
  • 取消、超时、非 2xx 错误处理都更可控
  • 和现有 API Client 风格容易统一

代价

  • 要自己处理粘包、半包、解码(下一节展开)
  • 没有浏览器那种「断了自动重连」,需要的话得自己补

粘包、半包、解码到底怎么处理?

reader.read()每次给你的不是「一条完整 SSE 事件」,而是一块块字节(Uint8Array)
网络怎么切包,你控制不了,所以会出现三种情况。

1)解码:字节 → 文本

TCP/HTTP 流里先是二进制。中文等多字节字符还可能被拆到两次read()中间。

constdecoder=newTextDecoder();// stream: true 很重要:告诉解码器「后面可能还有字节」// 遇到半个汉字时先缓存,等下次凑齐再吐出完整字符buffer+=decoder.decode(value,{stream:true});

如果写成decoder.decode(value)(默认stream: false),半个 UTF-8 字符可能直接变成 `` 或乱码。

2)半包:一次read()不够一条事件

服务端本意推送:

data: {"event":"content","data":"你好"}\n\n

但第一次可能只收到:

data: {"event":"content","da

第二次才收到:

ta":"你好"}\n\n

如果每次read()立刻JSON.parse,第一次必炸。

做法:先塞进buffer,只处理已经完整的部分。

3)粘包:一次read()塞了多条事件

也可能一次就收到:

data: {"event":"start"}\n\n data: {"event":"content","data":"你"}\n\n data: {"event":"content","data":"好"}\n\n

如果只当一条处理,会漏事件或解析失败。

做法:用分隔符切开,循环处理每一段。

4)标准解法:缓冲区 + 分隔符

SSE 一条事件以空行\n\n结束,所以:

每次 read 到一块字节 → decode 成文本,追加到 buffer → 用 '\n\n' split → 最后一段多半是「还没收完的半包」,塞回 buffer → 前面那些完整段,再提取 data: 交给业务

对应代码核心就三行:

buffer+=decoder.decode(value,{stream:true});constsegments=buffer.split('\n\n');buffer=segments.pop()||'';// 半包留下,完整段拿去处理

图示:

buffer 当前内容: ┌─────────────────────────────────────────────┐ │ data: {"event":"start"}\n\n │ ← 完整,可处理 │ data: {"event":"content","data":"你"}\n\n │ ← 完整,可处理 │ data: {"event":"cont │ ← 半包,留在 buffer └─────────────────────────────────────────────┘ ↑ segments.pop() 留着等下次

业务层JSON.parse再包一层try/catch,是为了兜住脏数据;
真正防半包的,是上面的 buffer,不是 catch。


服务端要配合什么?

无论前端用哪种连法,服务端都要先把响应变成 SSE:

res.writeHead(200,{'Content-Type':'text/event-stream','Cache-Control':'no-cache',Connection:'keep-alive','X-Accel-Buffering':'no',// 避免 Nginx 把流缓冲住});// 可选:先写一行注释心跳,帮部分代理保持连接res.write(': keep-alive\n\n');// 推一条业务事件res.write(`data:${JSON.stringify({event:'content',data:'你好'})}\n\n`);// 结束res.end();

客户端断开时记得停掉后续写入:

req.on('close',()=>{aborted=true;});

为什么常说「拿原生 res 自己写,别走普通 JSON 拦截器」?

普通接口的返回路径通常是:

Controller return { foo: 1 } → 拦截器 / 管道再包一层 → 变成 { code: 0, msg: 'success', data: { foo: 1 } } → 框架一次性 JSON.stringify 后发给前端

SSE 要的是另一条路:

先写响应头 Content-Type: text/event-stream → 每隔一会儿 res.write('data: ...\n\n') → 连接一直开着,最后再 res.end()

如果 SSE 也走「普通 JSON 拦截器」,常见会坏在三处:

  1. 格式被包坏
    你本想推:

    data: {"event":"content","data":"你好"}\n\n

    拦截器却可能变成一整段:

    {"code":0,"msg":"success","data":"……流内容或对象……"}

    前端按 SSE 去拆data:行,全对不上。

  2. 时机不对
    JSON 接口是「算完再一次性返回」。
    SSE 是「边算边推」。拦截器等你return才包装,流式体验没了。

  3. Content-Type 不对
    普通接口默认application/json;SSE 必须是text/event-stream
    头设错了,浏览器/客户端不会按事件流处理。

所以 Nest 里常见写法是:

@Post('optimize/plan')asyncoptimizePlan(@Req()req,@Res()res){// @Res():接管原生响应,框架不再替你自动 JSON.stringifyres.writeHead(200,{'Content-Type':'text/event-stream',/* ... */});res.write(`data:${JSON.stringify({event:'start'})}\n\n`);// ... 持续 writeres.end();}

如果项目有全局响应拦截器,还要对 SSE跳过包装,例如看到已经是text/event-stream就原样放过:

// 伪代码:全局拦截器里if(contentType.includes('text/event-stream')){returnnext.handle();// 不要 map 成 { code, msg, data }}returnnext.handle().pipe(map((data)=>({code:0,msg:'success',data})));

一句话:

普通接口:框架帮你打包成 JSON 信封。
SSE:你自己按事件协议往响应里「一点一点写」,别让信封逻辑插手。


怎么选?

需要 POST body?或需要 Authorization Header? ├─ 是 → fetch + ReadableStream └─ 否 ├─ 需要自动重连的简单订阅 → EventSource └─ 仍想统一客户端封装 → 也可以一律用 fetch

实战经验:

  • 聊天/写作/长任务生成:几乎都是第二种
  • 公告、公开看板、简单通知:第一种够用
  • 团队若已有鉴权 API Client,优先第二种,少维护两套连接哲学

小结

  • SSE 是服务端推事件的 HTTP 长连接,核心格式是data: ...\n\n
  • 方式一EventSource:简单、能自动重连,但基本限于 GET,难带自定义头
  • 方式二fetch + ReadableStream:可 POST、可带 Token、可 Abort,解析要自己写
  • 粘包/半包靠buffer +\n\n分隔;解码用TextDecoder({ stream: true })
  • SSE 不要走普通 JSON 信封拦截器:自己writeHead+ 持续write
  • 选哪种,看你的接口要不要鉴权请求体,大多数业务流式接口会选第二种
← 返回列表