iT邦幫忙

2026 iThome 鐵人賽

DAY 15
0
JavaScript

30 天新世代 JavaScript 自我學習指南系列 第 15

Day 15|Web Stream 資料流的另一種收法:SSE 與 EventSource

  • 分享至 

  • xImage
  •  

摘要

SSE (Server Sent Events)讓 server 可以一直主動送資料給瀏覽器。它不是什麼特別的協定,就是一段普通的 HTTP 回應,裡面放純文字,每筆事件之間用一個空白行隔開。瀏覽器內建的 EventSource 會幫你把文字切成一筆一筆的事件,斷線還會自動重連;如果改用 fetch 自己讀,拿到的就只是沒切好的一段段文字,這些事都得自己做。

  • 前置知識:Day 14 看過 Hono 如何把資料一段一段寫進 stream、client 逐塊讀出來。沒讀過也不影響,今天聊聊 EventSource 這個新名詞,當然看過 ReadableStream 體驗會更深刻。

  • 學習路線:今天從一個 Hono SSE endpoint 出發。先看 SSE 長什麼樣、為什麼 chunk 不等於 event,再用 EventSource 完整接一次,最後拆掉它,看看 response.body 那條路差在哪。

  • 標籤ES2018 Async Generator SSE Async Iterable TransformStream backpressure AbortSignal


今日學習目標

Day 14 翻 Hono 的串流 helper 時,看到 server 可以把資料一段一段寫進 stream,client 那端則逐塊讀出來。翻著翻著,就撞見了另一個名詞:SSE(Server-Sent Events) 😏——server 在同一條連線上持續推送事件,瀏覽器直接就能收。

但這兩件事其實不在同一層:stream 交給你的是 chunk,SSE 卻變成是「一筆有名稱、有 data 的事件」。所以問題來了——這個邊界,到底由誰負責定義?

  1. 說出一筆 SSE 事件長什麼樣,以及那個空白行為什麼是關鍵。
  2. 解釋為什麼 chunk 不等於 event——一筆事件可能被切開,好幾筆也可能擠在一起。
  3. Hono streamSSE() 從 server 持續送出事件,並用 EventSource 完整接收一次。
  4. 避開 EventSource 的四個實務陷阱,尤其是一次性回應被自動重連。
  5. 說明 response.body 這條路拿到的是什麼,以及為什麼收 SSE 不該自己重造。

https://ithelp.ithome.com.tw/upload/images/20260919/20145251d6wGD8P5GB.png


一、SSE 是什麼?先看它長什麼樣

HTTP 的預設節奏是一問一答:client 發 request,server 回 response,結束。但有些場景需要反過來——通知、進度回報、AI 逐字生成答案,這些都是 server 有話要說,而且不知道什麼時候說完。

SSE(Server-Sent Events)就是為此設計的:client 只發一次 request,server 把 response 一直開著,隨時往裡面寫新事件。

它沒有另外開一條協定,就跑在普通的 HTTP response 上,內容是純文字。一筆事件長這樣:

event: time-update
data: 2026-09-19T02:23:24.808Z
id: 0
                 ← 這一行是空的,代表「這筆講完了」

規則只有幾條:每行是「欄位名 + 冒號 + 值」,data 放內容,event 給事件取名字,id 讓斷線重連時知道接到哪裡。

那個空白行是小陷阱😂

它不是排版留白,是協定規定的結束記號——而且不是空格,是一行零個字元的行。把跳脫字元寫出來就清楚了:

"event: time-update\ndata: 10:00\n\n"
                                ↑↑
                                │└─ 空白行:這筆事件結束
                                └── data 那行自己的換行

結尾是連續兩個 \n:第一個結束 data: 那一行,第二個獨立成一行、內容為空,就是邊界。

之所以能拿它當記號,是因為 SSE 每行都是「欄位名 + 冒號 + 值」,空白行不可能出現在一筆事件內部,不會誤判。

反過來說也成立:沒補上空白行,事件就永遠不會送達。server 只寫了 data: hello\n 就停手的話,這筆資料會一直卡在 parser 的緩衝區裡。第三節會看到 Hono 的 writeSSE() 最後那個 + '\n\n',做的就是這件事。

