你学废了吗:Server-Sent Events

如果需要实现一个站点的消息通知功能,例如通知用户收到私信、站内信等信息,像这样的:

前端实现上,你第一时间的思路是什么呢?

定时器轮询

最简单的做法就是写个定时器,在固定的时间间隔内去请求消息通知接口:

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...

相关推荐
neter.asia13 分钟前
vue中如何关闭eslint检测?
前端·javascript·vue.js
~甲壳虫14 分钟前
说说webpack中常见的Plugin?解决了什么问题?
前端·webpack·node.js
代码之光_198032 分钟前
保障性住房管理:SpringBoot技术优势分析
java·spring boot·后端
光影少年33 分钟前
vue2与vue3的全局通信插件,如何实现自定义的插件
前端·javascript·vue.js
As977_34 分钟前
前端学习Day12 CSS盒子的定位(相对定位篇“附练习”)
前端·css·学习
susu108301891136 分钟前
vue3 css的样式如果background没有,如何覆盖有background的样式
前端·css
ajsbxi37 分钟前
苍穹外卖学习记录
java·笔记·后端·学习·nginx·spring·servlet
Ocean☾38 分钟前
前端基础-html-注册界面
前端·算法·html
Dragon Wu40 分钟前
前端 Canvas 绘画 总结
前端
CodeToGym1 小时前
Webpack性能优化指南:从构建到部署的全方位策略
前端·webpack·性能优化