
我做了这么多年前端跟服务端打交道最多的就是各种数据推送方案。早些年用轮询后来上 WebSocket再后来遇到一些只需要服务端单向推送的场景才发现 SSEServer-Sent Events才是那个最被低估的方案。最近在项目里把它做了一次彻底的封装从基础用法到断线重连、从 EventSource 到 POST 请求、从单通道到多路复用踩了不少坑也沉淀了一套比较完整的实战方案。这篇就来聊聊 SSE 的方方面面代码可以直接抄。SSE全称 Server-Sent Events是 HTML5 标准里的一种服务端推送技术。它基于 HTTP 协议服务端可以主动向客户端推送消息。和 WebSocket 相比SSE 更轻量内置了断线重连和事件 ID 管理而且可以直接复用现有的 HTTP 基础设施不需要单独维护长连接协议。适合做消息通知、实时数据看板、AI 对话流式输出、日志实时刷新等场景。这篇指南适合几类人一是被轮询和 WebSocket 搞得很烦想找一个更简单的单向推送方案的开发者二是已经用过 SSE 但被各种坑折磨过想知道正确封装姿势的进阶选手三是准备面试想系统梳理 SSE 知识点的人。我会从原理到实战从基础 API 到完整封装把我知道的都说出来。1. 整体设计与思路拆解1.1 为什么是 SSE 而不是 WebSocket先解决一个很多人纠结的问题到底选 SSE 还是 WebSocket我自己在项目里两种都用过说几个实在的对比。WebSocket 是双向全双工通信客户端和服务端都能随时发消息。但它的成本不低需要协议升级握手、独立的 ws 协议、专门的网关和负载均衡策略、心跳保活机制。如果你只是需要服务端单向推数据用 WebSocket 就像开着一辆卡车去送一封快件能用但明显是杀鸡用了宰牛刀。SSE 的优势在于它还是 HTTP 协议。这意味着什么意味着现有的 Nginx、CDN、负载均衡、鉴权中间件、日志系统全部可以直接复用不用额外搭建任何基础设施。SSE 是单向的服务端向客户端推数据客户端想发消息就用普通的 HTTP 请求这种“推送用 SSE交互用普通请求”的组合在大多数业务场景下都是最优解。还有一个被很多人忽略的点SSE 内置断线重连。EventSource 底层有 readyState 状态机和自动重连机制服务端断开了客户端会自动重新建立连接不需要你手动去实现什么心跳和重连逻辑。而 WebSocket 挂了就是挂了需要你写一堆 onclose、onerror、重试策略的代码。选型建议很直接服务端主动推、客户端被动收且频率较高 —— 选 SSE需要双向实时交互比如聊天室、协同编辑 —— 选 WebSocket数据频率很低比如每分钟一次—— 用轮询就够了别折腾1.2 SSE 的核心机制和数据格式SSE 的核心是text/event-stream这个 MIME 类型。服务端保持连接不关闭按特定格式往响应流里写数据。一个标准的 SSE 数据帧长这样id: 1 event: message data: {type: tick, value: 42}每个字段的含义id事件 ID客户端会存下来断线重连时通过Last-Event-ID头带给服务端实现增量续传event事件类型默认是message可以自定义客户端用addEventListener监听data实际数据内容可以是多行多行 data 会被拼接成一个字符串retry重连时间间隔单位毫秒服务端可以动态指定字段之间用换行分隔事件之间用空行分隔如果有多行 data客户端会把它们用换行符拼接起来。所以如果你要传 JSON压成一行就行别瞎换行不然解析会出问题。1.3 什么时候需要封装封装能解决什么原生 EventSource 虽然开箱即用但在真实项目里远远不够。我总结了几类必须做封装的场景原生 API 不支持自定义请求头。EventSource 只能发 GET 请求不能设置 Header。但项目里的接口基本都要带 token 鉴权这就很尴尬了。要么把 token 放在 query 参数上会写进网关日志有安全风险要么就得自己用 fetch 实现。原生 API 不支持 POST 请求。有些业务场景尤其是 AI 对话和复杂查询请求体很大或者包含敏感参数GET 装不下也暴露不安全必须用 POST。EventSource 做不到只能自己封装。原生 API 的重连策略太粗暴。虽然内置自动重连但它是无脑重连不会退避不会处理连接被服务端主动关闭的情况也不会把重连事件暴露给你做 UI 提示。所以一个真正能上生产的 SSE 封装至少要具备这几个能力自定义请求头、支持 GET 和 POST、断线自动重连且带退避策略、事件分发不只 message 一种类型、超时控制、状态回调、防重复连接、资源释放。2. 核心细节解析与实操要点2.1 EventSource 基础 API 盘点先过一遍原生 API这是封装的地基。const es new EventSource(/api/sse, { withCredentials: true }); es.onopen () { console.log(连接已建立); }; es.onmessage (event) { const data JSON.parse(event.data); console.log(收到消息:, data); }; es.onerror (error) { // 这里 error 信息有限通常需要根据 readyState 判断 console.log(连接异常, readyState:, es.readyState); }; // 自定义事件 es.addEventListener(chat, (event) { const data JSON.parse(event.data); console.log(收到 chat 事件:, data); }); // 关闭连接 es.close();EventSource 的 readyState 有三种状态常量值含义CONNECTING0正在连接中OPEN1连接已建立正常通信CLOSED2连接已关闭不会重连一个很重要的细节当 readyState 是 0 的时候触发 onerror说明连接还没建立起来就挂了此时 EventSource 会自动尝试重连但如果是服务端主动关闭连接返回 HTTP 200 后关断readyState 会是 2EventSource 会自动重新连接。对这就是它的特性源码里就是这么实现的——只要不是close()手动关闭它都会尝试重连。这个特性在日常用挺好但封装的时候就得注意区分到底是临时断网需要重试还是服务端明确告诉我们“你别来了”。这两种情况的重连策略应该不一样。2.2 数据解析的坑多行 data、字段命名、注释行SSE 协议里有个容易被忽视的规则以冒号开头的行是注释行。客户端收到注释行不会触发任何事件但可以作为保活手段。我见过一些后端同事用注释行做心跳: ping这种做法比空行更规矩因为它明确表明“连接还活着”。再说到 JSON 解析。服务端最常见的输出是data: {code: 0, data: {...}}客户端这边直接JSON.parse(event.data)就行。但有个坑如果 JSON 里恰好有换行符比如消息内容里带了\n有的后端会直接输出成多行 data。协议规定多行 data 会被拼接成一行但拼接的时候用的是换行符连接。这就可能造成 JSON 格式错误。所以务必跟后端约定好所有 JSON 数据必须压缩成单行输出。事件类型也有讲究。我在项目中一般约定三种事件message通用的数据推送ready服务端准备好后推送第一条表示通道可用error服务端逻辑层面的错误比如参数校验失败、权限不足。注意这个 error 是业务错误不是连接错误需要在 onmessage 里额外分发出来不能在 onerror 里处理2.3 鉴权方案对比Query 参数、Cookie、自定义 HeaderSSE 鉴权是个绕不开的话题。三种方式我都用过各有利弊把 token 放在 query 参数上就是/sse?tokenxxx。优点是用原生 EventSource 就能搞定代码少缺点是 token 会出现在 Nginx access_log、网关日志、浏览器历史记录里。如果你用的是短时 token比如 10 分钟有效还能接受长时 token 千万别这么干。用 Cookie 鉴权就是用 withCredentials 让浏览器自动带上 Cookie。这是最标准的方式也最安全。但要求后端支持 Cookie 鉴权且接口要和前端同域或者正确配置 CORS 凭证。如果你做的是 BFF 架构前端请求自己的后端这个方案很舒服。用自定义 Header 鉴权原生 EventSource 做不到得用 fetch 封装。这种方式最灵活可以随便带Authorization: Bearer xxx。但要处理一件事情fetch 的响应流是 ReadableStream需要自己解析 SSE 数据帧。后面我会给出完整的实现。我自己在生产环境的推荐方案是同域用 Cookie跨域用自定义 Header fetch 封装。Query 参数只适合临时调试不建议上生产。3. 实操过程与核心环节实现3.1 基于 fetch 流式读取的 SSE 封装先上核心代码用 fetch 替代 EventSource实现自定义 Header 和 POST 请求的支持。class SSEClient { constructor(options) { this.url options.url; this.method options.method || GET; this.headers options.headers || {}; this.body options.body || null; this.withCredentials options.withCredentials || false; this.enableAutoReconnect options.enableAutoReconnect ?? true; this.reconnectInterval options.reconnectInterval || 3000; this.maxReconnectTimes options.maxReconnectTimes || Infinity; this.onMessage options.onMessage || (() {}); this.onOpen options.onOpen || (() {}); this.onError options.onError || (() {}); this.onClose options.onClose || (() {}); this.controller null; this.reconnectTimes 0; this.isManualClose false; this.eventListeners new Map(); } connect() { if (this.controller) { this.disconnect(); } this.isManualClose false; this.controller new AbortController(); const fetchOptions { method: this.method, headers: this.headers, signal: this.controller.signal, }; if (this.body) { fetchOptions.body this.body; } if (this.withCredentials) { fetchOptions.credentials include; } const responsePromise fetch(this.url, fetchOptions); responsePromise .then((response) { if (!response.ok) { throw new Error(HTTP error: ${response.status} ${response.statusText}); } const contentType response.headers.get(Content-Type) || ; if (!contentType.includes(text/event-stream)) { throw new Error(Unexpected content type: ${contentType}); } this.onOpen(); this.readStream(response.body.getReader()); }) .catch((err) { if (err.name AbortError) { // 主动取消不触发重连 return; } this.handleError(err); }); return this; } async readStream(reader) { const decoder new TextDecoder(utf-8); let buffer ; try { while (true) { const { done, value } await reader.read(); if (done) { if (this.isManualClose) { this.onClose(); } else { this.handleError(new Error(Stream closed by server)); } break; } buffer decoder.decode(value, { stream: true }); // 按空行切分 SSE 事件 const frames buffer.split(/\r?\n\r?\n/); buffer frames.pop(); // 最后一段可能不完整留到下一轮处理 for (const frame of frames) { this.parseFrame(frame); } } } catch (err) { if (err.name AbortError) { this.onClose(); } else { this.handleError(err); } } } parseFrame(frame) { const lines frame.split(/\r?\n/); let eventName message; const dataLines []; let id null; let retry null; for (const line of lines) { if (line.startsWith(:)) { continue; // 注释行 } const colonIndex line.indexOf(:); if (colonIndex -1) continue; const field line.slice(0, colonIndex); let value line.slice(colonIndex 1); if (value.startsWith( )) { value value.slice(1); } if (field event) { eventName value; } else if (field data) { dataLines.push(value); } else if (field id) { id value; } else if (field retry) { retry parseInt(value, 10); } } if (dataLines.length 0) return; const data dataLines.join(\n); this.dispatchEvent(eventName, { id, retry, data }, data); } dispatchEvent(eventName, meta, rawData) { let parsedData; try { parsedData JSON.parse(rawData); } catch { parsedData rawData; } const eventPayload { ...meta, data: parsedData, rawData, }; if (eventName message) { this.onMessage(eventPayload); } if (this.eventListeners.has(eventName)) { const listeners this.eventListeners.get(eventName); for (const listener of listeners) { listener(eventPayload); } } } on(eventName, callback) { if (!this.eventListeners.has(eventName)) { this.eventListeners.set(eventName, []); } this.eventListeners.get(eventName).push(callback); return this; } handleError(err) { this.onError(err); if (!this.enableAutoReconnect || this.isManualClose) { return; } if (this.reconnectTimes this.maxReconnectTimes) { this.onError(new Error(Max reconnect times reached)); return; } // 退避策略指数退避 随机抖动 const delay Math.min(this.reconnectInterval * Math.pow(2, this.reconnectTimes), 30000); const jitter Math.round(Math.random() * 1000); this.reconnectTimes; setTimeout(() { if (!this.isManualClose) { this.connect(); } }, delay jitter); } disconnect() { this.isManualClose true; if (this.controller) { this.controller.abort(); this.controller null; } } } export default SSEClient;用起来的效果是这样const client new SSEClient({ url: /api/ai/chat, method: POST, headers: { Content-Type: application/json, Authorization: Bearer ${token}, }, body: JSON.stringify({ prompt: 你好, stream: true }), onMessage: (event) { console.log(event.data); }, onOpen: () { console.log(连接建立); }, onError: (err) { console.error(连接出错:, err.message); }, }); client.connect(); // 监听自定义事件 client.on(ping, (event) { console.log(收到 ping:, event.data); }); // 手动断开 client.disconnect();几点实现说明读取流用的是 ReadableStream TextDecoder。TextDecoder 要注意{ stream: true }这个参数因为二进制流切出来的每个 chunk 边界跟 UTF-8 字符边界是不对齐的如果不加 stream 模式中文字符可能会在截断的地方变成乱码。SSE 帧的切分我用了正则/\r?\n\r?\n/兼容了 Windows 和 Unix 的换行符。buffer 里留下的最后一段不完整数据等下一轮拿到新 chunk 后再拼接。这是个很关键的处理逻辑少了它流式传输的数据会丢失首尾。dispatchEvent里顺便做了 JSON 解析。有的后端只会推纯字符串有的推 JSON封装成自动判断有助于减少业务侧的防御代码。3.2 Vue 3 组合式 API 封装useSSE在 React 里我一般直接用上面那个类配 useEffect 就可以。在 Vue 3 项目里我更喜欢用组合式 API 封装一层把生命周期、销毁、响应式状态都管起来。import { ref, onMounted, onBeforeUnmount } from vue; import SSEClient from /utils/sse-client; export function useSSE(options) { const client ref(null); const connected ref(false); const error ref(null); const lastMessage ref(null); const eventListeners options.eventListeners || []; onMounted(() { client.value new SSEClient({ ...options, onOpen: () { connected.value true; error.value null; if (options.onOpen) options.onOpen(); }, onError: (err) { connected.value false; error.value err; if (options.onError) options.onError(err); }, onMessage: (event) { lastMessage.value event.data; if (options.onMessage) options.onMessage(event); }, }); for (const { name, handler } of eventListeners) { client.value.on(name, handler); } client.value.connect(); }); onBeforeUnmount(() { if (client.value) { client.value.disconnect(); } }); const reconnect () { if (client.value) { client.value.reconnectTimes 0; client.value.connect(); } }; return { connected, error, lastMessage, reconnect, }; }组件里这样用script setup import { useSSE } from /composables/useSSE; import { onMessage, handleNewOrder } from /api/order; const { connected, lastMessage, error } useSSE({ url: /api/order/realtime, onMessage: (event) { handleNewOrder(event.data); }, }); // 在模板里展示 connected 状态给用户一个实时的连接指示 /script这个组合式封装的核心价值是把连接生命周期和组件生命周期绑定了。组件卸载自动断开不会留下“组件都销毁了连接还挂着”的内存泄漏问题。connected、error、lastMessage变成响应式数据UI 可以直接绑定省去手动管理事件回调的麻烦。3.3 多事件分发与业务解耦上面的封装已经支持了自定义事件。这在实际业务里特别有用。举个我最近做的项目的例子一个实时大屏需要同时展示股票行情、系统告警、用户操作日志。如果用同一个 message 事件推前端判断起来很费劲data: {type: stock, symbol: AAPL, price: 173.5} data: {type: alert, level: warning, message: CPU usage exceeded} data: {type: log, userId: 123, action: login}这在协议上其实是把不同类型的数据混在一个通道里用 data 里的 type 字段做二次分发。更好的方式是让后端直接用 SSE 协议的事件类型来区分event: stock data: {symbol: AAPL, price: 173.5} event: alert data: {level: warning, message: CPU usage exceeded} event: log data: {userId: 123, action: login}客户端代码就清晰多了client.on(stock, ({ data }) { updateStockPrice(data); }); client.on(alert, ({ data }) { showAlert(data); }); client.on(log, ({ data }) { appendLog(data); });这种做法的好处是前后端都省去了 type 枚举的维护成本。后端 emit 时只用关心推的是什么业务事件前端注册监听时也很直观看到on(stock, ...)就知道是股票行情回调。代码即文档。需要注意的是message事件是默认兜底。如果推送的帧没有指定event字段就会当message处理。如果你的业务里同时用了自定义事件和默认 message要把它们设计清楚避免后端忘记写 event 字段导致前端拿到数据但找不到对应 handler。3.4 多路复用一个连接承载多个数据流再往上走一步有些页面需要同时接收多个互不相关的数据流。比如一个后台管理页面上面是要实时刷新的订单列表下面是日志流。最直接的方案是开两个 SSE 连接但这样会占用两条 HTTP 长连接对连接数紧张的场景浏览器对同一域名有 6 个 TCP 连接的限制不友好。我的做法是用一条连接通过event字段做多路分发。后端在连接建立后可以为不同业务注册不同的频道推送时按频道打上不同的 event 标记event: order.updated data: {orderId: 123, status: paid} event: log.new data: {level: info, message: order #123 paid}客户端注册成client.on(order.updated, ({ data }) { updateOrderList(data); }); client.on(log.new, ({ data }) { appendToLogPanel(data); });这时候其实不用开两个连接但能同时满足两个业务模块的数据需求。代价是要和后端约定好一套事件命名规范。我自己的习惯是业务模块.动作比如order.updated、user.login、stock.price既有可读性也能避免事件名冲突。4. 常见问题与排查技巧实录4.1stream disconnected before completion: idle timeout waiting for sse排查这个报错应该是最近大家在 Nginx 日志里见到最多的了。字面意思是流在完成之前被断开了等待 SSE 消息时空闲超时。触发场景一般是 SSE 连接建立了但有一段空闲时间比如 10-20 秒没有数据流经Nginx 或负载均衡就把连接断开了。原因一般是代理层的空闲超时配置。Nginx 默认proxy_read_timeout是 60 秒也就是说如果 60 秒内没有从上游读到任何数据连接就会被判为超时断开。而 SSE 的特点是服务端可能长时间不推数据只在有事件时才推一条。一旦超过代理的超时阈值连接就被强断了。解决方案有两个方向服务端定期发送: ping注释行或者event: ping的心跳帧来保活让代理层认为连接一直有数据在流动。这是最规范的做法。很多后端框架比如 Spring WebFlux 的 SSREmitter需要手动设置心跳。只要服务端每 15-20 秒发一个注释行Nginx 就不会判断为空闲了。调大 Nginx 的proxy_read_timeout可以调到 300 秒甚至更长。但这不是根治只是拖延。如果是一直没数据的长连接调再大也还是会断。还有个细节Nginx 的proxy_buffering配置。SSE 是流式输出必须把缓冲关掉否则 Nginx 会把上游响应先攒起来等积累到一定大小才一次性发给浏览器SSE 就变成了“伪流式”。我踩过这个坑现象是服务端推了消息但页面迟迟不更新过一会儿突然全出来了。配置如下location /api/sse { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Connection ; proxy_buffering off; proxy_cache off; proxy_read_timeout 300s; proxy_send_timeout 300s; }proxy_http_version 1.1也很关键。SSE 需要长连接HTTP/1.0 默认是短连接每次请求完就断开根本没法做流式推送。proxy_set_header Connection 是清空 Connection 头让 Nginx 不会尝试和上游做 HTTP/1.1 的 Connection 复用而被干扰。4.2 连接被服务端主动关闭 vs 网络异常断开的区分前面提到 EventSource 在服务端主动关闭时也会自动重连这有时候是好事有时候是坏事。好事的场景服务端因为发布重启或临时故障断开了连接客户端自动重连用户无感连接恢复后继续推送。这个体验是很好的。坏事的场景用户登录状态过期服务端主动关闭连接。如果前端无脑自动重连会陷入“连上-鉴权失败-断开-再连”的死循环白白浪费资源。解决方案是在协议层面区分。我的做法是服务端判断到鉴权失效时先通过 SSE 推送一条业务错误消息再关闭连接event: auth_error data: {code: 401, message: session expired}客户端收到auth_error事件后设置isManualClose true主动断开连接然后跳转登录页。封装里这么处理client.on(auth_error, (event) { client.isManualClose true; client.disconnect(); // 跳转登录页 window.location.href /login; });这样就把“临时故障可重连”和“永久性错误不可重连”区分开了。如果是服务端就返回 401 状态码而没有先推事件那就在 fetch 响应处理里直接判断if (response.status 401) { client.isManualClose true; this.onError(new Error(Unauthorized)); return; }这两种情况都要考虑因为不同的后端实现方式不一样。4.3 重连风暴与服务端压力默认的自动重连如果策略不好一旦服务端故障所有客户端会在同一时刻疯狂重连把本来就脆弱的服务端打到更惨这就是“重连风暴”或者“惊群效应”。SSE 内置的自动重连用的是固定间隔没有退避这在客户端数量大的时候风险很高。我的封装里实现了指数退避加随机抖动代码在handleError里有。逻辑是首次重连延迟 3 秒之后每次失败翻倍3s、6s、12s、24s…封顶 30 秒再加一个 0-1000ms 的随机抖动。为什么要加抖动如果所有客户端都在第 3 秒重连还是会在同一时刻打向服务端。加上随机抖动后重连时间会均匀散开服务端的压力就会平缓很多。还有一个建议是可以在服务端加一个简单的监控统计当前活跃 SSE 连接数设定阈值告警。连接数异常飙升基本就说明客户端重连逻辑出问题了。4.4 页面隐藏时暂停连接显示时恢复这是个容易被人忽略的优化点。用户切到其他标签页后SSE 连接还在照常收数据。如果数据的处理逻辑里有 UI 更新浏览器对 background tab 的 JS 执行有节流策略页面切回来时会积压一堆 UI 更新瞬间卡顿一下。性能好的做法是监听visibilitychange事件页面隐藏时主动断开连接页面重新可见时再重新连接。核心代码document.addEventListener(visibilitychange, () { if (document.hidden) { // 临时暂停不要触发重连 client.isManualClose true; client.controller?.abort(); } else { // 恢复连接 client.isManualClose false; client.reconnectTimes 0; client.connect(); } });这个优化的收益是减少无意义的网络流量降低 CPU 使用率不用处理和渲染不可见页面的数据也避免回到页面时的卡顿感。要注意的细节是暂停时要通过isManualClose true阻止自动重连否则刚断开又自动连上了白忙活。4.5 Vue/React 项目里的数据残留和内存泄漏还有一种常见问题是用框架开发时组件卸载后 SSE 连接没关。这种情况通常发生在在组件方法里 new 了 EventSource / fetch 流但没有在组件的卸载钩子里做清理。组件销毁后连接还活着回调还在执行如果回调里引用了组件实例比如 setState 或修改 ref就会导致内存泄漏严重时会报“Cant perform a React state update on an unmounted component”之类的警告。解决办法就是像我 3.2 节里做的那样把连接生命周期绑定到组件生命周期。React 里面就是 useEffect 的 cleanupVue 里面就是 onBeforeUnmount。这是一个必须刻进 DNA 的习惯。另外要注意 EventSource 是自己有状态的对象如果连接重连过多次每个连接实例都不能重复使用。我在封装里每次connect()时都会先中断上次的controller确保不会产生多个并行连接。这个细节在重复点击“重连按钮”时会特别重要没做去重的化点几次就开几条流浏览器会直接被拖垮。4.6 浏览器兼容性需要知道的老版本回退方案SSE 的兼容性其实还不错除了极早期的 EdgeEdgeHTML 内核一度只支持 WebSocket和部分老版本的 IE全系列不支持现代浏览器基本都支持。如果你要支持 IE那 SSE 就用不了只能退回到轮询方案。如果只考虑需要兼容旧版 Edge 的场景有一个替代方案用fetch流式读取这就是我在 3.1 节里实现的方案。fetch 的 ReadableStream 在稍微新一点的浏览器里都能用而且 API 是标准的。如果你的目标环境极其老旧比如 Android WebView 5.x、IE11那就老老实实写轮询吧或者降级策略先尝试用 SSE不支持就自动切到 3 秒一次的轮询。我在一个老项目中做过这个降级体验不能说多好但至少功能可用。5. 从面试题看 SSE 的考察重点5.1 面试中关于 SSE 的高频知识点我看了一下最近的前端面试题SSE 出现的频率越来越高了。面试官关注的点通常集中在SSE 和 WebSocket 的区别、SSE 的数据格式、EventSource 的 API 细节、断线重连机制、以及如何用 fetch 实现 SSE。SSE 和 WebSocket 的区别要答出这几个维度维度SSEWebSocket方向单向服务端到客户端双向全双工协议HTTP/HTTPSWebSocket 协议数据格式文本按 SSE 规范文本或二进制帧断线重连内置自动重连需要自己实现鉴权复用 HTTP Cookie/Header额外处理复杂度低高EventSource 的 API 细节有三个必考知识点readyState三种状态连接中、已连接、已关闭、onmessage和addEventListener两种订阅方式、以及close()方法手动关闭。用 fetch 实现 SSE 的题目在面试中也出现过。核心思路fetch 拿到ReadableStream用getReader()读取解析text/event-stream格式的数据帧。遇到断线需要自己处理重连。这个题目一出来如果面试者能答出TextDecoder的stream: true参数和 SSE 帧切分这些细节基本就稳了。5.2 常见追问及思路还有一个面试官喜欢追问的问题为什么 EventSource 只能 GET而 fetch 方案能 POST要回答这个你需要了解 EventSource 的 API 设计局限——它被设计成一个简单的开箱即用类只支持 GET 请求不提供自定义请求头能力。服务端 API 需要更复杂的参数传递时就不够用了。这时就要用 fetch ReadableStream 的方案能完全控制请求参数和响应处理。还有一个容易被问到的场景题实时消息推送你会选 SSE 还是 WebSocket要回答这个不能只背区别要结合业务如果是聊天类应用用户需要实时双向沟通选 WebSocket如果是通知类应用服务端单向推送SSE 就够了如果考虑到实现成本、鉴权、负载均衡的复杂度SSE 在大部分场景下都是性价比更高的选择。能从这个维度回答面试官会觉得你有真实项目经验不是在背书。6. 生产环境落地经验与扩展建议6.1 协议约定前后端必须对齐的几个点SSE 这个技术本身不复杂但真正让它“复杂”起来的是前后端约定。我在多个项目中踩坑之后整理了一份 SSE 协议约定清单每次做新项目都会先和后端对齐数据格式统一用单行 JSON。多行 data 容易导致 JSON 解析问题。事件类型统一定义不要随意发明新的。像message、ready、error、auth_error、ping这几种文档里列清楚每个事件的语义和数据结构。心跳策略统一。服务端每 15-20 秒发一个注释行: ping或者event: ping的心跳帧客户端不需要响应因为 SSE 是单向的这纯粹是为了保活防止代理层超时断开。断线重连的语义统一。客户端重连时通过Last-Event-ID头把上次收到的最后一条事件 ID 带给服务端服务端据此决定是从头补发还是只发增量。这个机制原生 EventSource 自动支持但用 fetch 封装的话需要手动处理。关闭连接的语义统一。服务端主动断开要分两种情况可重连的临时故障和不可重连的永久错误。永久错误要在断开前通过事件帧把错误码推给客户端。6.2 大流量场景的注意事项如果页面同时有成千上万人打开每个用户都保持一条 SSE 连接服务端的并发连接数会是一个很大的压力。这不是 SSE 本身的问题TCP 长连接的性质就是这样。有几个经验可以分享接入层必备Nginx 或网关要做好proxy_read_timeout、连接数限制、以及limit_conn配置避免某个 IP 恶意建立大量连接。服务端监控实时监控 ESTABLISHED 状态的连接数和连接建立速率。如果连接数快速飙升说明可能有异常客户端在疯狂重连。合并推送如果业务允许可以把多条消息合并成一条批量推送降低网络报文数量。比如股票行情每秒推一次合并报价就要比每只股票单独推一条高效得多。6.3 SSE 的扩展方向AI 对话和流式响应最近 AI 应用爆发SSE 又火了一把。大模型接口比如 OpenAI 兼容的 chat/completions 接口流式输出用的正是 SSE 协议。前端要拿 AI 回复一个字一个字地渲染出来SSE 是最自然的选择。我在对接 AI 接口时服务端返回的格式大致是这样data: {id: chatcmpl-123, choices: [{delta: {content: 你好}, index: 0}]} data: {id: chatcmpl-123, choices: [{delta: {content: 世界}, index: 0}]} data: [DONE]最后那个[DONE]是一个约定好的结束标记看到它就可以关闭响应或者把 UI 切换成“生成完成”状态了。我的封装里做了特殊处理如果data等于[DONE]就触发一个done事件业务侧可以监听它来做收尾工作。前端 AI 对话的体验优化还有一个关键点渲染节流。如果每个 token 都触发一次 UI 更新React 或 Vue 的重渲染次数会非常多性能压力大。我的做法是收集文本到一个 buffer用 requestAnimationFrame 或者 50ms 定时器批量刷新 DOM。不要小看这个优化长回复场景下渲染帧数从几百次降到几十次体感差别很大。6.4 最后的建议什么时候别用 SSE写了不少 SSE 的优点最后也要泼一盆冷水。SSE 不是万能的有些场景就是不适合。高实时性双向通信场景比如多人游戏、在线白板协同SSE 的支持度很差因为它是单向的客户端要发消息还得另走普通 HTTP延迟比 WebSocket 高很多。消息频率极低且不要求准时的场景比如每天的报表推送用 SSE 反而浪费连接资源HTTP 轮询就够了。客户端和服务端跨网关场景下代理配置不可控比如客户的内网环境有各种安全设备很可能会拦掉长时间不响应的连接难以排查这时用短轮询反而更稳。技术选型这件事永远是场景优先。SSE 是个好工具但它不是解决问题的全部答案。作为前端开发者掌握 SSE 的封装和实战用法意味着你在遇到实时推送需求时多了一个轻量可靠的选择。希望这些经验能帮你少踩几个坑。我在项目中还做了一些有意思的扩展比如配合 Agent 编排系统把 SSE 当作事件总线、用 Redis 订阅做多实例广播、以及在小程序场景里用 WebSocket 模拟 SSE 的行为——这些内容后续可以再单独写几篇。先说这么多如果你在实际落地过程中遇到什么问题欢迎在评论区讨论。