這些都不是實作慣例,而是 WHATWG HTML Standard 的明文規定。規格在逐行處理規則裡列了 "If the line is empty (a blank line)" 這一條,對應的動作就是把事件送出去;而串流若在最後一個空行之前就結束,殘留的資料必須被丟棄,那筆不完整的事件不會被送達。

常有人拿它跟 WebSocket 比:
WebSocket 是雙向的、能傳二進位,但要另外交握、自己處理重連;SSE 只能 server 單向送文字,換來的是「就是一個 HTTP response」的簡單,瀏覽器還內建了自動重連。只需要 server 推、不需要 client 一直回話時,SSE 通常夠用。


那它跟 ReadableStream 是什麼關係?

一開始會發現怎麼有兩種接收 Stream 資料流的用法,也是我最容易打結的地方—— 因為 Day 14 看到的 ReadableStream 好像也在做 「資料陸續抵達」 這件事~

差別在於它們回答不同層次的問題。ReadableStream 是 Web API,描述程式如何讀取一段還在抵達的資料; SSE 比較像在 HTTP response 裡的文字約定,描述這些資料要如何組成一筆事件

ReadableStream SSE
是什麼 通用的 Web 資料流介面 HTTP response 上的文字事件格式
consumer 拿到什麼 chunk;網路 response 常是 Uint8Array event;有 typedatalastEventId 等意義
邊界由誰決定 網路 response 的 chunk 由 runtime/傳輸層交付;不保證對應任何業務資料 以空白行結束一筆 event,例如 data: ...\n\n
常見 consumer getReader()read()for await...of EventSourcemessage 或自訂事件 listener
能拿來做什麼 檔案、一般 response、NDJSON、SSE 等各種持續資料 server 單向推送可命名事件

chunk 不等於 SSE event

一個 SSE event 可能被切成多個 ReadableStream chunk;多個 event 也可能一起出現在同一個 chunk。

https://ithelp.ithome.com.tw/upload/images/20260919/20145251jWCpdTN840.png

最容易混淆的地方在於:chunk 不等於 event

把一筆 SSE event 想成一張便條紙:最後那個空白行(\n\n)代表「這張寫完了」。假設 server 連續寫出兩張便條:

event: tick
data: 1
                 ← 空白行,第一張寫完了
event: tick
data: 2
                 ← 空白行,第二張寫完了

但網路不像辦公室助理,會一張一張完整交到你手上;它比較像郵差,每次往信箱塞多少紙不固定。可能先送來這些:

chunk 1  "event: tick\ndata: 1\n\nevent: ti"
chunk 2  "ck\ndata: 2\n\n"

第一個 chunk 裡,第一筆 event 已經完整,可以交付;但第二筆只收到一半,得先留在緩衝區。等 chunk 2 到了,補齊 \n\n,才能交付第二筆。

\n 的時候很容易看錯:data: 1\n\n 裡的兩個 \n第一個只是結束 data: 1 那一行,第二個才是空白行。兩個換行字元加起來,畫面上只會看到一個空行,不是兩個。

也可能一次送來兩張:

chunk 1  "event: tick\ndata: 1\n\nevent: tick\ndata: 2\n\n"

這次同一個 chunk 裡有兩個 \n\n,所以可以一次交付兩筆 event。

所以 parser 的工作很單純:先把新 chunk 接到緩衝區後面;只要找到 \n\n,就切出一筆完整 event;剩下的文字繼續留著等下一個 chunk。

而且是湊齊一筆就交付一筆,不會等整條串流結束。上面那個例子裡,第一筆在 chunk 1 抵達的當下就送出去了,你的 listener 那時就被呼叫;第二筆要等 chunk 2 才補齊。AI 逐字回覆之所以能一個字一個字出現在畫面上,就是這個道理。

沒湊齊的殘料則不會半套交出去——如果連線在這時候斷掉,那半筆會直接被丟棄,不會有人收到不完整的事件。

writeSSE() 呼叫兩次,只代表 server 寫了兩筆 SSE event;不代表 client 下一次讀取時一定剛好拿到兩筆。event 的邊界由 SSE 格式決定,chunk 的邊界則由傳輸層決定。

這個 parser 瀏覽器已經放在 EventSource 裡了。EventSource 時,你直接收到完整 event;改用 fetch()response.body 時,才需要自己面對 chunk、緩衝區與切分。

整趟旅程長這樣:

