「这是我参与11月更文挑战的第24天,活动详情查看:2021最后一次更文挑战」
WebSocket 已经不是什么新鲜的东西了,它可以实现浏览器和服务器实时双向通信,在实时应用场景下有广泛的使用。而本文的主角 SSE(Server-sent Events)解决的问题有所不同,它为普通的 http 服务器提供了向浏览器推送数据的能力,它只能由服务器向浏览器单向推送,因此解决的问题场景有限,只针对不使用 WebSocket 的场景,提供了一种比轮训更优雅的服务器向浏览器推消息的机制,本篇文章从技术实现角度看看 SSE 相关内容。
SSE 使用的也是 http 请求,实现 SSE 服务的关键就是 Content-Type: text/event-stream ,添加这个 header 后服务器数据会以流的形式返回,客户端可以以事件的形式订阅。
先来看服务器对数据格式的要求:
- 数据以文本格式发送,使用 UTF-8 编码
- 每条消息以空行作为分隔符,冒号开头的行是注释,会被忽略
- 每条消息的内容为 字段名:字段值,有 event,id,data,retry 四种字段
看下四种字段的定义:
- event 表示给客户端订阅的事件类型,不带 event 字段的数据使用内置的 message 事件订阅
- id 事件 id
- data 事件内容
- retry 重新连接时间,单位为 ms
以上就是服务器端规范的内容,我们可以简单实现一个 server:
const Koa = require('koa');
const { PassThrough } = require('stream');
const app = new Koa();
app.use(async (ctx) => {
const { url } = ctx;
if (url === '/test') {
ctx.set('Content-Type', 'text/event-stream');
let stream = new PassThrough();
stream.write(`id: 1\n`);
stream.write(`data: msg1\n`);
stream.write('retry: 1000\n');
stream.write('\n\n');
stream.write('event: test\n');
stream.write(`id: 2\n`);
stream.write(`data: msg2\n`);
stream.write('retry: 1000\n');
stream.write('\n\n');
ctx.body = stream;
}
});
app.listen(3030);
这里使用 PassThrough 来实现一个流的效果,我们推送了两条消息,第一条 msg1 不带事件类型,第二条 msg2 事件类型为 test。
注意在代码里两条消息之间的分隔符是 \n\n 因为一个 \n 是换行再多加一个 \n 再换一次行才会添加空行,在代码外面看我们的消息更直观:
id: 1
data: msg1
retry: 1000
event: test
id: 2
retry: 1000
至此服务器已经成功推送了两条消息,接下来看浏览器的实现,在浏览器中,我们使用 EventSource 来创建与 SSE 服务器的连接:
const eventSource = new EventSource('/test');
SSE 的数据会以数据的形式推送过来,之后只需要在 eventSource 上添加事件监听就可以了:
eventSource.addEventListener('open', () => {
// ...
});
eventSource.addEventListener('message', () => {
// ...
});
eventSource.addEventListener('error', () => {
// ...
});
eventSource 对象上面有三个内置的事件:
- open 连接创建时触发
- message 收到不带消息类型的数据时触发
- error 连接出现错误时触发
对于带类型的消息,直接监听消息类型就可以:
eventSource.addEventListener('test', () => {
// ...
});
新建一个 index.html 页面来测试一下:
<script>
const eventSource = new EventSource("/test");
eventSource.addEventListener("open", () => {
console.log("open");
});
eventSource.addEventListener("message", (e) => {
console.log("message", e.data);
});
eventSource.addEventListener("error", () => {
console.log("error");
});
eventSource.addEventListener("test", (e) => {
console.log("test", e.data);
});
</script>
由于 SSE 依赖 http 服务,浏览器有跨域限制,因此我们需要在同源环境加载 index.html 页面内容,这里直接使用上面的 http 服务,添加 static 路由加载静态页面:
const { readFile } = require('fs/promises');
app.use(async (ctx) => {
const { url } = ctx;
// ... sse
if (url === '/static') {
ctx.type = 'html';
ctx.body = await readFile('./index.html');
}
});
app.listen(3030);
之后访问 http://localhost:3030/static 页面,可以看到控制台打印的日志:
可以使用浏览器的 network 工具查看详细信息:
我们可以从服务器持续推送数据,修改上面的测试程序:
let index = 0;
setInterval(() => {
stream.write(`id: ${+new Date()}\n`);
stream.write(`data: ${index++}\n`);
stream.write('retry: 10000\n');
stream.write('\n\n');
}, 2000);
我们可以收到定时推送的数据信息:
服务器事件推送可以解决以往使用轮询处理的问题,可以在没有 WebSocket 的场景下作为服务器推送的方案选择。但是如果想构建实时应用,WebSocket 还是最佳选择。