使用服务器发送的事件流式传输更新

服务器发送的事件 (SSE) 通过 HTTP 连接从服务器向客户端发送自动更新。建立连接后,服务器可以发起数据传输。

您可能想要使用 SSE 从 Web 应用发送推送通知。SSE 是单向发送信息,因此您不会收到来自客户端的更新。

SSE 的概念可能已经不陌生。Web 应用会“订阅”服务器生成的更新流,每当发生新事件时,都会向客户端发送通知。但要真正了解服务器发送的事件,我们需要了解其 AJAX 前身的限制。其中包括:

  • 轮询:应用会反复轮询服务器以获取数据。大多数 AJAX 应用都使用此技术。使用 HTTP 协议时,提取数据围绕请求和响应格式展开。客户端发出请求,并等待服务器响应数据。如果没有可用的数据,则返回空响应。额外的轮询会产生更大的 HTTP 开销。

  • 长轮询(挂起 GET / COMET):如果服务器没有可用数据,则会将请求保持为打开状态,直到有新数据可用。因此,此技术通常称为“挂起 GET”。当信息可用时,服务器会做出响应并关闭连接,然后重复此过程。因此,服务器会不断使用新数据做出响应。如需进行此设置,开发者通常会使用一些技巧,例如将脚本标记附加到“无限”iframe。

服务器发送的事件从一开始就以高效为目标进行设计。与 SSE 通信时,服务器可以随时向您的应用推送数据,而无需发出初始请求。换句话说,更新可以随时从服务器流式传输到客户端。SSE 会在服务器和客户端之间打开单个单向通道。

服务器发送的事件与长轮询之间的主要区别在于,SSE 由浏览器直接处理,而用户只需监听消息即可。

服务器发送的事件与 WebSocket

为什么要选择服务器发送的事件而不是 WebSocket?这个问题问得好。

WebSocket 协议丰富,支持双向全双工通信。对于游戏、即时通讯应用以及需要双向近乎实时更新的任何用例,双向通道更为合适。

不过,有时您只需要服务器进行单向通信。例如,当好友更新状态、股票行情、新闻动态或其他自动数据推送机制时。换句话说,是对客户端 Web SQL 数据库或 IndexedDB 对象存储的更新。如果您需要将数据发送到服务器,XMLHttpRequest 始终是您的好帮手。

SSE 通过 HTTP 发送。无需使用特殊协议或服务器实现即可正常运行。WebSocket 需要全双工连接和新的 WebSocket 服务器来处理该协议。

此外,服务器发送的事件具有 WebSocket 在设计上所缺乏的各种功能,包括自动重新连接、事件 ID 和发送任意事件的功能。

使用 JavaScript 创建 EventSource

如需订阅事件流,请创建一个 EventSource 对象,并将流的网址传递给该对象:

const source = new EventSource('stream.php');

接下来,为 message 事件设置处理脚本。您可以选择监听 openerror

source.addEventListener('message', (e) => {
  console.log(e.data);
});

source.addEventListener('open', (e) => {
  // Connection was opened.
});

source.addEventListener('error', (e) => {
  if (e.readyState == EventSource.CLOSED) {
    // Connection was closed.
  }
});

从服务器推送更新时,onmessage 处理脚本会触发,并且其 e.data 属性中会显示新数据。神奇的是,每当连接关闭时,浏览器都会在大约 3 秒后自动重新连接到来源。您的服务器实现甚至可以控制此重新连接超时

大功告成。您的客户端现在可以处理来自 stream.php 的事件。

事件流格式

从来源发送事件流的过程是构建一个纯文本响应,并使用遵循 SSE 格式的 text/event-stream Content-Type 进行传送。在基本形式下,响应应包含 data: 行,后跟您的消息,再后跟两个“\n”字符以结束数据流:

data: My message\n\n

多行数据

如果消息较长,可以使用多行 data: 将其拆分。以 data: 开头的连续两行或更多行将被视为一条数据,这意味着系统只会触发一次 message 事件。