server 想說的事    { event: "time-update", data: "10:00" }
        ↓ 用 writeSSE() 組成文字,結尾補上空白行
一段文字          "event: time-update\ndata: 10:00\n\n"
        ↓ 轉成位元組,塞進 HTTP response
網路上的 chunk     chunk A、chunk B、chunk C…(怎麼切不一定)
        ↓ EventSource 累積、找空白行、切出來
一個完整的事件      { type: "time-update", data: "10:00" }

頭尾兩端長得一模一樣——server 想送什麼,client 就收到什麼。中間那三層是為了讓資料能過網路,繞的路。

最後那個「切出來」的動作,你有兩種選擇

這是今天真正的分岔點:

  • 交給瀏覽器做——用 EventSource,三行程式,事件自動組好送到你手上。
  • 自己做——用 fetchresponse.body,拿到的是 chunk,累積和切分都得自己寫。

response.body 那一層流的是還沒分好的位元組,EventSource 那一層流的是已經分好的事件。


二、server 端:用 Hono streamSSE() 持續送出事件

Hono 的 streamSSE() 底層同樣建立 Web TransformStream。差別在於,它在一般 writer 之外提供 writeSSE():把一筆應用程式訊息轉成 event:data:id:retry: 等行,最後補上 \n\n 作為 event 分隔。

以下是可放進 Hono Node.js 專案的最小 route。以 npm create hono@latest 建立 Node.js template 後,將核心 route 放入 src/index.ts,啟動開發伺服器,再用 curl -N http://localhost:3000/events 即可觀察連續事件:

import { serve } from '@hono/node-server';
import { Hono } from 'hono';
import { streamSSE } from 'hono/streaming';

const app = new Hono();

app.get('/events', (c) =>
  streamSSE(c, async (stream) => {
    let id = 0;
    let active = true;

    stream.onAbort(() => {
      active = false;
      console.log('client disconnected');
    });

    while (active && !stream.aborted) {
      await stream.writeSSE({
        event: 'time-update',
        id: String(id++),
        data: new Date().toISOString(),
      });
      await stream.sleep(1000);
    }
  }),
);

serve({ fetch: app.fetch, port: 3000 });

writeSSE() 負責把物件組成 SSE 文字,sleep() 則是每秒送一筆。callback 只要不結束,response 就一直開著。

onAbort() 是 client 斷線時的通知——關掉分頁、換頁、或呼叫 source.close() 都會觸發。範例用它把 active 設成 false,讓迴圈停下來,實際專案也可以在這裡清掉 timer 或訂閱。要注意它只代表「這條連線沒人在聽了」,不表示 server 其他工作也跟著取消。

內部其實只是字串拼接

writeSSE() 的實作幾乎全是字串處理:

// 簡化自 hono/src/helper/streaming/sse.ts
const sseData = [
  message.event && `event: ${message.event}`,
  dataLines,
  message.id !== undefined && `id: ${message.id}`,
].filter(Boolean).join('\n') + '\n\n';

await this.write(sseData);   // ← 字串組完之後,才寫進 stream

注意最後一行:SSE 的格式化發生在寫進管線之前。也就是說——

stream 那一層完全不知道 SSE 是什麼。

管線裡流的只是 bytes。\n\n 對它而言只是兩個換行字元,沒有任何理由順著它切——這就是第一節「chunk 不等於 event」的根本原因。

下一節換到 client,看瀏覽器怎麼把這些 bytes 變回事件。


三、client 端:用 EventSource 把事件收下來

server 那邊一直在 push,client 這邊要有人接。瀏覽器內建的 EventSource 就是為 SSE 準備的,一個可以直接放進頁面的完整版本長這樣:

const output = document.querySelector('#output');
const status = document.querySelector('#status');

const source = new EventSource('/events');

// 1. 連上了
source.addEventListener('open', () => {
  status.textContent = '連線中';
});

// 2. 依事件名稱分流——名稱就是 server 那邊 writeSSE({ event: ... }) 給的
source.addEventListener('time-update', (e) => {
  output.textContent = e.data;
});

// 3. server 說結束了,就主動關掉(下一節會解釋為什麼非關不可)
source.addEventListener('done', () => {
  status.textContent = '完成';
  source.close();
});

