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 多路复用

十一、自检题

  1. SSE 和 WebSocket 最本质的区别是什么?什么场景下"其实用 SSE 就够了"?
  2. EventSource 不能自定义请求头,那登录 token 怎么传?哪种方式最安全?为什么 URL 传 token 不好?
  3. 服务端发 id: 42 有什么用?断线重连后浏览器会做什么?服务端要怎么配合?
  4. 为什么服务端要定时发 : ping\n\n?Nginx 上还要配什么?
  5. onerror 触发时,为什么不能立刻提示「连接已断开」?要看什么字段?
答案
  1. 本质区别:方向(SSE 单向服务端→客户端,WS 双向)+ 协议(SSE 是 HTTP,WS 是独立协议)。很多"实时"需求其实只是服务端推(通知、进度、AI 输出),用 SSE 就够了,还能白嫖自动重连。
  2. ① URL 参数 ?token=xxx(不推荐:token 会进浏览器历史、服务器 access log、Referer);② Cookie(推荐,加 HttpOnly + Secure);③ 换 fetch + ReadableStream(12.3 会讲,可自定义 header,但要自己实现重连)。
  3. id: 42 让浏览器记住"最后收到的事件 ID"。断线重连时浏览器自动带请求头 Last-Event-ID: 42,服务端据此从 43 开始补发断线期间的消息,实现不丢消息。
  4. 空闲连接会被代理 / 负载均衡 / NAT 当作死连接掐掉。发 : ping\n\n(注释行,不触发事件)保持活跃。Nginx 还要配 proxy_buffering off 和 proxy_read_timeout,否则消息会被缓冲、连接会被超时断开。
  5. onerror 在自动重连期间也会触发(此时 readyState === 0 CONNECTING,浏览器正在重试)。只有 readyState === 2 (CLOSED) 才是真的关闭了、不再重连。误判会导致每次重连都弹错误提示。
posted @ 2026-10-16 17:47  寅信  阅读(18)  评论(0)    收藏  举报