每行都应以一个“\n”结尾(最后一行除外,它应以两个“\n”结尾)。传递给 message 处理程序的结果是一个由换行符串联的字符串。例如:

data: first line\n
data: second line\n\n</pre>

这会在 e.data 中生成“first line\nsecond line”。然后,您可以使用 e.data.split('\n').join('') 重新构建不含“\n”字符的消息。

发送 JSON 数据

使用多行有助于您在不破坏语法的情况下发送 JSON:

data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n

可能用于处理该数据流的客户端代码:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.id, data.msg);
});

将 ID 与事件相关联

您可以通过添加一行以 id: 开头的行来随数据流事件发送唯一 ID:

id: 12345\n
data: GOOG\n
data: 556\n\n

设置 ID 可让浏览器跟踪上次触发的事件,以便在与服务器的连接中断时,使用新请求设置特殊的 HTTP 标头 (Last-Event-ID)。这样,浏览器就可以确定要触发哪个事件。message 事件包含 e.lastEventId 属性。

控制重新连接超时

每次连接关闭大约 3 秒后,浏览器会尝试重新连接到来源。您可以通过添加以 retry: 开头的行(后跟尝试重新连接之前要等待的毫秒数)来更改此超时时间。

以下示例会在 10 秒后尝试重新连接:

retry: 10000\n
data: hello world\n\n

指定事件名称

单个事件源可以通过添加事件名称来生成不同类型的事件。如果存在以 event: 开头的行,后跟事件的唯一名称,则事件与该名称相关联。在客户端上,您可以设置一个事件监听器来监听该特定事件。

例如,以下服务器输出会发送三种类型的事件,即通用“message”事件、“userlogon”事件和“update”事件:

data: {"msg": "First message"}\n\n
event: userlogon\n
data: {"username": "John123"}\n\n
event: update\n
data: {"username": "John123", "emotion": "happy"}\n\n

在客户端上设置事件监听器:

source.addEventListener('message', (e) => {
  const data = JSON.parse(e.data);
  console.log(data.msg);
});

source.addEventListener('userlogon', (e) => {
  const data = JSON.parse(e.data);
  console.log(`User login: ${data.username}`);
});

source.addEventListener('update', (e) => {
  const data = JSON.parse(e.data);
  console.log(`${data.username} is now ${data.emotion}`);
};

服务器示例

以下是 PHP 中的基本服务器实现:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache'); // recommended to prevent caching of event data.

/**
* Constructs the SSE data format and flushes that data to the client.
*
* @param string $id Timestamp/id of this connection.
* @param string $msg Line of text that should be transmitted.
**/

function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

$serverTime = time();

sendMsg($serverTime, 'server time: ' . date("h:i:s", time()));
?>

下面是使用 Express 处理程序在 Node JS 上实现的类似:

app.get('/events', (req, res) => {
    // Send the SSE header.
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });

    // Sends an event to the client where the data is the current date,
    // then schedules the event to happen again after 5 seconds.
    const sendEvent = () => {
        const data = (new Date()).toLocaleTimeString();
        res.write("data: " + data + '\n\n');
        setTimeout(sendEvent, 5000);
    };

    // Send the initial event immediately.
    sendEvent();
});

sse-node.html

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
  </head>
  <body>
    <script>
    const source = new EventSource('/events');
    source.onmessage = (e) => {
        const content = document.createElement('div');
        content.textContent = e.data;
        document.body.append(content);
    };
    </script>
  </body>
</html>

取消事件流

通常,当连接关闭时,浏览器会自动重新连接到事件源,但您可以从客户端或服务器取消此行为。

如需从客户端取消数据流,请调用:

source.close();

如需从服务器取消流式传输,请使用非 text/event-stream Content-Type 进行响应,或返回 200 OK 以外的 HTTP 状态(例如 404 Not Found)。

这两种方法都可以防止浏览器重新建立连接。

关于安全

EventSource 生成的请求受与其他网络 API(例如 fetch)相同的同源政策的约束。如果您需要从其他来源访问服务器上的 SSE 端点,请参阅如何使用跨源资源共享 (CORS) 进行启用。