// 4. 出事了。注意這裡拿不到 status code,只知道「斷了」
source.addEventListener('error', () => {
  status.textContent = source.readyState === EventSource.CLOSED
    ? '已中斷'
    : '重新連線中…';
});

// 5. 離開頁面時收拾乾淨
window.addEventListener('beforeunload', () => source.close());

幾個容易漏掉的點:

  • 沒有指定 event: 的事件,會進到 message。所以 server 若只寫 data:,這裡要監聽的是 'message' 而不是自訂名稱。
  • e.data 永遠是字串。送 JSON 的話,這端要自己 JSON.parse()
  • e.lastEventId 對應 server 的 id:;斷線重連時瀏覽器會用 Last-Event-ID 這個 header 把它送回去,server 就能從斷點接續。
  • readyState 有三種:CONNECTINGOPENCLOSED,用來分辨「正在重連」和「真的結束了」。

到這裡整個 SSE 串接就完成了。你沒有碰到任何 ReadableStream——瀏覽器把讀取、解碼、切事件、重連全部包掉了。


四、那 ReadableStream 呢?拆掉 EventSource 看看

Day 14 讀 HTTP 串流用的是 response.body,一個 ReadableStream。同一個 /events,改用 fetch() 讀讀看:

const response = await fetch('/events');

for await (const chunk of response.body.pipeThrough(new TextDecoderStream())) {
  console.log(JSON.stringify(chunk));
}

這段看起來很長,其實只做三件事:從 response 拿位元組 → 轉成文字 → 一段一段印出來。

寫法 在做什麼
response.body 網路送來的原始資料;裡面是位元組,也就是一串數字,不是文字
new TextDecoderStream() 把這串數字翻成 UTF-8 文字
.pipeThrough(...) 讓資料先經過這個「翻譯器」,後面拿到的就會是文字
for await...of 每有一段新文字抵達,就拿一段出來;還沒到就先等

JSON.stringify() 只是方便觀察:直接印文字時,\n 會真的換行;包起來後才會顯示成 \n,比較看得出 chunk 是在哪裡切開的。

TextDecoderStream 能省嗎?不行。因為網路切 chunk 時,連一個字都可能被切成兩半。

UTF-8 裡一個中文字通常有 3 個位元組;如果「你」只來了前兩個位元組,現在還不能翻成文字。TextDecoderStream 會先記住這半個字,等下一個 chunk 把最後一個位元組送來,再交出完整的「你」,簡單來說文字在電腦或網路傳輸存儲的單位是不一樣地~需要轉換。

這其實和 SSE parser 做的是同一件事:不完整的資料先留著,等下一段補齊。 差別只在它們拼的是不同東西:

層級 什麼被切開 誰負責接回去
位元組 一個 UTF-8 字元 TextDecoderStream
事件 一筆 SSE event EventSource(或你自己)

pipeThrough() 怎麼把多個轉接頭串成一條管線
輸出是這樣:

"event: time-update\ndata: 2026-09-19T02:23:24.808Z\nid: 0\n\n"
"event: time-update\ndata: 2026-09-19T02:23:25.810Z\nid: 1\n\n"

沒有 e.data,沒有事件名稱可以分流——你拿到的是一段一段的原始文字,event:data: 只是字串的一部分。

而且不能假設「一個 chunk 就是一筆事件」。實測把 data 換成 64KB,第一個 chunk 有 65529 個字元卻一筆完整事件都沒有;把 sleep() 拿掉連發,50 筆事件又會擠進同一個 chunk。要拿到事件,就得自己累積文字、自己找 \n\n、自己把欄位拆成物件——還要處理斷線重連與 Last-Event-ID

這就是兩者的分工:

ReadableStreamresponse.body EventSource
你拿到什麼 chunk,一段還沒分好的文字或位元組 event,typedatalastEventId 都備妥
邊界誰決定 傳輸層,跟你的資料無關 SSE 的空白行
誰負責解析 瀏覽器
斷線重連 自己寫 內建,會帶 Last-Event-ID 續傳
適用範圍 任何串流:檔案、NDJSON、自訂協定 只有 text/event-stream

所以收 SSE 就用 EventSource,不要自己重造——尤其重連牽涉退避與續傳,手寫很容易漏。

會需要退回 fetch + response.body 的,是那些 EventSource 做不到的場合:它只能發 GET、不能自訂 header、不能帶 body,因為要送 Authorization: Bearer、要 POST 帶 prompt、要能中途取消,才只好自己讀 response.body 解析資料。


