跳到主要内容

SSE(Server-Sent Events)

SSE 很适合放在请求与数据层里看,因为它处理的不是普通接口查询,而是“服务端持续往客户端推送数据流”。

如果只需要服务端单向往前端推送消息,而不需要客户端在同一条连接里频繁回发消息,SSE 往往会比 WebSocket 更轻,也更容易接进现有 HTTP 体系。

SSE 在解决什么

普通 HTTP 请求更像这样:

  • 客户端发起
  • 服务端响应
  • 一次请求结束

一旦进入这些场景,就会显得不够顺:

  • AI 对话流式输出
  • 实时日志
  • 任务进度流
  • 审核状态流
  • 单向通知流

如果这些场景继续用短轮询,常见问题就是:

  • 更新不够及时
  • 空转请求很多
  • 前端要自己不停拉
  • 服务端和网关资源会被重复请求放大

SSE 的核心价值,就是把一次 HTTP 响应拉成长连接事件流,让服务端可以持续往前推消息。

先记住它的几个特征

  • 基于 HTTP
  • 服务端单向推送
  • 浏览器原生支持 EventSource
  • 默认文本协议,内容类型是 text/event-stream
  • 原生模型自带自动重连
  • 很适合流式输出、通知流、日志流
  • 不适合高频双向互动

MDN 对 SSE 的描述也很明确:浏览器可以通过 EventSource 接收服务端持续发送的事件流。

它和普通 HTTP 的区别

SSE 不是“反复请求一个接口”,而是:

  • 客户端建立一次 HTTP 请求
  • 服务端保持响应不断开
  • 后续持续写入事件块
  • 客户端边收边处理

这意味着:

  • 服务端不能等数据全部准备好再一次性返回
  • 网关和代理层要允许长时间保持连接
  • 前端要按流式思路处理,而不是按一次 await res.json() 的思路处理

SSE 的协议格式怎么理解

SSE 用的是纯文本协议。一个事件块通常像这样:

event: message
id: msg_001
data: hello

每个事件以空行结束。常见字段有:

  • event
  • data
  • id
  • retry

event

表示事件类型:

event: progress

data

表示事件内容。可以出现多行。

data: {"step": 1}

id

表示事件 ID,通常配合断线重连和续传使用。

id: chunk_120

retry

表示建议的重连间隔,单位是毫秒。

retry: 3000

注释行

: 开头的行通常用来做心跳:

: ping

一个最小服务端输出示例

HTTP/1.1 200 OK
Content-Type: text/event-stream; charset=utf-8
Cache-Control: no-cache, no-transform
Connection: keep-alive

id: 1
event: message
data: hello

id: 2
event: message
data: world

响应头最少要关注什么

最关键的通常是:

  • Content-Type: text/event-stream; charset=utf-8
  • Cache-Control: no-cache, no-transform
  • Connection: keep-alive

其中 no-transform 很值得加,因为有些中间层会对响应做压缩或改写,流式场景下这会带来额外问题。

原生 EventSource 怎么接

这是比较容易起步的一种方式。

const es = new EventSource('/api/stream', { withCredentials: true })

es.onmessage = (event) => {
console.log(event.data)
}

es.onerror = () => {
console.log('stream error')
}

EventSource 的优点

  • 浏览器原生支持
  • 自动重连模型直接可用
  • API 简单
  • 适合快速接流式接口

EventSource 的限制

  • 不支持自定义请求头
  • 请求方法固定是 GET
  • 更适合 Cookie 鉴权或 URL token
  • 协议解析相对固定,不像 fetch 那么自由

fetch 读 SSE 有什么不同

如果需要:

  • 自定义请求头
  • POST 请求
  • 更灵活地处理 ReadableStream
  • 和现有请求层保持一致

那前端通常会改成 fetch + stream reader

const res = await fetch('/api/stream', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ prompt: 'hello' }),
})

const reader = res.body?.getReader()

这种方式更灵活,但代价是:

  • 需要自己解析 SSE 协议
  • 需要自己处理重连
  • 需要自己处理心跳和状态恢复

EventSourcefetch 怎么选

EventSource 更适合

  • GET 即可满足
  • Cookie 鉴权即可满足
  • 只想快速接稳定的单向流

fetch 更适合

  • 需要 POST
  • 需要自定义请求头
  • 需要和现有请求层统一
  • 需要更细的流式控制

一个更接近真实项目的 EventSource 封装

export type SseMessage = {
type: string
data: string
id?: string
}

export class EventSourceClient {
private source: EventSource | null = null
private url = ''

connect(url: string) {
this.url = url
this.source = new EventSource(url, { withCredentials: true })

this.source.onopen = () => {
console.log('sse opened')
}

this.source.onmessage = (event) => {
console.log('sse message', event.data)
}

this.source.onerror = () => {
console.log('sse error')
}
}

close() {
this.source?.close()
this.source = null
}
}

事件类型怎么用

onmessage 只能接默认消息。如果服务端发的是带 event: 的事件类型,就应该显式监听。

const es = new EventSource('/api/stream')

es.addEventListener('progress', (event) => {
const e = event as MessageEvent
console.log('progress', e.data)
})

es.addEventListener('heartbeat', () => {
console.log('heartbeat')
})

这层很适合:

  • 进度流
  • 状态流
  • 分类型消息流

id 和断点续传怎么做

SSE 的一个优势,是协议层就考虑了事件 ID。

服务端发送:

id: chunk_120
data: next token

浏览器在后续重连时,会带上 Last-Event-ID

