使用 server-sent 事件

開發一個使用 server-sent 事件的網頁應用程式很簡單。在伺服器端只需要一些的程式碼與網頁串流事件,而客戶端這邊的處理進入事件的部分幾乎跟 websockets 一樣。這是一種單向的連線,所以你無法從客戶端向伺服器傳送事件。

從伺服器端接收事件

server-sent event API 包含在  EventSource (en-US) 介面;為了與伺服器端開啟連線並接收事件,需要建立帶有產生事件 script URL 的 EventSource (en-US) 物件。例如:

const evtSource = new EventSource("ssedemo.php");

如果事件產生的 script 在不同源的伺服器上,在建立 EventSource (en-US) 物件時需要同時提供 URL 和第二個參數作為選項設定。假設客戶端的 script 伺服於 example.com

const evtSource = new EventSource("//api.example.com/ssedemo.php", { withCredentials: true } );

當你完成初始化事件來源後,你就可以透過新增 message 事件的處理器來開始監聽來自伺服器的訊息:

evtSource.onmessage = function(event) {
  const newElement = document.createElement("li");
  const eventList = document.getElementById("list");

  newElement.innerHTML = "message: " + event.data;
  eventList.appendChild(newElement);
}

上述的程式碼會監聽進入的訊息(這裡來自伺服器的通知沒有指明 event 欄位,所以統一用 onmessage 處理即可)並且把訊息的文字附加到 document 的清單。

你也可以利用 addEventListener() 監聽事件:

evtSource.addEventListener("ping", function(event) {
  const newElement = document.createElement("li");
  const time = JSON.parse(event.data).time;
  newElement.innerHTML = "ping at " + time;
  eventList.appendChild(newElement);
});

上述的程式碼大同小異,不同之處在於若伺服器傳送了 event 欄位值為「ping」的訊息時它就會把 data 欄位的值解析為 JSON 並輸出到畫面上。

當連線不是透過 HTTP/2 時,SSE 會受到最大連線數限制所苦,尤其當開啟多個分頁。每個瀏覽器有自己的限制數而且被限制在很低的數量(6)。這個問題已經被 ChromeFirefox 標註為「Won't fix」(不修復)。限制是基於每個瀏覽器 + 網域,也就是說你可以針對 www.example1.com 網域在所有的分頁中開啟六個 SSE 連線,另一個網域 www.example2.com 也可以開啟六個(根據 Stackoverflow)。當使用 HTTP/2 時最大同時 HTTP streams 連線數是由伺服器和客戶端之間協調(預設 100)。

從伺服器發送事件

由伺服器端所發送的事件需要使用 text/event-stream 的 MIME 類型回應。每一個通知皆由一組文字組成並由一對換行結尾。如何處理事件串流的格式,請參考 Event stream format

下面是一個 PHP 範例:

date_default_timezone_set("America/New_York");
header("Cache-Control: no-cache");
header("Content-Type: text/event-stream");

$counter = rand(1, 10);
while (true) {
  // Every second, send a "ping" event.

  echo "event: ping\n";
  $curDate = date(DATE_ISO8601);
  echo 'data: {"time": "' . $curDate . '"}';
  echo "\n\n";

  // Send a simple message at random intervals.

  $counter--;

  if (!$counter) {
    echo 'data: This is a message at time ' . $curDate . "\n\n";
    $counter = rand(1, 10);
  }

  ob_end_flush();
  flush();

  // Break the loop if the client aborted the connection (closed the page)

  if ( connection_aborted() ) break;

  sleep(1);
}

上述的程式碼會在每秒產生一個類型為「ping」的事件。每一個事件的資料是一個 JSON 物件,內容為事件產生時的 ISO 8601 時間戳。同時會隨機發送一個簡易訊息(沒有事件類型)。
迴圈的執行會獨立於連線的狀態,,所以在迴圈裡必須檢查連線的狀態,若斷線了要關閉連線(譬如,客戶端關閉了網頁)。

備註: 你可以從下列的 Github 文章中找到包含本文所使用程式碼的完整範例 —— 參考 Simple SSE demo using PHP.

錯誤處理

當錯誤發生時(譬如網路逾時或有關存取控制的問題)會產生錯誤事件。你可以透過對 EventSource 物件實作 onerror 回呼的方式採取程式化的處理:

evtSource.onerror = function(err) {
  console.error("EventSource failed:", err);
};

關閉事件串流

預設的情況下,如果客戶端和伺服器的連線關閉則連線會被重啟。連線的關閉會伴隨著 .close() 方法的執行。

evtSource.close();

事件串流(Event Stream)格式

事件串流是個簡易的文字資料串流,內容必須以 UTF-8 格式編碼。在事件串流中,不同的訊息以一對換行符號做區隔。若要撰寫註解,則要在該行的開頭加上冒號(:)。

備註: 註解將有助於防止連線逾時;伺服器端可以定時發送註解以維持連線活著。

每一個訊息是由一到多列的欄位所組成的文字。每個欄位依序由欄位的名稱、冒號、該欄位的文字內容所組合而成。

欄位

每隔訊息皆可以由下列的欄位組合而成,每個欄位以換行做為區隔:

event
事件的類型。如果有指定則在瀏覽器端會對該事件名稱的監聽器發布事件;網頁的原始碼必須使用 addEventListener() 來監聽已命名的事件。 onmessage 處理器只有在訊息沒有指定事件名稱時才會被呼叫。
data
訊息的資料欄位。當 EventSource 連續接收到多列以 data: 開頭的內容;它會串接這些內容並為每一列插入一個換行字元。最後的換行會被移除。
id
EventSource (en-US) 物件的最新一個事件 ID 。
retry
當嘗試傳送事件時重新連線的時間。這個值必須是整數,單位是毫秒,作為重新連線的時間。若指定是非整數則這個欄位會被忽略。

除上述的幾個欄位,其他欄位均會被忽略。

備註:如果某列的內容沒有包含冒號,則該列的內容都會被視為欄位名稱及空字串的欄位值。

BCD tables only load in the browser

Data-only 訊息

在下列的範例中,共發送了三個訊息。第一個是註解,因其以冒號開頭。如之前提到的,對不會持續發送訊息的情境下,這將有助於維持連線的存續。

第二則訊息包含了 data 欄位及「some text」的值。第三則訊息包含了 data 欄位及「another message\nwith two lines」。注意在內容所出現的換行符號。

: this is a test stream

data: some text

data: another message
data: with two lines

命名事件

這個範例傳送了一些命名的事件。每一個事件都被指定了 event 欄位並且 data 欄位也有相應的 JSON 字串作為客戶端回應事件所需的資料。 data 欄位的內容可以是任何的字串;它並沒有強制必須以 JSON 的格式撰寫。

event: userconnect
data: {"username": "bobby", "time": "02:33:48"}

event: usermessage
data: {"username": "bobby", "time": "02:34:11", "text": "Hi everyone."}

event: userdisconnect
data: {"username": "bobby", "time": "02:34:23"}

event: usermessage
data: {"username": "sean", "time": "02:34:36", "text": "Bye, bobby."}

混合及配對

你並非一定只能用未命名訊息或已分類的事件;實際上你可以在單一的事件中把它們混合在一起。

event: userconnect
data: {"username": "bobby", "time": "02:33:48"}

data: Here's a system message of some kind that will get used
data: to accomplish some task.

event: usermessage
data: {"username": "bobby", "time": "02:34:11", "text": "Hi everyone."}