SSE服务端单向推送
12.1 SSE(Server-Sent Events)
一、一句话
服务端单向推送,基于普通 HTTP,浏览器原生支持,自带自动重连。
客户端 ──GET /api/stream──→ 服务端
客户端 ←──持续不断的文本── 服务端(一条连接,长期不断)
- 方向:只能服务端 → 客户端(单向)
- 协议:
Content-Type: text/event-stream - API:
new EventSource(url)(浏览器原生,零依赖)
二、四种"实时"方案对比(先建立坐标)
| 方案 | 方向 | 协议 | 自动重连 | 二进制 | 自定义 Header |
|---|---|---|---|---|---|
| 短轮询 | 客户端拉 | HTTP | ❌(自己写) | ✅ | ✅ |
| 长轮询 | 客户端拉 | HTTP | ❌(自己写) | ✅ | ✅ |
| SSE ⭐ | 服务端推 | HTTP | ✅ 内置 | ❌(要 base64) | ❌ |
| WebSocket | 双向 | WS | ❌(自己写) | ✅ | ❌(握手阶段受限) |
选型口诀:
只要服务端推 → SSE;要双向(聊天/协作)→ WebSocket;老浏览器/复杂鉴权 → 长轮询。
三、协议格式(很简单)
服务端返回的内容长这样:
data: 第一条消息\n\n
data: 第二条消息\n\n
event: notice\n
data: {"msg":"自定义事件"}\n\n
id: 42\n
data: 带ID的消息\n\n
retry: 5000\n\n
: 这是注释行,用来做心跳\n\n
| 字段 | 作用 |
|---|---|
data: |
消息内容(必须有) |
event: |
自定义事件名(默认 message) |
id: |
事件 ID → 断线后浏览器自动带 Last-Event-ID |
retry: |
告诉浏览器重连间隔(毫秒) |
: 开头 |
注释,不触发事件 → 用来做心跳 |
\n\n |
两个换行 = 一个事件结束(协议的关键) |
多行
data:会被合并,用\n连接(可以用来传 JSON 的多行结构)。
四、浏览器 API
const es = new EventSource('/api/stream')
// ① 默认事件(服务端不写 event: 时)
es.onmessage = (e) => {
console.log(e.data) // 字符串
}
// ② 自定义事件
es.addEventListener('notice', (e) => {
console.log(JSON.parse(e.data))
})
// ③ 生命周期
es.onopen = () => console.log('连接建立')
es.onerror = (e) => {
console.log('出错 / 断开', es.readyState)
}
// ④ 状态:0 连接中 / 1 已连接 / 2 已关闭
es.readyState
// ⑤ 关闭(⚠️ 不关会一直重连 + 内存泄漏)
es.close()
五、⭐ 两个"白送"的能力
① 自动重连(内置,不用写一行代码)
连接断开后,浏览器自动重连(默认间隔约 3s):
断开 → 等 3s → 重新 GET → 成功继续
服务端可以用 retry: 5000 改这个间隔。
对比 WebSocket:重连要自己写心跳 + 退避(这是 SSE 最大的优势)。
② 断线补偿(Last-Event-ID)
服务端发了 id: 42,连接断了 → 浏览器重连时会自动带上请求头:
Last-Event-ID: 42
服务端据此判断"客户端收到 42 了,从 43 开始补发" → 消息不丢。
// Node/Express 服务端
app.get('/stream', (req, res) => {
const lastId = Number(req.headers['last-event-id'] ?? 0)
// 从 lastId + 1 开始补发
})
六、⚠️ 三个硬限制(决定你用不用它)
| 限制 | 后果 | 绕过方式 |
|---|---|---|
| 不能自定义请求头 | token 放不进 Authorization |
① URL 参数 ?token=xxx ② Cookie(推荐) ③ 改用 fetch + ReadableStream(12.3) |
| 只能 GET | 参数只能放 URL | 复杂参数 → 先 POST 创建任务拿到 id,再 SSE 订阅 |
| 只支持 UTF-8 文本 | 二进制不行 | 先 base64 编码 |
还有:HTTP/1.1 下同域名最多 6 个连接,开太多 SSE 会堵死其他请求(HTTP/2 无此问题)。
七、Vue 封装 useSSE
// src/composables/useSSE.ts
import { onScopeDispose, ref, shallowRef } from 'vue'
type Status = 'connecting' | 'open' | 'closed'
export function useSSE(
url: string,
options: {
/** 自定义事件名 → 处理函数 */
events?: Record<string, (data: any) => void>
/** 默认 message 事件 */
onMessage?: (data: string) => void
onError?: () => void
/** 收到 JSON 自动 parse */
json?: boolean
} = {},
) {
const status = ref<Status>('connecting')
const lastData = ref<string>('')
const es = shallowRef<EventSource | null>(null)
function parse(raw: string) {
if (!options.json) return raw
try { return JSON.parse(raw) } catch { return raw }
}
function connect() {
close()
const source = new EventSource(url)
es.value = source
status.value = 'connecting'
source.onopen = () => { status.value = 'open' }
source.onmessage = (e) => {
lastData.value = e.data
options.onMessage?.(parse(e.data))
}
// 自定义事件
for (const [name, handler] of Object.entries(options.events ?? {})) {
source.addEventListener(name, (e: MessageEvent) => handler(parse(e.data)))
}
source.onerror = () => {
// ⚠️ readyState === 2 才是真关闭;0 表示正在自动重连,别急着报错
if (source.readyState === EventSource.CLOSED) {
status.value = 'closed'
}
options.onError?.()
}
}
function close() {
es.value?.close()
es.value = null
status.value = 'closed'
}
connect()
// ⭐ 组件卸载自动关闭,否则一直重连 + 内存泄漏
onScopeDispose(close)
return { status, lastData, close, reconnect: connect }
}
用法:
<script setup lang="ts">
const { status, close } = useSSE('/api/report/progress', {
json: true,
events: {
progress: d => { percent.value = d.percent },
done: d => { message.success('生成完成'); close() },
},
})
</script>
<template>
<a-progress v-if="status === 'open'" :percent="percent" />
<a-tag v-else color="red">连接已断开</a-tag>
</template>
八、服务端示例(Node / Express)
app.get('/api/report/progress', (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')
// ⭐ Nginx 必须关缓冲,否则消息会被攒着不发
res.setHeader('X-Accel-Buffering', 'no')
res.flushHeaders()
let percent = 0
const timer = setInterval(() => {
percent += 10
// 自定义事件 + JSON
res.write(`event: progress\n`)
res.write(`data: ${JSON.stringify({ percent })}\n\n`)
if (percent >= 100) {
res.write(`event: done\ndata: {"url":"/files/a.xlsx"}\n\n`)
clearInterval(timer)
res.end()
}
}, 1000)
// ⭐ 心跳:防止代理/负载均衡掐掉空闲连接
const heartbeat = setInterval(() => res.write(': ping\n\n'), 15_000)
req.on('close', () => {
clearInterval(timer)
clearInterval(heartbeat)
})
})
Nginx 配置(不加会失效):
location /api/stream {
proxy_pass http://backend;
proxy_buffering off; # ⭐ 关键:不缓冲
proxy_cache off;
proxy_read_timeout 3600s; # 长连接不要被掐
chunked_transfer_encoding on;
}
九、应用场景
| 场景 | 为什么适合 SSE |
|---|---|
| AI 流式输出(ChatGPT 早期方案) | 单向、文本、自动重连 |
| 导出/生成进度 | 服务端推百分比 |
| 实时通知 / 站内消息 | 单向推送足够 |
| 股价 / 监控大盘 | 高频单向 |
日志流(docker logs -f 网页版) |
持续追加文本 |
⚠️ 双向交互(IM、协同编辑、游戏)必须 WebSocket。
十、常见坑清单
| 坑 | 现象 | 解决 |
|---|---|---|
| 代理缓冲 | 消息攒着一起到 / 延迟 | X-Accel-Buffering: no + proxy_buffering off |
| 空闲被掐 | 几分钟后断开不再重连 | 服务端每 15s 发 : ping\n\n |
没 close() |
组件卸载后仍在重连 | onScopeDispose(close) |
onerror 里误判 |
每次重连都弹"连接失败" | 判断 readyState === 2 才提示 |
| 响应压缩 | gzip 让流缓冲 | Content-Encoding: none 或关掉该路径压缩 |
| 浏览器 6 连接上限 | 页面其他请求卡住 | 用 HTTP/2,或合并成 1 条 SSE 多路复用 |
十一、自检题
- SSE 和 WebSocket 最本质的区别是什么?什么场景下"其实用 SSE 就够了"?
EventSource不能自定义请求头,那登录 token 怎么传?哪种方式最安全?为什么 URL 传 token 不好?- 服务端发
id: 42有什么用?断线重连后浏览器会做什么?服务端要怎么配合? - 为什么服务端要定时发
: ping\n\n?Nginx 上还要配什么? onerror触发时,为什么不能立刻提示「连接已断开」?要看什么字段?
答案
- 本质区别:方向(SSE 单向服务端→客户端,WS 双向)+ 协议(SSE 是 HTTP,WS 是独立协议)。很多"实时"需求其实只是服务端推(通知、进度、AI 输出),用 SSE 就够了,还能白嫖自动重连。
- ① URL 参数
?token=xxx(不推荐:token 会进浏览器历史、服务器 access log、Referer);② Cookie(推荐,加HttpOnly+Secure);③ 换fetch+ReadableStream(12.3 会讲,可自定义 header,但要自己实现重连)。 id: 42让浏览器记住"最后收到的事件 ID"。断线重连时浏览器自动带请求头Last-Event-ID: 42,服务端据此从 43 开始补发断线期间的消息,实现不丢消息。- 空闲连接会被代理 / 负载均衡 / NAT 当作死连接掐掉。发
: ping\n\n(注释行,不触发事件)保持活跃。Nginx 还要配proxy_buffering off和proxy_read_timeout,否则消息会被缓冲、连接会被超时断开。 onerror在自动重连期间也会触发(此时readyState === 0 CONNECTING,浏览器正在重试)。只有readyState === 2 (CLOSED)才是真的关闭了、不再重连。误判会导致每次重连都弹错误提示。

浙公网安备 33010602011771号