但行好事 莫问前程
前言🎀
在某些场景前端需要 持续的接收后端的数据更新,这通常使用两种方案:
- 客户端拉取 ------ 以一定间隔向服务器请求更新;代表:长轮询,短轮询
- 服务端推送 ------ 服务端主动将更新推送到客户端;代表:WebSocket,SSE
其中 SSE 与 WebSocket 一样同为 HTML5 新特性,但它却不那么广为人知,但实际上它早已被成熟的运用在服务端单向通信的场景中。
本文,我们一起学习 便捷、高效的 服务端推送技术 :SSE(Server-Sent Events),了解它的相关知识与应用,并简单实现 GPT 的交互效果(已放至结语中)。
简介
SSE(Server-Sent Events),即服务器发送事件,是一种基于 HTTP 协议、用于服务端向客户端推送实时数据的技术。
与 WebSocket 不同的是,SSE 是单向的,数据消息只能从服务端到发送到客户端(如用户浏览器),会占用 HTTP 连接数。
在不需要请求服务端的情况下,相对于繁重的 ws,SSE 无疑是一种简单、高效的轻量级代替方案。
例如 GPT 使用 SSE 一边计算一边将回答内容推送给前端,避免用户等待时间过长
规范&定义
SSE 的实现借助了 http协议支持分块传输 的特性,简单来说:
- 客户端与服务端之间建立一个
keep-alive
长连接 - 服务端会响应数据类型为
text/event-stream
的事件流 - 响应内容会被分割为多个块
(chunks)
进行传输
HTTP
Cache-Control: no-cache
Connection: keep-alive
Content-Type: text/event-stream
Transfer-Encoding: chunked
Transfer-Encoding: chunked
表示响应内容是分块传输的,前端使用EventSource
发送的请求通过 devtools 能直观的查看数据的传输过程。
Content-Type: text/event-stream
表示响应内容的类型是 事件流,是实现 SSE 的核心,前端需要持续读取事件流数据进行解析。
- EventStream
EventStream(事件流)是一种连续的数据流,由一系列事件组成。
每个事件由下列的一行或多行字段组成,每行字段以 \n
结尾:
- event:事件名称,可选项。
- data:事件数据,必选,可以是任意格式的文本或JSON数据。
- id:事件ID,可选项,用于标识事件,断线重连后可以它为依据(Last-Event-ID)恢复传输。
- retry:重新连接时间间隔,可选项,用于指定客户端重新连接的时间间隔。
不同事件的内容之间通过约定的边界,一般为空行(\n\n
)来分隔。
举个例子:
vbnet
event: time\n
data: 2023-11-28 10:00:00\n\n
event: message\n
data: Hello, world!\n\n
event: custom-event\n
id: 12345\n
data: Custom event\n
data: data1\n
data: data2\n\n
...
最终数据传输的格式为 UTF-8格式编码的文本 或 使用 Base64 编码 和 gzip 压缩的二进制消息。
实现
服务端
服务端实现 SSE,需要遵循以下规范:
- 首先,设置 SSE 相关的响应头(事件流、长连接、chunk传输-事件流自带、禁用缓存)
- 其次,将数据封装为事件(event)按照一定格式发送给客户端
- 然后,设置适当的延迟和缓冲 控制发送时机(适用于动态生成内容和大数据传输)
- 最后,在合适的时候断开客户端连接
js
const express = require("express");
const app = express();
const port = 3001;
//允许跨域
app.all("*", function (req, res, next) {
res.setHeader("Access-Control-Allow-Origin", "*");
next();
});
app.get("/sse", (req, res) => {
const str =
"Server-Sent Events是一种用于实现服务器向客户端实时推送数据的技术。它基于HTTP协议,使用长连接来保持服务器与客户端之间的通信。与传统的轮询或基于WebSocket的实时通信相比,SSE具有简单易用、轻量级的特点。";
// 设置 SSE 相关的响应头
res.setHeader("Content-Type", "text/event-stream;charset=utf-8");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
let index = 0;
// 封装事件 & 控制发送频率
const timer = setInterval(() => {
if (index < str.length) {
// 每次仅推送一个字符
const data = `data: ${JSON.stringify(str[index])}\n`;
// 此处省略了event id 等属性 . . .
// const chunk = `${event}${id}${data}\n`;
const chunk = `${data}\n`
// 分割为块进行推送
res.write(chunk);
index++;
} else {
clearInterval(timer);
// 断开连接
res.end();
}
}, 30);
// 监听客户端断开连接事件
req.on('close', () => {
console.log('Client closed connection.');
res.end();
});
});
app.listen(port, () => {
console.log(`Example app listening on port ${port}`);
});
注:尽可能的遵循事件的规范
chunk: { event, id, data, retry }
,data
为必选项
遵守事件流规范的响应,可以通过 postman 很直观的看到它的响应内容:
客户端
通过浏览器内置的 API - EventSource
,我们可以便捷的进行SSE通信,但EventSource
存在不少缺陷,我更推荐灵活性强、GPT同样在使用的方案:fetch
。
fetch
方案需要手动解析接收到的数据流:
- 获取可读流(ReadableStream)对象
res.body
- 获取一个读取器(reader)对象
res.body.getReader()
- 通过
reader.read()
方法,异步读取响应体的内容(ReadableStreamDefaultReader) - 将数据流转换为UTF-8字符串
- 根据事件流的规范 以
\n
为根据 拆分字段数据 - 持续读取数据流,等待服务端断开或者手动关闭
js
fetch(url, { headers })
.then((res) => {
if (res.status === 200) {
console.log("status 200");
// 获取可读流(ReadableStream)对象
return res.body;
}
})
.then((rb) => {
// 获取一个读取器(reader)对象,提供read()方法 可以通过cancel()关闭
const reader = rb?.getReader();
let buffer = "";
// read()方法,可以异步读取响应体的内容(ReadableStreamDefaultReader)
reader?.read().then(function process({ done, value }) {
// done: boolean 数据流是否接收完成,value: Uint8Array 返回数据
if (done) {
console.log("status done");
return;
}
// 将数据流转换为UTF-8字符串
const message = new TextDecoder("utf-8").decode(value);
buffer += message;
const lines = buffer.split("\n");
buffer = lines.pop() || '';
lines.forEach((line) => {
console.log(line);
});
// 开始读取流信息
return reader?.read().then(process);
});
})
.catch((e) => {
console.log("status error");
});
fetch
并未原生提供终止操作方法,可以通过 AbortController 和AbortSignal
实现请求中断
js
let controller;
controller = new AbortController();
fetch(url, { signal: controller.signal, headers })
// 断开 fetch-SSE 连接
const closeSSE = () => {
if (controller) {
controller.abort();
controller = undefined;
outputElement.innerHTML += `close connection<br />`;
}
};
EventSource
注:在前端中 使用SSE 不等于 一定使用了EventSource
浏览器内置了与服务器发送事件通信的接口 EventSource
:
ts
const source = new EventSource(url, { withCredentials: boolean = false });
source.onmessage = (e) => {
console.log('message: ', { ...JSON.parse(e.data) });
};
source.onopen = (e) => {
console.log('connection open');
};
source.onerror = (e) => {
console.log('error message: ' + e.event);
};
// 监听自定义事件
source.addEventListener('eventA', (e) {
console.log('eventA message: ' + e.data);
})
优点:接口中定义了与服务器连接、接收事件/数据、处理错误、关闭连接等功能的特性。它真的很方便!但...
缺点:只能使用GET请求,不能自定义 HTTP 请求头,鉴权等操作只能通过传输同源下的 Cookie。这也是我不推荐它的原因~
特性 | EventSource | fetch API |
---|---|---|
兼容性 | 广泛支持,包括Internet Explorer 8及更高版本 | 在较新的浏览器中得到支持,不完全支持Internet Explorer |
数据格式 | 只支持服务器发送的文本数据,自动转换为文本 | 可以获取包括文本、JSON、Blob等在内的各种数据格式 |
错误处理 | 自动尝试重新连接,可以监听'error'事件来处理错误 | 没有内置的重试机制,需要手动处理错误并可能需要进行重试 |
流式处理 | 支持简单处理服务器发送的流式数据 | 不直接支持流式处理,但可以使用Response对象的body属性获取流式接口 |
CORS问题 | 受同源策略限制,除非服务器配置了适当的CORS头,否则无法跨源加载 | 不受同源策略限制,可以跨源请求数据,但需要服务器配置适当的CORS头 |
灵活性 | 只能发送GET请求,拼接字符串传参 | 可以发起任意类型请求。传参灵活 |
总结
长/短轮询 适用于实时性要求不高的应用 并且浪费服务器资源。
WebSocket
适合复杂的双向通信,而 SSE
适合简单的服务器推送场景。
SSE
通信的数据类型为事件流 ,每个事件由以下字段组成{ event?, id?, data, retry? }
。
服务端实现:设置对应的响应头,封装数据为事件,控制发送频率,合理断开连接
客户端实现 :EventSource
使用简单 但不够灵活,fetch
使用灵活 但需要手动解析数据
使用 SSE 之前要判断是否适合业务场景,并且使用中要注意连接断开的时机 以及错误处理
更多
nginx 可能不会自动压缩 text/event-stream
类型传输的数据。
nginx
http{
# 开启压缩机制
gzip on;
# 指定会被压缩的MIME类型
gzip_types text/plain application/javascript text/css application/xml text/javascript image/jpeg image/gif image/png;
# 省略后续...
}
至少后端是这么跟我说的😫,我自己也调查了一下:
-
根据 nginx官方文档,语法: gzip_types mime-type [mime-type ...] ,nginx-gzip 匹配 MIME类型 进行压缩
-
翻阅 IANA官方注册表,还真找不到
text/event-stream
. . . -
再查询 HTTP官方 对
Content-Type
的定义 也是
- 尝试自定义MIME Type
gzip_types: text/event-stream;
数据是被压缩了,但是响应内容却是一次性过来的
事件流的 MIME Type 没有官方定义?nginx能做到同时分块的压缩、传输吗?希望有大佬替我解惑
在数据量大的情况下,不经过压缩的数据会十分占用带宽,几乎相同的数据量,压缩前后差距极大
目前解决方法为:后端传输数据时手动对 event.data
进行gzip压缩,前端在解码后用pako
库对数据进行解压使。
对比一下,优化效果比较明显:
结语🎉
demo源码:github.com/XIwE1/sse-g...
不要光看不实践哦,希望本文能对你有所帮助。
持续更新前端知识,脚踏实地不水文,真的不关注一下吗~
写作不易,如果有收获还望 点赞+收藏 🌹
才疏学浅,如有问题或建议还望指教!