SSE
Server-Sent Events(SSE)是一种在客户端和服务端之间实现单向事件流的机制,允许服务端主动向客户端发送事件数据。在 SSE 中,可以使用自定义事件(Custom Events)来发送具有特定类型的事件数据。
webSocket属于全双工通讯,也就是前端可以给后端实时发送,后端也可以给前端实时发送,SSE属于单工通讯,只能服务端实时主动给前端发送消息。
SSE 是 HTML5 中一个与通信相关的 API,主要由两部分组成:服务端与客户端的通信协议(HTTP
协议)及客户端可供 JavaScript 使用的 EventSource
对象。
Server-Sent Events API | WebSockets API |
---|---|
基于 HTTP 协议 | 基于 TCP 协议 |
单工,只能服务端单向发送消息 | 全双工,可以同时发送和接收消息 |
轻量级,使用简单 | 相对复杂 |
内置断线重连和消息追踪的功能 | 不在协议范围内,需手动实现 |
文本或使用 Base64 编码和 gzip 压缩的二进制消息 | 类型广泛 |
支持自定义事件类型 | 不支持自定义事件类型 |
连接数 HTTP/1.1 6 个,HTTP/2 可协商(默认 100) | 连接数无限制 |
服务端实现
协议
SSE 协议非常简单,本质是浏览器发起 http 请求,服务端在收到请求后,返回状态与数据,并附带以下响应头字段:
js
Content-Type: text/event-stream
Cache-Control: no-cache
Connection: keep-alive
-
SSE API规定推送事件流的 MIME 类型为
text/event-stream
。 -
必须指定浏览器不缓存服务端发送的数据,以确保浏览器可以实时显示服务端发送的数据。
-
SSE 是一个一直保持开启的 TCP 连接,所以 Connection 为 keep-alive。
消息格式
EventStream(事件流)为 UTF-8
格式编码的文本
或使用 Base64 编码和 gzip 压缩的二进制消息。
每条消息的字段名有event
、id
、retry
、data
组。以冒号
开头的行为注释行,会被浏览器忽略。
每一次发送的信息,由若干个message
组成,每个message
之间用\n\n
分隔。每个message
内部由若干行组成,每一行都是如下格式。
js
字段名:字段值\n
注意:
- 除上述四个字段外,其他所有字段都会被忽略。
- 如果一行字段中不包含冒号,则整行文本将被视为字段名,字段值为空。
- 注释行可以用来防止链接超时,服务端可以定期向浏览器发送一条消息注释行,以保持连接不断。
js
event: message
data: Hello, world
data: Another message
1、2、4行就是3个数据行,1、2两个数据行是一条消息,4行也是一条消息,这两个消息组成了整个接口返回的文本。 即服务端向客户端推送了两条消息,第一条消息的事件名称为 message,事件数据为 Hello, world;第二条消息没有指定事件名称,事件数据为 Another message。
event字段
事件类型,如果指定了该字段,则在客户端收到该条消息时,会在当前 EventSource 对象上触发一个事件,事件类型就是该字段的字段值。可以使用 addEventListener 方法在当前 EventSource 对象上监听任意类型的命名事件。
如果该条消息没有 event 字段,则会触发 EventSource 对象 onmessage 属性上的事件处理函数。
id字段
事件ID,事件的唯一标识符,客户端会跟踪事件ID,如果发生断连,客户端会把收到的最后一个事件ID放到 HTTP Header Last-Event-Id
中进行重连,作为一种简单的同步机制。
例如,可以在服务端将每次发送的事件ID值自动加 1,当浏览器接收到该事件ID后,下次与服务端建立连接后再请求的 Header 中将同时提交该事件ID,服务端检查该事件ID是否为上次发送的事件ID,如果与上次发送的事件ID不一致则说明客户端存在与服务器连接失败的情况,本次需要同时发送前几次客户端未接收到的数据。
retry字段
重连时间,整数值,单位 ms,如果与服务端的连接丢失,客户端将等待指定时间,然后尝试重新连接。如果该字段不是整数值,会被忽略。
当服务端没有指定客户端的重连时间时,由客户端自行决定每隔多久与服务端建立一次连接(一般为 30s)。
两种情况会导致浏览器重新发起连接:一种是时间间隔到期,二是由于网络错误等原因,导致连接出错
data字段
消息数据,数据内容只能以一个字符串的文本形式进行发送,如果需要发送一个对象时,需要将该对象以一个 JSON 格式的字符串的形式进行发送。在客户端接收到该字符串后,再把它还原为一个 JSON 对象。
如果消息很长,可以分成多行,最后一行用\n\n
结尾,前面行都用\n
结尾。
js
data: begin message\n
data: continue message\n\n
下面是一个发送 JSON 数据的例子
js
data: {\n
data: "foo": "bar",\n
data: "baz", 555\n
data: }\n\n
客户端实现
在浏览器端,可以使用 JavaScript 的 EventSource API 创建 EventSource
对象监听服务器发送的事件。一旦建立连接,服务器就可以使用 HTTP 响应的 'text/event-stream' 内容类型发送事件消息,浏览器则可以通过监听 EventSource 对象的 onmessage
、onopen
和 onerror
事件来处理这些消息。
建立连接
EventSource 接受两个参数:URL 和 options。
url 为 http 事件来源,一旦 EventSource 对象被创建后,浏览器立即开始对该 url 地址发送过来的事件进行监听。url可以与当前网址同域,也可以跨域。跨域时,可以指定第二个参数,打开withCredentials属性,表示是否一起发送 Cookie
options 是一个可选的对象,包含 withCredentials 属性,表示是否发送凭证(cookie、HTTP认证信息等)到服务端,默认为 false。
js
const eventSource = new EventSource('http_api_url', { withCredentials: true })
与 XMLHttpRequest 对象类型,EventSource 对象有一个 readyState 属性值,表明连接的当前状态。该属性只读,可以取以下值:
- 0:相当于常量
EventSource.CONNECTING
,表示连接还未建立,或者断线正在重连。 - 1:相当于常量
EventSource.OPEN
,表示连接已经建立,可以接受数据。 - 2:相当于常量
EventSource.CLOSED
,表示连接已断,且不会重连。
可以使用 EventSource 对象的 close
方法关闭与服务端之间的连接,使客户端不再建立与服务端之间的连接。
js
// 建立连接
const eventSource = new EventSource('http_api_url', { withCredentials: true })
// 关闭连接
eventSource.close()
监听事件
可以使用 addEventListener() 方法来监听事件。EventSource 对象触发的事件主要包括以下三种:
- open 事件:当成功连接到服务端时触发。
- message 事件:当接收到服务器发送的消息时触发。该事件对象的 data 属性包含了服务器发送的消息内容。
- error 事件:当发生错误时触发。该事件对象的 event 属性包含了错误信息。
js
// 初始化 eventSource
const eventSource = new EventSource('http_api_url', { withCredentials: true })
eventSource.addEventListener('open', function(event) {
console.log('Connection opened')
})
eventSource.addEventListener('message', function(event) {
console.log('Received message: ' + event.data);
})
// 监听自定义事件
eventSource.addEventListener('xxx', function(event) {
console.log('Received message: ' + event.data);
})
eventSource.addEventListener('error', function(event) {
console.log('Error occurred: ' + event.event);
})
默认情况下,服务器发来的数据,总是触发浏览器EventSource
实例的message
事件。开发者还可以自定义 SSE 事件,这种情况下,发送回来的数据不会触发message
事件。
js
// 客户端对 SSE 的foo事件进行监听
source.addEventListener('foo', function (event) {
var data = event.data;
// handle message
}, false);
// 服务端
app.get('/sse', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.status(200);
res.write(': 注释行第一条数据\n');
res.write("retry: 10000\n");
// 服务端发送foo事件
res.write("event: foo\n");
res.write('data: {"username": "bobby", "time": "02:33:48"}\n');
})
也可以采用属性监听(onopen
、onmessage
、onerror
)的形式
js
eventSource.onopen = function(event) {
console.log('Connection opened')
}
eventSource.onmessage = function(event) {
console.log('Received message: ' + event.data);
}
eventSource.onerror = function(event) {
console.log('Error occurred: ' + event.event);
})
EventSource
对象的属性监听只能监听预定义的事件类型(open
、message
、error
)。不能用于监听自定义事件类型。如果要实现自定义事件类型的监听,可以使用addEventListener()
方法。
实践
服务端
express 增加响应头text/event-stream
设置推送事件流的 MIME 类型
js
import express from 'express';
// 引入中间件
import loggerMiddleware from './middleware/logger.js'
// express是函数
const app = express();
app.use(loggerMiddleware);
app.use('/assets', express.static('static'));
app.get('/sse', (req, res) => {
res.set({
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
res.status(200);
const url = req.url
// 每隔 1 秒发送一条消息
let id = 0
const intervalId = setInterval(() => {
id++
res.write(`event: customEvent\n`)
res.write(`id: ${id}\n`)
res.write(`retry: 30000\n`)
const params = url.split('?')[1]
const data = { id, time: new Date().toISOString(), params }
res.write(`data: ${JSON.stringify(data)}\n\n`)
if (id > 10) {
clearInterval(intervalId)
res.end()
}
}, 1000)
// 当客户端关闭连接时停止发送消息
req.on('close', () => {
clearInterval(intervalId)
id = 0
res.end()
})
})
app.listen(8089, () => {
console.log("8089端口已启动");
});
客户端
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Demo</title>
</head>
<body>
<h1>SSE Demo</h1>
<button onclick="connectSSE()">建立 SSE 连接</button>
<button onclick="closeSSE()">断开 SSE 连接</button>
<br />
<br />
<div id="message"></div>
<script>
const messageElement = document.getElementById('message')
let eventSource
// 建立 SSE 连接
const connectSSE = () => {
eventSource = new EventSource('/sse?content=xxx')
// 监听消息事件
eventSource.addEventListener('customEvent', (event) => {
const data = JSON.parse(event.data)
messageElement.innerHTML += `${data.id} --- ${data.time} --- params参数:${JSON.stringify(data.params)}` + '<br />'
})
eventSource.onopen = () => {
messageElement.innerHTML += `SSE 连接成功,状态${eventSource.readyState}<br />`
}
eventSource.onerror = () => {
messageElement.innerHTML += `SSE 连接错误,状态${eventSource.readyState}<br />`
}
}
// 断开 SSE 连接
const closeSSE = () => {
eventSource.close()
messageElement.innerHTML += `SSE 连接关闭,状态${eventSource.readyState}<br />`
}
</script>
</body>
</html>
sse在服务端是无法主动关闭连接的,必须由客户端关闭
对此,一种常见的方案就是模拟close事件。可以自定义事件发送消息:
js
event: close
retry: 3
data: bye
客户端只需要监听自定义事件,主动关闭连接。
Fetch实现
fetch配置对象的完整 API:
js
const response = fetch(url, {
method: "GET",
headers: {
"Content-Type": "text/plain;charset=UTF-8"
},
body: undefined,
referrer: "about:client",
referrerPolicy: "no-referrer-when-downgrade",
mode: "cors",
credentials: "same-origin",
cache: "default",
redirect: "follow",
integrity: "",
keepalive: false,
signal: undefined
});
cache
cache属性指定如何处理缓存。可能的取值如下:
default
:默认值,先在缓存里面寻找匹配的请求。no-store
:直接请求远程服务器,并且不更新缓存。reload
:直接请求远程服务器,并且更新缓存。no-cache
:将服务器资源跟本地缓存进行比较,有新的版本才使用服务器资源,否则使用缓存。force-cache
:缓存优先,只有不存在缓存的情况下,才请求远程服务器。only-if-cached
:只检查缓存,如果缓存里面不存在,将返回504错误。
mode
mode属性指定请求的模式。可能的取值如下:
cors
:默认值,允许跨域请求。same-origin
:只允许同源请求。no-cors
:请求方法只限于 GET、POST 和 HEAD,并且只能使用有限的几个简单标头,不能添加跨域的复杂标头,相当于提交表单所能发出的请求。
credentials
credentials属性指定是否发送 Cookie。可能的取值如下:
same-origin
:默认值,同源请求时发送 Cookie,跨域请求时不发送。include
:不管同源请求,还是跨域请求,一律发送 Cookie。omit
:一律不发送。
跨域请求发送 Cookie,需要将
credentials
属性设为include
。
signal
signal属性指定一个 AbortSignal 实例,用于取消fetch()请求。
fetch()请求发送以后,如果中途想要取消,需要使用AbortController对象。
js
let controller = new AbortController();
let signal = controller.signal;
fetch(url, {
signal: controller.signal
});
signal.addEventListener('abort',
() => console.log('abort!')
);
controller.abort(); // 取消
console.log(signal.aborted); // true
首先新建 AbortController 实例,然后发送fetch()请求,配置对象的signal属性必须指定接收 AbortController 实例发送的信号controller.signal。controller.abort()方法用于发出取消信号。这时会触发abort事件,这个事件可以监听,也可以通过controller.signal.aborted属性判断取消信号是否已经发出。
js
let controller = new AbortController();
setTimeout(() => controller.abort(), 1000);
try {
let response = await fetch('/long-operation', {
signal: controller.signal
});
} catch(err) {
if (err.name == 'AbortError') {
console.log('Aborted!');
} else {
throw err;
}
}
以上是一个1秒后自动取消请求的例子。
keepalive
keepalive属性用于页面卸载时,告诉浏览器在后台保持连接,继续发送数据。
一个典型的场景就是,用户离开网页时,脚本需要向服务端提交一些用户行为的统计信息。这时,如果不用keepalive属性,数据可能无法发送,因为浏览器已经把页面卸载了。
js
window.onunload = function() {
fetch('/analytics', {
method: 'POST',
body: "statistics",
keepalive: true
});
};
redirect
redirect属性指定 HTTP 跳转的处理方法。可能的取值如下:
follow
:默认值,fetch()
跟随 HTTP 跳转。error
:如果发生跳转,fetch()
就报错。manual
:fetch()
不跟随 HTTP 跳转,但是response.url
属性会指向新的 URL,response.redirected
属性会变为true
,由开发者自己决定后续如何处理跳转。
integrity
integrity
属性指定一个哈希值,用于检查 HTTP 回应传回的数据是否等于这个预先设定的哈希值。
比如,下载文件时,检查文件的 SHA-256 哈希值是否相符,确保没有被篡改。
js
fetch('http://site.com/file', {
integrity: 'sha256-abcdef'
});
referrer
referrer
属性用于设定fetch()
请求的referer
标头。
这个属性可以为任意字符串,也可以设为空字符串(即不发送referer
标头)。
js
fetch('/page', {
referrer: ''
});
referrerPolicy
referrerPolicy属性用于设定Referer标头的规则。可能的取值如下:
no-referrer-when-downgrade
:默认值,总是发送Referer
标头,除非从 HTTPS 页面请求 HTTP 资源时不发送。no-referrer
:不发送Referer
标头。origin
:Referer
标头只包含域名,不包含完整的路径。origin-when-cross-origin
:同源请求Referer
标头包含完整的路径,跨域请求只包含域名。same-origin
:跨域请求不发送Referer
,同源请求发送。strict-origin
:Referer
标头只包含域名,HTTPS 页面请求 HTTP 资源时不发送Referer
标头。strict-origin-when-cross-origin
:同源请求时Referer
标头包含完整路径,跨域请求时只包含域名,HTTPS 页面请求 HTTP 资源时不发送该标头。unsafe-url
:不管什么情况,总是发送Referer
标头。
Response.body 属性
Response.body
属性是 Response 对象暴露出的底层接口,返回一个 ReadableStream 对象,供用户操作。
它可以用来分块读取内容,应用之一就是显示下载的进度。
js
const response = await fetch('flower.jpg');
const reader = response.body.getReader();
while(true) {
const {done, value} = await reader.read();
if (done) {
break;
}
console.log(`Received ${value.length} bytes`)
}
response.body.getReader()
方法返回一个遍历器。这个遍历器的read()
方法每次返回一个对象,表示本次读取的内容块。这个对象的done
属性是一个布尔值,用来判断有没有读完;value
属性是一个 arrayBuffer 数组,表示内容块的内容,而value.length
属性是当前块的大小。
使用fetch方式进行sse流式处理
使用浏览器提供的EventSource API可以快速简单的实现SSE连接,但是也有一定的限制:
- 不能自定义请求头;
- 不能使用POST(默认为GET),所以如果传参太长的话可能超过浏览器的长度限制;
fetch请求的缺点:
-
需要手动解析数据:使用 fetch() 方法需要手动解析从 SSE 服务器端点接收的数据流,这需要一些额外的代码和技术。
-
无法自动重连:使用 fetch() 方法无法自动重连 SSE 服务器端点。如果与 SSE 服务器端点的连接断开,我们需要手动重新连接。
-
无法处理错误:使用 fetch() 方法无法处理 SSE 数据流的错误。如果发生错误,我们需要手动处理并调试代码。
插件:@microsoft/fetch-event-source,可以使用post请求,也可以自定义请求头。
插件API
参数名 | 描述 |
---|---|
headers | 仅支持字符串键值对 { "xx": "xx"} |
onopen | 成功链接时回调 |
onmessage | 服务器返回消息回调 返回{ data,event,id,retry } ,data即服务器返回数据 |
onclose | 响应完成回调 |
onerror | 发生任何错误的回调,并会自动在1s后重新尝试目前测试函数体内throw err;会阻断重试 |
openWhenHidden | 页面文档被隐藏时会关闭sse链接并在显示时重连,true无论隐藏与否都不关闭 |
fetch | 默认 window.fetch |
插件使用
@microsoft/fetch-event-source
该插件没有介绍在项目中使用script
脚本标签引入,只介绍了通过npm 引入的方式。 可以借助 webpack 工具来解决。
第一步初始化包管理文件:
js
npm install webpack --save-dev
npm install webpack-cli --save-dev
第二步项目根目录新建 webpack.config.js 文件:
js
import path from 'node:path'
export default {
entry: "./test.js", //打包入口
output: {
//打包文件输出路径
path: path.resolve(process.cwd(), "dist"),
filename: "index.js",
},
};
代码中 test.js 文件是使用了 @microsoft/fetch-event-source,打包入口文件可以根据自己项目文件路径填写
第三步打包文件:在项目终端 执行 npx webpack
js
npx webpack
第四步把打包生成的文件(index.js)在项目中引入
html
<script src="./index.js"></script>
客户端index.html代码:
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Demo</title>
</head>
<body>
<h1>SSE Demo</h1>
<button id="connectSSE">建立 SSE 连接</button>
<button id="closeSSE">断开 SSE 连接</button>
<br />
<br />
<div id="message"></div>
</body>
<script src="./index.js"></script>
</html>
test.js代码:使用@microsoft/fetch-event-source
插件
js
import { fetchEventSource } from '@microsoft/fetch-event-source'
const messageElement = document.getElementById('message')
const connectElement = document.getElementById('connectSSE')
const closeElement = document.getElementById('closeSSE')
let controller = new AbortController();
// 建立 SSE 连接
const connectSSE = async () => {
fetchEventSource('http://localhost:8089/fetch-sse', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content:'i am gloria'
}),
signal: controller.signal,
openWhenHidden: true,
async onopen(response) {
console.log('onopen', response);
messageElement.innerHTML += `FETCH 连接成功<br />`;
},
onmessage(msg) {
console.log('fetchEventSource:', msg);
const value = JSON.parse(msg.data);
messageElement.innerHTML += `id:${value.id}--time:${value.time}--body参数:${JSON.stringify(value.data)}<br />`
},
onclose() {
console.log('onclose');
messageElement.innerHTML += `FETCH 连接关闭<br />`;
},
onerror(err) {
console.log('onerror', err);
controller.abort();
throw err;
}
});
}
connectElement.onclick = connectSSE;
// 断开 SSE 连接
const closeSSE = () => {
if (controller) {
controller.abort();
// 中断请求无法再次请求,所以再次创建对象
controller = new AbortController();
messageElement.innerHTML += `FETCH 连接关闭<br />`;
}
}
closeElement.onclick = closeSSE;
服务端代码app.js:
js
import express from 'express';
// 引入中间件
import loggerMiddleware from './middleware/logger.js'
// express是函数
const app = express();
app.use(express.json());
app.use(loggerMiddleware);
app.use('/assets', express.static('static'));
app.post('/fetch-sse', (req, res) => {
let body = req.body;
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});
let count = 0;
// 每隔 1 秒发送一条消息
let timer = setInterval(() => {
const data = { id: count, time: new Date().toISOString, data: body }
res.write(`data: ${JSON.stringify(data)}\n\n`);
count++;
if (count >= 5) {
clearInterval(timer);
res.end();
}
}, 1000);
})
app.listen(8089, () => {
console.log("8089端口已启动");
});
效果呈现: