使用 Server-Sent Events

最近在做一个需要不断更新状态的功能时,简单对比了下轮询、Comet、SSEs(Server-Sent Events)以及 WebSocket 几种方案之后,选择了实现起来简单的 SSEs 方案。如果不是之前听同事介绍过,恐怕不知道何时才能了解这种技术,我发现这个实现简单的技术在网上的资源并不是很多,看过的几篇文章是三五年前写的。在实际的使用过程中加深了其设计的理解以及踩了一些坑。

为什么选用 Server-Sent Events

关于 SSEs 的简单使用可以参考 Using server-sent events,详细的介绍可以阅读 Stream Updates with Server-Sent Events

轮询作为一种经典的数据更新技术,通过不断发送 AJAX 请求来实现,其明显的缺点有:一是发送无用的请求;二是服务端数据更新时,客户端不能即时更新。

Comet是轮询的升级版,避免了其明显的两个缺点,但与 SSEs 相比较还是存在缺点,其实现方式比较 Hack,而且不是浏览器厂商特意设计出来的,对连接错误处理不可靠。详细介绍可参考 Reverse Ajax, Part 1: Introduction to Comet

WebSocket是专门用来实现双通道通信的,对于只需要服务端更新数据的应用来说有点儿杀鸡用牛刀的意思,而且其实现比较复杂,需要服务器支持。而 SSEs 不需要服务器做特殊处理,同时 IE8+ 及现代浏览器都支持或有兼容性方案。

可能还有一个选择 SSEs 的原因,我想尝试下这种技术。

接口

浏览器端通过全局的 EventSource 构造函数来创建到服务端的 SSEs 连接。

var ev = new EventSource('/sseapi');

默认情况下请求遵循同源策略,如果要建立跨域连接,需要设置第二个参数:

var ev = new EventSource('//api.domain.com/sse', { withCredentity: true });

兼容性方案

Can I Use 显示主要 IE 及 Android 2.1~4.3 是不支持 EventSource。

尽管这里列出了多个 Polyfills,但是从最近提交记录、测试等考量后,自己选用了 https://github.com/amvtek/EventSource,支持 IE8+ 及 Android Browser 2.1+。

实例

浏览器端

sse-example.html

<!DOCTYPE html>
<html>
<head>
  <meta charset="utf-8" />
  <title>Server-Sent Events Example</title>
  <style>
    form {
      font-size: 16px;
      margin-bottom: 1em;
    }
    button {
      font-size: 16px;
      cursor: pointer;
    }
  </style>
</head>
<body>
  <form>
    <button type="button" id="close">关闭连接</button>
  </form>
  <div id="message"></div>
  <div id="result"></div>

  <script>
  var ev = new EventSource('/sseapi');

  // 打印`ev`
  console.log('ev:', ev);

  ev.addEventListener('message', function (e) {

    // 打印事件对象`e`
    console.log('e:', e);

    var message = document.getElementById('message');
    message.innerHTML = e.data;
  }, false);

  ev.addEventListener('init', function (e) {
    var result = document.getElementById('result');
    var data = JSON.parse(e.data);
    result.innerHTML = '<p>Username: <span id="username">' +
                      data.username + '</span><br>' +
                      'Score: <span id="score">' +
                      data.score + '</span></p>';
  }, false);

  ev.addEventListener('update', function (e) {
    var data = JSON.parse(e.data);
    var score = document.getElementById('score');
    score.innerHTML = data.score;
  }, false);

  ev.addEventListener('open', function (e) {
    console.log('Connection was opened.');
  }, false);

  ev.addEventListener('error', function (e) {
    console.error('Connection came across an error.');
  }, false);

  var closeBtn = document.getElementById('close');

  closeBtn.addEventListener('click', function (e) {
    ev.close();
    console.log('EventSource was closed.');
    console.log('ev:', ev);
  }, false);
  </script>
</body>
</html>

服务端

使用 NodeJS 及 Express 实现。

sse-server.js

var express = require('express');

var app = express();
var PORT = 8020;

// Serve static file sse-example.html
app.get('/', function (req, res, next) {
  var fileName = 'sse-example.html';
  res.sendFile(fileName, { root: __dirname }, function (err) {
    if (err) {
      console.log(err);
      res.status(err.status).end();
    } else {
      console.log('Sent: ', fileName);
    }
  });
});

// Server-sent events api
app.get('/sseapi', function (req, res, next) {

  // 设置 HTTP Status Code 与 HTTP Headers
  res.writeHead(200, {
    Connection: 'keep-alive',
    'Content-Type': 'text/event-stream',
    'Cache-Control': 'no-cache'
  });

  // 设定浏览器在连接断开后延迟多长时间重新建立连接
  // 通过 retry 字段控制,其单位为毫秒
  res.write('retry: 10000\n');

  // 通过 data 字段发送所要传输的数据,冒号后面的空格字符被忽略,
  // 必须以两个换行符结尾才会发送一个 Event
  res.write('data: Test default event name\n\n');

  var score = 0;

  // 如果不指定 event,其默认为 "message",即客户端需要监听的事件名称
  res.write('event: init\n');
  res.write('data: {"username": "Alex Chao", "score": ' + score + ' }\n\n');
  res.flushHeaders();

  var timerId = setInterval(function () {
    score++;

    res.write('event: update\n');
    res.write('data: { "score": ' + score + ' }\n\n');
    res.flushHeaders();
  }, 3000);

  // 连接关闭后清除定时器
  req.connection.on('close', function () {
    console.log('Connection was closed.');
    clearInterval(timerId);
  });
});

app.listen(PORT, function () {
  console.log('Server listening on: http://127.0.0.1:%s', PORT);
});

Github 下载代码

注意事项

  • 服务端在连接关闭后应该中止任务,防止可能的内存泄露。
  • 如果浏览器到服务器端存在代理,则代理服务器转发 HTTP chunk 时可能会出现问题,比如 Nginx 会立即将接受到的数据包转发出去,此时可配置proxy_buffer off;,可参考 EventSource/Server-Sent Events through Nginx
  • 网上有提到某些杀毒软件会阻止 event streaming data chunks,我是用的 OSX 系统安装了 Microsoft Endpoint Protection,没有发现有影响。

参考