这意味着服务端可以:

  • 从上一个已确认事件继续推
  • 跳过已经发过的 chunk
  • 给断线恢复留出空间

如果是 AI 输出、日志流、长任务进度流,这层很有价值。

重连怎么理解

EventSource 自带的重连

原生 EventSource 已经会在连接断开后自动重连。

但项目里通常还要自己补一层策略

原因一般有:

  • 要限制最大重试次数
  • 要区分鉴权失败和网络失败
  • 要结合页面状态决定是否继续连
  • 要做退避,而不是始终固定频率

一个更稳的重试思路

  • 网络抖动:允许自动重连
  • 页面主动停止:不再重连
  • 鉴权失效:直接终止并触发登录态处理
  • 服务端明确拒绝:不要死循环重试

心跳为什么重要

虽然 SSE 是长连接,但中间层并不一定会无限放行空闲连接。

常见问题包括:

  • 代理层空闲超时
  • CDN / 网关切断长时间无数据的连接
  • 浏览器标签页切后台时连接状态变化

所以服务端通常会定期发:

: ping

或者:

event: heartbeat
data: {}

前端则可以基于心跳做超时判断。

鉴权怎么做

SSE 的鉴权方式比 WebSocket 更收敛一些。

如果站点本身已经是 Cookie 登录态,EventSource 会是很顺的一条路。

2. URL token

如果必须显式传 token,而又只能用 EventSource,有些项目会放在 query string 里。

这种方式虽然常见,但要更注意:

  • 日志暴露
  • 中间层记录 URL
  • token 生命周期控制

3. fetch + stream

如果项目必须带自定义 Authorization 头,更稳的方式通常就是直接改成 fetch 读流。

服务端怎么写更稳

Node / Express 思路

app.get('/api/stream', (req, res) => {
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8')
res.setHeader('Cache-Control', 'no-cache, no-transform')
res.setHeader('Connection', 'keep-alive')

res.flushHeaders?.()

const timer = setInterval(() => {
res.write(`event: heartbeat\n`)
res.write(`data: {}\n\n`)
}, 15000)

req.on('close', () => {
clearInterval(timer)
res.end()
})
})

边缘运行时 / Web Streams 思路

如果运行环境是 Fetch API 风格,也可以直接返回 ReadableStream

const stream = new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode('event: message\n'))
controller.enqueue(new TextEncoder().encode('data: hello\n\n'))
},
})

return new Response(stream, {
headers: {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
},
})

Nginx / 代理层要注意什么

SSE 很多线上问题都出在中间层,而不是前端代码。

常见关注点通常有:

  • 不要缓冲响应
  • 不要把长连接太快切断
  • 不要让 gzip / transform 打乱事件边界

如果代理层默认缓冲响应,客户端就会看到“服务端明明在推,前端却一大段时间都收不到”。

和 WebSocket 的边界

SSE 更适合

  • 服务端单向推送
  • AI 输出流
  • 日志流
  • 进度流
  • 审核状态流

WebSocket 更适合

  • 聊天
  • 协同编辑
  • 双向互动
  • 高频状态同步
  • 房间 / 频道模型

一句话记:

  • 只需要服务端往前端推:优先看 SSE
  • 双方都要频繁发消息:优先看 WebSocket

和轮询、长轮询的关系

SSE 是实时链路里的中间层选择:

  • 比轮询更实时
  • 比长轮询更自然地承接持续推送
  • 比 WebSocket 更轻,但能力也更单向

如果要一起对照看,这部分已经单独拆到 轮询 / 长轮询 / 实时通信选型 里了。

真实项目里常见的前端问题

1. 连接没断,但 UI 早就不更新了

通常要排查:

  • 心跳是否还在
  • 中间层是否做了缓冲
  • 事件解析是否卡住
  • 前端是否把 reader 或 EventSource 提前清掉了

2. 重连之后内容重复

通常要看:

  • 是否用了 id
  • 是否处理了 Last-Event-ID
  • 前端是否做了去重

3. AI 流式输出里断线恢复不好做

如果要恢复,就最好一开始就把:

  • chunk ID
  • 上下文 cursor
  • 用户侧已收到位置

设计进去。

4. EventSource 不能带请求头

这不是 bug,而是原生限制。需要请求头时,通常就改走 fetch + stream

常见误区

1. 把 SSE 当成 WebSocket 的简化版替代

它们不是“同一层少一点功能”的关系,而是设计目标就不同。

2. 只看前端 API,不看代理层

很多 SSE 问题最后都和:

  • Nginx 缓冲
  • 反向代理超时
  • 网关 transform

有关。

3. 只做自动重连,不做停止条件

一旦用户主动停止、鉴权失效、任务结束,前端应该能明确停下来。

4. 流式输出没有事件类型和 ID

项目一开始简单时还看不出问题,一旦进入:

  • 断点恢复
  • 多种消息类型
  • 复杂状态流

没有 eventid 会很快变乱。

在前端项目里怎么放更合适

如果项目已经有请求层,SSE 通常也应该收进这一层,而不是直接散在页面组件里。

比较稳的结构一般像这样:

src/
services/
sse/
event-source.ts
fetch-stream.ts
parser.ts
reconnect.ts
cursor.ts

这样后面要补:

  • 重连
  • 心跳
  • cursor 恢复
  • 流式解析
  • 鉴权策略

都会更顺。

一个更实际的落地顺序

  1. 先确认是否只需要单向推送
  2. 再决定走 EventSource 还是 fetch + stream
  3. 再补心跳和停止逻辑
  4. 再补 idLast-Event-ID 和恢复策略
  5. 最后再处理代理层和监控

参考来源