五、實務上會踩到的四件事

上面那段程式能跑,但放進真實專案還有幾個坑。

一、EventSource 會自己重連

這是最常踩的。把一個「送完就結束」的 endpoint 交給它——例如 AI 回答完就關閉連線:

// server:送完三段文字就結束 callback,Hono 隨即自動 close
for (const word of ['Async', ' Generator', ' 很好用']) {
  await stream.writeSSE({ event: 'delta', data: word });
  await stream.sleep(200);
}
await stream.writeSSE({ event: 'done', data: '' });

client 實際跑出來是這樣:

delta: "Async"
delta: " Generator"
delta: " 很好用"
done
★ error 事件(EventSource 準備重連)
delta: "Async"          ← 整段又來一次
delta: " Generator"
delta: " 很好用"
done

server 端也確實印出「第 1 次連線」「第 2 次連線」。

原因是 EventSource 的設計前提是長期存在的通知管道:連線關閉一律當成異常,隔一段時間就重連。但「一次性回答」關閉是正常結束——它分不出這兩種情況。

放著不管,使用者會看到答案重複,後端會被重複計費。解法就是前一節那行:

source.addEventListener('done', () => source.close());

server 說結束了,client 要自己關。 這是使用 EventSource 最重要的一條規矩。

二、離開畫面時一定要 close()

const source = new EventSource('/events');

// 換頁、元件卸載、關閉分頁時
source.close();

忘了關的後果比一般連線更嚴重——它不只是佔著連線,而是會持續重連,即使畫面早就換掉了。

三、錯誤資訊很少,要有心理準備

error 事件不帶 status code:

source.addEventListener('error', () => {
  // 只知道「出事了」,不知道是 401、500,還是網路斷線
});

所以做不出「登入已過期,請重新登入」這種精確提示,只能顯示籠統的「連線中斷」。能分辨的只有 readyState:是還在重連,還是已經 CLOSED

如果產品上非得區分不可,那就是前一節說的「EventSource 做不到」的場合之一。

四、不要每個事件都動一次 DOM

AI 逐字回覆時一秒可能來幾十筆。每筆都改 DOM 會讓畫面卡住。累積起來,交給下一個影格一次更新:

let pending = '';
let scheduled = false;

source.addEventListener('delta', (e) => {
  pending += e.data;
  if (scheduled) return;

  scheduled = true;
  requestAnimationFrame(() => {
    output.textContent += pending;
    pending = '';
    scheduled = false;
  });
});

這跟 SSE 其實無關,是「資料逐筆抵達」本身帶來的問題:資料來得比你畫得快。


六、把整條路徑重新接起來

今天只用了一個 /events。從 server 到畫面,是這樣一條路:

server
  writeSSE({ event, data })     組成文字,結尾補上空白行
        ↓
  HTTP response(一直開著)
        ↓  網路把它切成任意大小的 chunk
client
  EventSource                   累積、找空白行、切出事件、斷線自動重連
        ↓
  addEventListener('time-update', …)

中間那段 chunk 的切法完全不可預期,但你不必管——EventSource 就是為了把這段包掉而存在的。

只有在它做不到的時候(要自訂 header、要 POST、要能中途取消),才需要退回 fetchresponse.body,自己面對那些 chunk。


結語

今天的重點可以濃縮成三句:

  1. SSE 是純文字約定——event:data:id: 幾行,空白行代表一筆結束。沒有二進位、沒有交握,curl -N 就看得見。
  2. server 端持續 push——Hono 的 streamSSE() 讓 route handler 可以一直 writeSSE(),response 保持開著。
  3. client 端用 EventSource——它把讀取、解碼、切事件、重連全包了。你只要 addEventListener,還有記得該關的時候 close()

至於 response.body 那條路:它給你的是還沒分好的 chunk,適用範圍更廣(任何串流都能讀),但 SSE 這件事上,瀏覽器已經幫你做完了,沒必要重造。


參考資料


上一篇
Day 14|怎麼讀一條 ReadableStream?從 getReader、read 到 for await...of
下一篇
Day 16|深入 Stream 本體:從 chunk、controller 到讀懂 Hono 的 StreamingApi
系列文
30 天新世代 JavaScript 自我學習指南18
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言