前端对接 SSE 的两种常见方式
前端对接 SSE 的两种常见方式LLM 流式输出、进度推送、长任务状态更新后端经常会用SSEServer-Sent Events一条 HTTP 长连接服务端持续往下推事件客户端边收边渲染。协议本身不复杂事件大致长这样data: {event:content,data:你好} data: {event:end,traceId:abc}关键点响应头是Content-Type: text/event-stream一条事件通常以空行\n\n结束业务数据多放在data:后面常见再包一层 JSON前端真正要选的是怎么连上这条流。常规有两种EventSource浏览器原生fetchReadableStream手动读流一句话对比EventSourcefetch ReadableStream怎么连new EventSource(url)fetch(url)后读response.bodyHTTP 方法基本只有GETGET / POST / PUT…都行自定义请求头基本不行难带Authorization随便带请求体没有可以发 JSON body自动重连浏览器自带要自己写解析成本低浏览器帮你拆事件要自己按行/按段解析适合场景公开订阅、简单通知要登录、要 POST、要精细控制业务 API 往往需要Bearer Token POST body所以第二种更常见监控面板、公开进度页第一种更省事。方式一EventSource基本用法constesnewEventSource(/api/notifications/stream);es.onmessage(event){// event.data 就是 data: 后面的字符串constpayloadJSON.parse(event.data);console.log(payload);};es.onerror(){// 默认会自动重连不需要时可 es.close()console.error(SSE error);};// 主动断开// es.close();如果服务端用了命名事件event: progress可以这样听es.addEventListener(progress,(event){constpayloadJSON.parse((eventasMessageEvent).data);console.log(payload);});优点API 短上手快断线自动重连对「订阅型」推送很友好不用自己处理字节流和粘包限制也是很多人最终换掉它的原因基本只能 GET复杂任务参数不好塞进 URL还可能暴露在日志/代理里。很难带自定义 Header标准EventSource不能方便地加Authorization: Bearer token于是常见歪招是把 token 塞进 query?access_token...既丑也不安全。错误与状态不好细控HTTP 401/403、业务error事件、主动取消都不如fetch直观。什么时候用它不需要登录或鉴权已靠 Cookie同源自动带上接口本身就是 GET 订阅你需要浏览器自带的断线重连方式二fetchReadableStream思路用fetch发起请求可 POST、可带 Header用response.body.getReader()读二进制块TextDecoder转成文本按 SSE 规则拆出data:行JSON.parse后分发给业务回调发起带鉴权的 SSE 请求asyncfunctionrequestAuthorizedSse(url:string,init:{method:string;body?:string;signal?:AbortSignal},onDataLine:(dataLine:string)void){consttokenlocalStorage.getItem(token)||;constresponseawaitfetch(url,{method:init.method,headers:{Content-Type:application/json,Authorization:Bearer${token},},body:init.body,signal:init.signal,});if(!response.ok){consterrorBodyawaitresponse.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);}constreaderresponse.body.getReader();constdecodernewTextDecoder();letbuffer;try{while(true){if(signal?.aborted){thrownewDOMException(Aborted,AbortError);}const{done,value}awaitreader.read();if(done)break;bufferdecoder.decode(value,{stream:true});constsegmentsbuffer.split(\n\n);buffersegments.pop()||;for(constsegmentofsegments){consttrimmedsegment.trim();if(!trimmed.startsWith(data:))continue;constdataParttrimmed.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{constdataJSON.parse(dataLine);switch(data.event){casestart:console.log(开始,data.traceId);break;casecontent:appendText(data.data);// 打字机效果break;caseend:finish(data);break;caseerror:showError(data.data);break;}}catch{// 忽略半包/脏行}});取消流AbortControllerconstabortControllernewAbortController();// 用户点「停止生成」abortController.abort();把signal传给fetch并在reader.read()循环里检查signal.aborted就能干净停掉。优点支持 POST JSON body复杂生成任务很常见能带Authorization等自定义头取消、超时、非 2xx 错误处理都更可控和现有 API Client 风格容易统一代价要自己处理粘包、半包、解码下一节展开没有浏览器那种「断了自动重连」需要的话得自己补粘包、半包、解码到底怎么处理reader.read()每次给你的不是「一条完整 SSE 事件」而是一块块字节Uint8Array。网络怎么切包你控制不了所以会出现三种情况。1解码字节 → 文本TCP/HTTP 流里先是二进制。中文等多字节字符还可能被拆到两次read()中间。constdecodernewTextDecoder();// stream: true 很重要告诉解码器「后面可能还有字节」// 遇到半个汉字时先缓存等下次凑齐再吐出完整字符bufferdecoder.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: 交给业务对应代码核心就三行bufferdecoder.decode(value,{stream:true});constsegmentsbuffer.split(\n\n);buffersegments.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。服务端要配合什么无论前端用哪种连法服务端都要先把响应变成 SSEres.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,(){abortedtrue;});为什么常说「拿原生 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 拦截器」常见会坏在三处格式被包坏你本想推data: {event:content,data:你好}\n\n拦截器却可能变成一整段{code:0,msg:success,data:……流内容或对象……}前端按 SSE 去拆data:行全对不上。时机不对JSON 接口是「算完再一次性返回」。SSE 是「边算边推」。拦截器等你return才包装流式体验没了。Content-Type 不对普通接口默认application/jsonSSE 必须是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选哪种看你的接口要不要鉴权和请求体大多数业务流式接口会选第二种
