如果需要实现一个站点的消息通知功能,例如通知用户收到私信、站内信等信息,像这样的:
前端实现上,你第一时间的思路是什么呢?
定时器轮询
最简单的做法就是写个定时器,在固定的时间间隔内去请求消息通知接口:
js
setInterval(() => {
getMessageCount().then((data) => {
updateCount(data);
});
}, 2000)
一个简单的轮询,每2秒就会去请求一次接口,然后更新数量。这个方法足够简单,但是缺点也很明显,消息的产生不是定时的,可能会出现大多数时间的请求都数量都没有变化,或者是2秒的间隔内产生大量的消息会出现更新不及时的情况,而且这样的定时轮询对服务端的压力也很大,会浪费掉大量的服务端资源。另外,定时请求,会导致不断地建立链接或者一直保持Http链接,浪费客户端的资源。
Websoket
对比起前面的定时器,Websocket的明显是一个更高级的做法。为了避免定时器遇到的问题,使用Websocket建立一个双端的长链接,仅需要做一次握手,两边就可以一直保持通讯。
服务端代码
js
const webSocket = require('ws');
const WebSocketServer = webSocket.Server;
const wsServer = new WebSocketServer({port: 8082}); //创建服务端链接,定义端口号
//链接
wsServer.on('connection', function (ws) {
console.log('client connected');
let enventId = 0;
let intervalId;
setInterval(() => {
const message = {
id: enventId,
data: `这是第${enventId}条消息`
};
ws.send(JSON.stringify(message));
enventId++;
}, 1000);
ws.on('close', function() {
clearInterval(intervalId);
});
});
客户端代码
html
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>WebSocket Client</title>
</head>
<body>
<ul id="message-list"></ul>
<script>
const wsServer = 'ws://localhost:8082';
const webSocket = new WebSocket(wsServer); //创建WebSocket对象
webSocket.onopen = function (event) {
//已经建立连接
console.log("已经建立连接");
};
webSocket.onclose = function (event) {
//已经关闭连接
console.log("已经关闭连接");
};
webSocket.onmessage = function (event) {
//收到服务器消息,使用event.data提取
event.stopPropagation();
const data = JSON.parse( event.data);
const eventList = document.getElementById("message-list");
const eventItem = document.createElement("li");
eventItem.innerHTML = "message: " + data.data;
eventList.appendChild(eventItem);
};
webSocket.onerror = function (event) {
//异常
console.error(event.message);
};
</script>
</body>
</html>
Server-Sent Events(SSE)
SSE是一种服务端单向推送的技术,让服务端与客户端可以通过http协议建立链接并持续传输数据流。注意这里面发送的不是一次性的数据包,而是一个数据流。这个数据流会连续传输,因此客户端不会关闭这个链接,会一直等待到服务端的数据传输。相当于就是一个请求时间超长的链接。
相对于Websocket,SSE会显得更加轻量:
- SSE是基于HTTP协议,现有的服务器基本都支持。而Websocket是独立的协议,需要额外支持
- SSE是单向的,比双向的Websocket更简单
- SSE支持断线重连,Webscoket需要自己实现
当然SSE也有自己的问题:
- 只支持文本数据,不支持二进制数据
- 受浏览器最大连接数的限制(不过可以使用HTTP2.0来扩展数量)
- 浏览器支持没有Websocket那么广:
Webscoket
SSE
相对于Websocket,在上面提到的单方面的消息通知的场景,使用SSE实现会更轻便。
SSE的实现
要使用SSE,需要在服务端的response设置以下的HTTP头:
vbnet
Content-Type: text/event-stream
必须设置Content-Type类型为event-stream。
服务端推送的消息,由一个或多个消息体组成,每个消息体由一行或多行文本组成,列出该消息体的字段。每个字段由字段名表示,后面是冒号,然后是该字段值的文本数据,最后由\n分隔。每个消息体结尾都要带上\n\n实现分隔(因为SSE传递的是文本信息,所以需要通过这种方式来实现分隔)
每个message按照下面的格式组装数据:
vbnet
event: <event-name>
data: <event-data>
id: <event-id>
retry: <retry-time>
这里面event、data、id和retry都是SSE的关键字。含义如下:
- event:可选,指定事件名称,可以用来区分不同类型的事件。
- data:必填,指定事件数据,可以是任何字符串。
- id:可选,指定事件ID,用于客户端重连时从上次断开的位置继续接收事件。
- retry:可选,指定重试时间间隔,单位为毫秒,如果连接中断,客户端将在指定的时间间隔后重新连接。
在服务端推送数据时,按照上述格式构造数据,然后通过响应流(response stream)发送给客户端。客户端收到数据后,可以通过解析响应流来获取事件名称、事件数据和事件ID等信息。
一个消息体大概是这样的:
bash
id:eventId\nevent:count\ndata: 这是第n条消息\nretry:1000\n\n
注意:
- 如果一行文本中不包含冒号,则整行文本会被解析成为字段名,其字段值为空
- 不管是一个消息体还是多个,每个消息体结尾都需要加上\n\n,不然客户端不会解析
在客户端则需要使用内置的EventSource对象来进行操作。
服务端
node实现
js
const http = require('http');
http.createServer((req, res) => {
// 设置响应头,指定Content-Type为text/event-stream,以及其他必要的头部信息
res.setHeader('Access-Control-Allow-Origin', '*'); // 允许跨域
res.setHeader( 'Content-Type', 'text/event-stream');
res.setHeader( 'Cache-Control', 'no-cache');
res.setHeader( 'Connection', 'keep-alive');
res.writeHead(200);
// 定义一个计数器,用于模拟推送的事件ID
let eventId = 0;
// 每隔1秒向客户端推送一条消息
const intervalId = setInterval(() => {
// 构造SSE格式的数据,并发送给客户端
res.write(`id: ${eventId}\n`); // 事件的id
res.write('event: MessageCount\n'); // 自定义事件名称为MessageCount
res.write(`data: 这是第${eventId+1}条消息\n\n`);
// 也可以这么写
res.write(`id:${eventId}\nevent:MessageCount\ndata:这是第${eventId+1}条消息\n\n`);
eventId++;
}, 1000);
// 当客户端关闭连接时,清除定时器
req.on('close', () => {
clearInterval(intervalId);
res.end();
});
}).listen(8989, () => {
console.log('Server running');
});
服务端的实现需要注意一下,因为建立的是一个长链接,会将当前进程占住,导致其他请求无法被响应,所以如果需要使用SSE,可以将其作为一个独立服务来实现或者在独立进程中启动。
客户端
按照EventSource对象提供的能力,我们需要创建一个EventSource的对象,通过这个对象来处理链接的生命周期。
创建对象
js
const eventSource = new EventSource('http://localhost:8989');
在跨域的情况下,可以添加withCredentials配置是否发送cookie、Authorization 等信息。
js
const eventSource = new EventSource('http://localhost:8989', {withCredentials:true});
建立链接
创建对象之后,EventSource会开始建立链接,一旦链接建立完成,就会触发open
事件:
js
eventSource.onopen = function(event) {
console.log('SSE open');
};
// 也可以使用监听的方式
eventSource.addEventListener('open', function(event) {
console.log('SSE open');
});
对于每个生命周期的方法,EventSource都提供类似dom事件的方式,既可以使用.on**=function
的方式,亦可以使用addEventListener
来监听事件。
接受消息
链接建立成功后就可以开始接受消息了,如果没有自定义事件的话,就使用message
事件来获取事件信息:
js
eventSource.onmessage = function (event) {
console.log('onmessage', event);
};
// 监听的方式
eventSource.addEventListener(
'messaage',
function (event) {
console.log('onmessage', event);
},
false
);
这里需要注意的是,如果是自定义事件的话,使用message
事件就无法获取到事件,例如上面服务端的代码,将事件命名为:MessageCount
js
res.write('event: MessageCount\n');
这里message
事件就无法监听到,需要使用自定义事件监听:
js
eventSource.addEventListener(
'MessageCount',
function (event) {
console.log('MessageCount', event);
},
false
);
处理错误
同上,我们可以通过error
事件来捕获链接的错误:
js
eventSource.onerror = function(err) {
console.log('error', err);
}
eventSource.addEventListener(
'error',
function (err) {
console.log('error', err);
},
false
);
关闭链接
SSE由客户端主动发起请求,服务端推送事件流,客户端无法主动停止服务端的事件流,同时服务端无法主动关闭链接。客户端可以主动关闭链接,如果服务端想要关闭链接,只能关闭http请求。
使用EventScource关闭链接(这个是方法):
js
eventSource.close();
EventSource的属性
- readyState,表示链接状态的数字:
-
- 0:CONNECTING
- 1:OPEN
- 2:CLOSED
- url,事件源的url
- withCredentials,是否开启withCredentials
客户端完整代码
html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SSE Client</title>
</head>
<body>
<ul id="message-list"></ul>
<script type="text/javascript">
// 是否可以使用EventSource
if (window.EventSource) {
const eventSource = new EventSource("http://127.0.0.1:8989");
console.log(eventSource);
eventSource.onopen = function(event) {
console.log('SSE open');
};
eventSource.onerror = function(err) {
console.log('error', err);
}
eventSource.addEventListener(
'MessageCount',
function (event) {
const eventList = document.getElementById("message-list");
const eventItem = document.createElement("li");
eventItem.innerHTML = "message: " + event.data;
eventList.appendChild(eventItem);
},
false
);
}
</script>
</body>
</html>
最终的效果
复杂消息体
上面的服务端实现是简单的消息体传递,假如要实现这样的消息传递:
不定的更新信息、点赞、收藏的消息数量,需要传递比较复杂的消息体。
我们可以把消息的data字段换成json字符串:
js
const count = {
message: 1,
like: 2,
collect: 3
};
res.write(`id:${eventId}\n`);
res.write('event:MessageCount\n');
res.write(`data:${JSON.stringify(count)}\n\n`);
在客户端接收到消息后,再转一下:
js
eventSource.addEventListener(
'MessageCount',
function (event) {
const data = JSON.parse(event.data);
// ...do something
},
false
);
也可以使用复合的消息体来实现数量的单独更新:
js
const messageCount =`id:message-${eventId}\nevent:MessageCount\ndata:1\n\n`;
const likeCount = `id:like-${eventId}\nevent:LikeCount\ndata:2\n\n`;
const collectCount = `id:collect-${eventId}\nevent:CollectCount\ndata:3\n\n`;
res.write(messageCount);
res.write(likeCount);
res.write(collectCount);
客户端分别接收处理:
js
eventSource.addEventListener(
'MessageCount',
function (event) {
const data = JSON.parse(event.data);
// ...do something
},
false
);
eventSource.addEventListener(
'LikeCount',
function (event) {
const data = JSON.parse(event.data);
// ...do something
},
false
);
eventSource.addEventListener(
'CollectCount',
function (event) {
const data = JSON.parse(event.data);
// ...do something
},
false
);
总结
本文介绍了SSE,我们了解了下SSE实现的基本原理,基于SSE实现了一个简单的消息数量更新的demo。相对于WebSocket,SSE更加轻量一些,如果是服务端单向的数据推送的话,可以尝试SSE来实现。
完整的例子
我写了一个完整的例子,有需要可以查看:github.com/chens01/SSE...