SSE 是什么 → 服务端四步实现 → 前端 EventSource → 两个大坑 → 最佳实践 → token 鉴权三方案 → 手写自定义客户端 → 第三方库选型 → 本质总结
| 模块 | 内容 | 学习产出 |
|---|---|---|
| 概念 | SSE = 单向 WebSocket;基于 HTTP,资源占用小、开发简单 | 知道什么时候该选 SSE |
| 服务端 | Express 四步:HTTP 服务 / GET 接口 / 响应头 / write 推送 | 能自己写一个 SSE 接口 |
| 前端 | new EventSource + onmessage / onopen / onclose | 三行代码接流 |
| 踩坑 | data: 开头格式;连接别乱断(自动重连机制) | 排掉最常卡住的问题 |
| 最佳实践 | event 协议多事件复用接口;组件卸载时 close | 把接口用到极致 |
| 鉴权 | query / cookie / 自定义请求头三方案 | 生产环境能带 token |
| 进阶 | 手写客户端(fetch + 可读流 + TextDecoder);两大第三方库选型 | 理解 SSE 本质、生产环境选对库 |
最近前端群里很多同学在问 SSE,所以出了这节课。SSE 的中文全称是 Server-Sent Events(服务器推送事件)。
最简单的理解:SSE 就是一个"单向的 WebSocket"——它可以让服务端主动给前端发消息,不需要前端先发请求。
我们平时写请求是"前端请求服务端,服务端再回消息";而 SSE 反过来:服务端可以主动推给前端,前端不需要先发请求。区别就在于它只能服务端 → 前端,是单向的。
| SSE | WebSocket | |
|---|---|---|
| 方向 | 单向(服务端 → 前端) | 双向(双方实时互发) |
| 协议 | HTTP / HTTPS 系列(和普通请求一样) | 专门的 WS 协议 |
| 服务器资源 | 远小于 WebSocket | 需维护 WS 连接池,开销大 |
| 服务端开发复杂度 | 远低于 WebSocket | 需管理连接池、断开、重连 |
很简单:你需要实时通讯,但这个实时通讯只需要后端给前端发消息,不需要前端主动实时发消息——SSE 就是很好的选择。典型场景:
用 Node + Express 开发一个 SSE 服务端,非常简单——前两步和普通接口一模一样,后两步是关键:
text/event-stream(文本事件流),编码为 UTF-8——不设置编码会中文乱码,这一步非常重要;// server.js —— SSE 服务端完整示例
const express = require('express')
const cors = require('cors') // 解决跨域问题
const app = express() // 创建 Express 实例
app.use(cors()) // 使用 CORS 解决跨域
// GET 接口(一定要是 GET,不要 POST)
app.get('/api/sse', (req, res) => {
// ① 设置响应头:文本事件流 + UTF-8 + 长连接
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=UTF-8', // 关键:事件流 + 编码
'Connection': 'keep-alive' // 默认就是 keep-alive,稳妥起见显式设置
})
// ② 通过 write 给前端发消息(每隔 1 秒发一个字)
const words = ['你', '好', 'S', 'S', 'E', '!']
let count = 0
const timer = setInterval(() => {
if (count < words.length) {
res.write(`data: ${words[count]}\n\n`) // 每次 write 前端就收到一个字
count++
} else {
clearInterval(timer) // 发完了清除定时器,但连接保持
}
}, 1000)
// 前端断开连接时
req.on('close', () => {
console.log('客户端断开连接')
clearInterval(timer)
})
})
app.listen(3000, () => console.log('SSE server running at :3000'))
res.end() 结束响应来断开,前端会自动重连。很多情况下我们根本不用 end 断开——断了前端会重连,而且我们希望连接长期保持方便随时发消息。前端使用更简单——EventSource 是现代浏览器自带的对象,window 里直接就有:
// index.html —— 前端三步接流
<script>
// 第一步:创建 EventSource 对象,把接口地址给它,就连上后端了
const eventSource = new EventSource('http://localhost:3000/api/sse')
// 第二步:监听 onmessage —— 后端每发一个字过来就触发(核心)
eventSource.onmessage = function (e) {
console.log('收到:', e.data) // e.data 就是后端发来的内容
}
// 其它事件:
// eventSource.onopen —— 建立连接的那一刻触发,只会触发一次
// eventSource.onerror —— 连接出错时触发(连接关闭时也会触发)
</script>
核心是 onmessage:后端每 write 一次,前端就触发一次,e.data 就是收到的内容。把每次收到的字拼起来,就是流式效果:
// 拼接实现流式打字效果
let message = ''
eventSource.onmessage = function (e) {
message += e.data
document.getElementById('output').textContent = message
}
老师先演示了一个"坑":服务器明明在逐步输出字符串,F12 网络里响应也能看到内容在出来,但前端的 onmessage 就是不触发,控制台什么都不打印。
data: 冒号开头,再是内容,再以 \n\n(斜杠 n 斜杠 n)结尾。只有这种格式才会触发 onmessage。// 错误写法:onmessage 不会触发
res.write('你好') // ❌ 没有 data: 开头,也没有 \n\n 结尾
// 正确写法:onmessage 才会触发
res.write('data: 你好\n\n') // ✅ data: 开头 + \n\n 结尾
所以当你发现:调用了后端的 SSE 接口,F12 网络里能看到响应在逐步输出,但 onmessage 就是不触发——那就大概率是后端返回的格式不对。可以直接去问后端(很多后端也不懂这个格式)。
用 SSE 一定要记住:服务端不能主动断开连接,前端也不要随便调用 close——一旦主动断开,就会导致无法重连,除非前端把页面关了重新打开,否则永远无法再主动发消息。
eventSource.close():连接关闭,除非重新创建 EventSource,否则后端没法再发消息;res.end() 结束响应:会断开,但是——前端会自动重连!// 前端:不要随便调 close
eventSource.close() // ⚠️ 一旦调用,除非重新创建,否则后端再也发不过来
// 后端:res.end() 断开 → 前端会自动重连
res.end() // 断开后前端会自动重连,再发一遍
// 后端:通过状态码彻底断开 → 才不会重连(更具体的服务端细节)
res.writeHead(204) // 204 No Content 等状态码表示"不会再重连"
一个 SSE 接口实际上可以通过 event 协议承载不同的功能——只用一个接口完成很多事,做到最大程度复用。
前面讲了:data: 开头 + 空行结尾的是普通消息,触发 onmessage。还有一种协议——event: 开头,后面跟事件名:
// server.js —— 用 event 协议发送"自定义事件"
// 普通消息(data 开头)→ 触发 onmessage
res.write('data: 你好\n\n')
// 自定义事件(event 冒号开头 + 事件名)→ 触发 addEventListener('事件名')
res.write('event: newMessage\n') // 事件名:newMessage(可以自定义)
res.write('data: 你有一条未读消息\n\n') // 该事件携带的内容
以 event: 开头写的内容不会触发 onmessage,而是触发前端对应的 addEventListener 监听——监听的名字就是 event 后面跟的事件名:
// 前端:监听自定义事件
eventSource.addEventListener('newMessage', function (e) {
console.log('收到新消息:', e.data) // 只有 event: newMessage 的消息走这里
})
// 普通消息仍然走 onmessage
eventSource.onmessage = function (e) {
console.log('普通消息:', e.data)
}
在 F12 的网络面板里看 EventStream,会发现:普通消息的类型是 message,而这条自定义消息的类型是 newMessage——所以它没有走 onmessage,而是走到了 newMessage 监听。
写过 Vue 的同学都知道:定时器、全局监听在组件卸载(onUnmounted)时要去掉。SSE 连接也一样——退出页面、进入另一个页面时,要记得关闭连接,否则连接一直挂着:
// Vue 组件:卸载时关闭 SSE 连接
import { onMounted, onUnmounted } from 'vue'
let eventSource = null
onMounted(() => {
eventSource = new EventSource('http://localhost:3000/api/sse')
eventSource.onmessage = (e) => { /* 处理消息 */ }
})
onUnmounted(() => {
eventSource && eventSource.close() // 记得关掉,避免连接泄漏
})
现实工作中接口往往需要 token 鉴权。但原生 EventSource 有一个致命问题:无法修改请求头!而 token 一般就带在请求头上。怎么办?三种方案:
// 把 token 拼到地址上,后端通过 query 解析
const eventSource = new EventSource(
`http://localhost:3000/api/sse?token=${token}`
)
// 传第二个参数:withCredentials: true,请求就会带上 cookie
const eventSource = new EventSource('http://localhost:3000/api/sse', {
withCredentials: true // 是否携带凭证(cookie)
})
把 token 写入 cookie,设置 withCredentials 后,请求头里就会带上 Cookie,后端从 cookie 里取。但注意:这要求后端走 cookie 鉴权;如果后端走的是 JWT(更常见的 token 鉴权),cookie 可能不好读取。
如果你必须像常规请求一样把 token 放在请求头里,那就要自定义你自己的 SSE 客户端了(见下一节)。另外还有一个兼容性原因:原生 EventSource 完全不支持 IE,为了兼容也常常自定义。
| 方案 | 做法 | 安全性 | 适用 |
|---|---|---|---|
| query 带 token | token 拼在地址上 | ❌ 不安全 | 临时演示 |
| cookie 携带 | withCredentials: true | ✅ 较安全 | 后端走 cookie 鉴权 |
| 请求头携带 | 自定义 SSE 客户端 | ✅ 安全 | 后端走 JWT / 需兼容 IE |
SSE 本质上就是一个 HTTP 请求,只不过它的 Content-Type 是流。既然是 HTTP 请求,就可以用 fetch——fetch 允许我们设置请求头,token 就能带上了:
// mySSE.js —— 手写自定义 SSE 客户端(可带请求头)
class MySSE {
constructor(url, options = {}) {
this.url = url
this.headers = options.headers || {}
this.onmessage = () => {} // 默认空方法,使用者可覆盖
}
async init() {
// ① 用 fetch 请求接口(GET),自定义请求头(可以带 token 了!)
const res = await fetch(this.url, {
method: 'GET',
headers: {
'Content-Type': 'text/event-stream; charset=UTF-8',
...this.headers // 比如 { token: 'xxx' }
}
})
// ② 拿到可读流
const reader = res.body.getReader() // res.body 是 ReadableStream 可读流
const decoder = new TextDecoder() // 文本解码器:把字节转为文本
// ③ 循环读取流
while (true) {
const { done, value } = await reader.read() // done: 是否读完;value: 本次读到的字节
if (done) break
const text = decoder.decode(value, { stream: true }) // Uint8Array → 文本
this._parse(text)
}
}
_parse(text) {
// ④ 自定义协议:比如规定以 "A:" 开头才解析
if (text.startsWith('A:')) {
this.onmessage(text)
}
// 否则不处理——协议完全由你自定义
}
}
export default MySSE
关键点拆解:
reader.read() 循环读取,得到 { done, value }:done 表示是否读取完毕,value 是本次读到的内容;value 是 Uint8Array(字节数组),不是文本,没法直接展示——需要借助 TextDecoder 的 decode 方法转成文本;data: 开头才调用 onmessage,否则不调用。理解了原理就明白了:原生 EventSource 要求 data: 开头,只是它源码里这么规定的。自定义客户端时,协议完全可以自己定——比如规定以 A: 开头才解析:
// 使用自定义 SSE
const sse = new MySSE('http://localhost:3000/api/sse', {
headers: { token: 'my-token' } // ✅ token 带在请求头上了
})
sse.onmessage = function (text) {
console.log('收到:', text) // 只有 A: 开头的内容才会走到这里
}
sse.init()
对应后端改成自定义协议:
// server.js —— 配合自定义协议:以 A: 开头
res.write('A: 你好\n\n') // 自定义协议:A: 开头
手搓比较麻烦,而且自己搓的可能不好用。这么明显的问题肯定有库解决,目前最常用的有两个:event-source-polyfill 和 @microsoft/fetch-event-source(微软官方维护)。
// 安装
npm install event-source-polyfill
// 使用:和原生 EventSource 一模一样,还支持自定义请求头
import { EventSourcePolyfill } from 'event-source-polyfill'
const eventSource = new EventSourcePolyfill('http://localhost:3000/api/sse', {
headers: {
token: 'my-token' // ✅ 允许在请求头携带 token
}
})
eventSource.onmessage = function (e) {
console.log('收到:', e.data)
}
它的使用方式和原生 EventSource 一模一样,什么都不用改——只不过内部用 fetch(或其它请求方式)实现了一遍 SSE。它的核心定位是polyfill(兼容垫片):原生 EventSource 完全不支持 IE,用它可以在旧浏览器里也能跑起来。
这是微软官方维护的 SSE 库(GitHub: Azure/fetch-event-source),完全基于 Fetch API 实现——它把 fetch() 的全部能力都开放给了 SSE:
// 安装
npm install @microsoft/fetch-event-source
// 基本使用:GET + 自定义请求头带 token
import { fetchEventSource } from '@microsoft/fetch-event-source'
const ctrl = new AbortController() // 用 AbortController 控制断开
fetchEventSource('http://localhost:3000/api/sse', {
method: 'GET',
headers: {
'Authorization': 'Bearer my-token' // ✅ 请求头带 token(常规写法)
},
signal: ctrl.signal, // ✅ 可随时主动断开:ctrl.abort()
// 握手阶段:连接建立后先校验状态码 / 响应头
onopen(res) {
if (res.ok && res.headers.get('content-type')?.includes('text/event-stream')) {
console.log('✅ SSE 连接建立成功')
} else {
console.error('❌ 连接异常,状态码:', res.status)
}
},
onmessage(ev) {
console.log('收到:', ev.data) // 与原生 EventSource 兼容,仍是 data: 协议
},
onclose() {
console.log('连接关闭')
},
onerror(err) {
console.error('错误:', err) // 细粒度:可区分 HTTP 错误 / 响应类型异常 / 流中断
},
})
它最大的杀手锏是:可以使用 POST + body——这对 AI 流式对话场景特别关键,因为很多 AI 接口要求 POST 请求、把 prompt 放在 body 里传:
// AI 对话场景:POST + body 传 prompt,流式接收回复
import { fetchEventSource } from '@microsoft/fetch-event-source'
fetchEventSource('https://api.example.com/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer my-token'
},
body: JSON.stringify({ message: '你好,请介绍下自己' }),
onmessage(ev) {
// 逐字/逐句拼接 AI 回复,实现打字机效果
console.log('AI 回复:', ev.data)
},
})
其它实用特性:openWhenHidden: true 可在页面切到后台时保持连接不断;支持自定义 retry 策略和自定义 fetch 实现;TypeScript 类型完整。
| 维度 | event-source-polyfill | @microsoft/fetch-event-source |
|---|---|---|
| 定位 | 兼容垫片(polyfill) | 基于 fetch 的加强版客户端 |
| API 形态 | 与原生 EventSource 一模一样(new EventSourcePolyfill) | 函数式 fetchEventSource(url, options) |
| 自定义请求头 | ✅ 支持(headers 选项) | ✅ 支持(且支持标准 Authorization 写法) |
| 请求方法 / body | ❌ 只能 GET,不能带 body | ✅ 支持 POST / body(AI 接口刚需) |
| 主动断开 | close() | ✅ AbortController,控制更精细 |
| 握手阶段校验 | 一般 | ✅ onopen 中校验状态码、content-type |
| 错误处理 | 较弱(只有 onerror) | ✅ 细粒度:HTTP 错误 / 响应类型异常 / 流关闭 |
| 后台保持连接 | — | ✅ openWhenHidden: true |
| 旧浏览器兼容 | ✅ 强(IE 都能用) | ⚠️ 依赖 fetch,旧浏览器需自行处理 |
| 维护方 | 社区(Yaffle) | ✅ 微软官方(Azure) |
通过这一系列的自定义和使用,可以发现 SSE 的本质:
| 术语 | 定义 |
|---|---|
| SSE | Server-Sent Events,服务器推送事件:基于 HTTP 的单向实时推送技术,服务端可主动给前端发消息 |
| EventSource | 浏览器自带对象,前端接收 SSE 的核心 API(onmessage / onopen / onerror) |
| text/event-stream | SSE 接口的响应头 Content-Type,标识"文本事件流" |
| data: 协议 | SSE 消息的标准格式:以 "data: " 开头、以 "\n\n" 结尾,原生 EventSource 才触发 onmessage |
| event: 协议 | 自定义事件:以 "event: 事件名" 开头,触发 addEventListener(事件名),不触发 onmessage |
| 自动重连 | 服务端 res.end() 断开后前端会自动重连;前端 close() 或服务端状态码断开则不会 |
| 可读流 | ReadableStream,SSE 响应的 body 形态;通过 getReader().read() 循环读取 |
| TextDecoder | 把 Uint8Array 字节数组解码为文本的对象(decode 方法) |
| withCredentials | EventSource 第二个参数,设为 true 时请求携带 cookie |
| event-source-polyfill | SSE 兼容垫片库:API 与原生 EventSource 一致,支持请求头携带 token、兼容 IE 等旧浏览器 |
| @microsoft/fetch-event-source | 微软官方 SSE 库:基于 Fetch API 实现,支持任意请求方法/头/body(可 POST)、AbortController 主动断开、onopen 握手校验与细粒度错误处理 |
// ========== server.js —— SSE 服务端完整代码 ==========
const express = require('express')
const cors = require('cors')
const app = express()
app.use(cors())
// SSE 接口(GET)
app.get('/api/sse', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=UTF-8',
'Connection': 'keep-alive'
})
const words = ['你', '好', 'S', 'S', 'E', '!']
let count = 0
const timer = setInterval(() => {
if (count < words.length) {
res.write(`data: ${words[count]}\n\n`)
count++
} else {
clearInterval(timer)
}
}, 1000)
req.on('close', () => {
console.log('客户端断开连接')
clearInterval(timer)
})
})
// 也可以用 event 协议发送自定义事件
// res.write('event: newMessage\ndata: 你有一条未读消息\n\n')
app.listen(3000, () => console.log('SSE server running at :3000'))
// ========== index.html —— SSE 前端完整代码 ==========
// 1. 基础使用
const eventSource = new EventSource('http://localhost:3000/api/sse')
let message = ''
eventSource.onmessage = function (e) {
message += e.data // 拼接实现流式效果
document.getElementById('output').textContent = message
}
// 2. 监听自定义事件(配合后端 event: 协议)
eventSource.addEventListener('newMessage', function (e) {
console.log('新消息:', e.data)
})
// 3. 组件卸载时关闭(Vue onUnmounted 里)
eventSource.close()
// 4. 携带 cookie(withCredentials)
// const es = new EventSource(url, { withCredentials: true })
// 5. 使用 event-source-polyfill 携带请求头(兼容旧浏览器)
// import { EventSourcePolyfill } from 'event-source-polyfill'
// const es = new EventSourcePolyfill(url, { headers: { token: 'xxx' } })
// 6. 使用 @microsoft/fetch-event-source(现代项目推荐,可 POST)
// import { fetchEventSource } from '@microsoft/fetch-event-source'
// fetchEventSource(url, { headers: { token: 'xxx' }, onmessage(e) { /* ... */ } })
// ========== mySSE.js —— 自定义 SSE 客户端完整代码 ==========
class MySSE {
constructor(url, options = {}) {
this.url = url
this.headers = options.headers || {}
this.onmessage = () => {}
}
async init() {
const res = await fetch(this.url, {
method: 'GET',
headers: {
'Content-Type': 'text/event-stream; charset=UTF-8',
...this.headers
}
})
const reader = res.body.getReader() // 可读流
const decoder = new TextDecoder() // 字节 → 文本
while (true) {
const { done, value } = await reader.read()
if (done) break
const text = decoder.decode(value, { stream: true })
// 自定义协议:以 "A:" 开头才解析
if (text.startsWith('A:')) {
this.onmessage(text)
}
}
}
}
// 使用:
// const sse = new MySSE('http://localhost:3000/api/sse', {
// headers: { token: 'my-token' }
// })
// sse.onmessage = (text) => console.log('收到:', text)
// sse.init()
export default MySSE
// ========== fetch-event-source 完整示例 ==========
// 安装:npm install @microsoft/fetch-event-source
import { fetchEventSource } from '@microsoft/fetch-event-source'
// 场景一:普通 SSE 推送(GET + 请求头带 token + 主动断开)
const ctrl = new AbortController()
fetchEventSource('http://localhost:3000/api/sse', {
method: 'GET',
headers: { 'Authorization': 'Bearer my-token' },
signal: ctrl.signal,
onopen(res) {
if (res.ok) console.log('连接建立成功')
},
onmessage(ev) {
console.log('收到:', ev.data)
},
onerror(err) {
console.error('出错:', err)
},
})
// 某处需要断开连接:
ctrl.abort()
// 场景二:AI 流式对话(POST + body,很多 AI 接口的刚需)
fetchEventSource('https://api.example.com/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': 'Bearer my-token'
},
body: JSON.stringify({ message: '你好' }),
openWhenHidden: true, // 切后台也保持连接
onmessage(ev) {
if (ev.data === '[DONE]') return // 约定结束标记
appendToScreen(ev.data) // 逐字拼接,打字机效果
},
})