跳至主要内容

[JS] HTTP Streaming


工作上在優化法規條文頁面效能時,遇到了一個典型問題:

條文資料量龐大,一次全部載入會造成頁面卡頓,
但使用傳統的分頁又無法提供良好的瀏覽體驗。

本文將深入比較兩種主要的前端 streaming 方案,並探討其底層原理與架構考量。


EventSource 和 Fetch ReadableStream 比較


EventSource

讓服務器主動推送資料給客戶端 (單向溝通)

客戶端

Client Side
// 3 行代碼就能實現 streaming
const eventSource = new EventSource('/api/notifications');
eventSource.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log('收到通知:', data);
};

// 監聽特定事件類型
eventSource.addEventListener('update', (event) => {
console.log('更新事件:', JSON.parse(event.data));
});

// 關閉連接
eventSource.close();

服務器端

Server Side
// Node.js 範例
app.get('/api/notifications-stream', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
'Access-Control-Allow-Origin': '*'
});

// 當有新通知時
const sendNotification = (data) => {
res.write(`data: ${JSON.stringify(data)}\n\n`);
};

// 發送特定類型的事件
const sendUrgentNotification = (data) => {
res.write(`event: update\n`);
res.write(`data: ${JSON.stringify(data)}\n\n`);
};

// 註冊到通知系統
NotificationSystem.addListener(req.user.id, sendNotification);
});

優勢

1. 瀏覽器原生最佳化

EventSource 是瀏覽器原生 API,享受底層最佳化:

  • 自動處理 UTF-8 解碼
  • 內建背壓控制
  • 記憶體使用最佳化

2. 自動重連機制

const eventSource = new EventSource('/api/stream');

eventSource.onerror = (error) => {
console.log('連接發生錯誤,瀏覽器會自動重連');
*// 不需要寫任何重連邏輯!*
};

網路中斷時,瀏覽器會自動嘗試重新連接,完全不需要我們處理。


限制與踩坑經驗

  1. 認證
    // 不能這樣做
    const eventSource = new EventSource('/private-notifications', {
    headers: {
    'Authorization': 'Bearer your-token' // 不支援!
    }
    });

    // 只能這樣湊合...
    const eventSource = new EventSource('/private-notifications?token=your-token');

    早在 2016 年就有開發者提出 EventSource 應該支援 custom headers 的 議題
    但 Chrome EventSource 團隊回應:

    EventSource 不再積極開發。我們認為資源更適合用在填補 fetch() 提供的更通用功能

  2. 連接數限制
    // ❌ 開太多連接會卡住
    for (let i = 0; i < 10; i++) {
    new EventSource(`/stream-${i}`); // 第 7 個會卡住
    }

    // ✅ 解決方案:使用單一連接處理多種事件
    const eventSource = new EventSource('/unified-stream');
    eventSource.addEventListener('type-1', handler1);
    eventSource.addEventListener('type-2', handler2);

    EventSource 在 HTTP/1.1 下確實受到每個瀏覽器每個域名最多 6 個並發連接的限制,
    ChromeFirefox 都將此問題標記為「Won't fix」(不會修復)

    HTTP/2 有條件解決 SSE 的連接限制問題

    • 從 6 個連接提升到 100 個(或更多)
    • 需要服務器和客戶端都支持 HTTP/2
    • 可能被 proxy 降級到 HTTP/1.1

Fetch + ReadableStream

功能實現

使用 Fetch Stream

async function createFetchStream(url, headers = {}) {
// 1. 發起 HTTP 請求
const response = await fetch(url, {
method: 'GET',
headers: {
'Authorization': 'Bearer your-token', // 支援自訂 headers!
...headers
}
});
// 2. 獲取讀取器
const reader = response.body.getReader();
// 3. 建立文字解碼器,將 Uint8Array 轉為字串
const decoder = new TextDecoder();
// 4. 建立緩衝區處理不完整的資料行
let buffer = '';
// 5. 無限迴圈持續讀取串流
while (true) {
// 6. 讀取一個資料 (chunk)
const { done, value } = await reader.read();
// 7. 串流結束時跳出迴圈
if (done) break;
// 8. 將 Uint8Array 解碼成字串並加入緩衝區 (buffer)
buffer += decoder.decode(value, { stream: true });
// 9. 按換行符分割,處理完整的行
const lines = buffer.split('\n');
// 10. 保留最後一行(可能不完整)
buffer = lines.pop() || '';
// 11. 處理每一個完整的行
lines.forEach(line => {
if (line.startsWith('data: ')) {
const data = line.substring(6);
onMessage(JSON.parse(data));
}
});
}
}

實際的 Chunks 分割情況

// 後端可能這樣分割送出:
chunk1: 'data: {"type": "message", "con'
chunk2: 'tent": "你好"}\n\ndata: {"type":'
chunk3: ' "message", "content": ",我是"}\n\n'
chunk4: 'data: {"type": "message", "content": "工程師"}\n\n'

注意 chunk 的邊界可能會切斷 JSON 或行,這就是為什麼需要 buffer 來處理!

原始資料流:
┌─────────────────────────────────────────────────────────────────────┐
│ data: {"type": "message", "content": "你好"}\n\n │
│ data: {"type": "message", "content": ",我是"}\n\n │
│ data: {"type": "message", "content": "工程師"}\n\n │
└─────────────────────────────────────────────────────────────────────┘

分割成 chunks:
┌──────────────────────────┐ ┌─────────────────────────┐
│ chunk1: 'data: {"type": │ │ chunk2: 'tent": "你好"} │
│ "message", "con' │ │ \n\ndata: {"ty │
└──────────────────────────┘ └─────────────────────────┘
┌─────────────────────────┐ ┌──────────────────────────┐
│ chunk3: 'pe": "message" │ │ chunk4: 'data: {"type": │
│ , "content": ", │ │ "message", "con │
│ 我是"}\n\n' │ │ tent": "工程師"} │
└─────────────────────────┘ └──────────────────────────┘

buffer 處理過程:
chunk1 → buffer: 'data: {"type": "message", "con' → 無完整行
chunk2 → buffer: 'tent": "你好"}\n\ndata: {"type":' → 解析出:{"type": "message", "content": "你好"}
chunk3 → buffer: '"message", "content": ",我是"}\n\n → 解析出:{"type": "message", "content": ",我是"}
chunk4 → buffer: 'data: {"type": "message", "content": "工程師"}\n\n' → 解析出:{"type": "message", "content": "工程師"}

最終結果:
✅ 三個完整的 JSON 物件被正確解析
✅ 沒有資料遺失或重複
✅ 即使 chunk 邊界切斷了 JSON,buffer 機制確保正確重組

最終解析結果 前端會依序收到三個完整的 JSON 物件:

// 第1次
onMessage({"type": "message", "content": "你好"});

// 第2次
onMessage({"type": "message", "content": ",我是"});

// 第3次
onMessage({"type": "message", "content": "工程師"});

優勢

  • 支援所有 HTTP 方法:GET、POST、PUT、DELETE 等
  • 完全的 Header 控制:可以自定義認證、Content-Type 等 headers
  • 靈活的數據格式:可以處理 JSON、CSV、純文本等任意格式
  • AbortController 支援:可以隨時取消請求

缺點

  • 實現複雜度高:需要手動處理編碼、解析、緩衝等細節,相比 EventSource 需要更多程式碼
  • 沒有自動重連:需要手動實現重連邏輯

Chunk 解析過程的差異說明

兩種方式的 chunk 解析差異:

  • EventSource 的解析
    • 瀏覽器自動處理:不需要手動管理 buffer,瀏覽器內建 SSE 解析器
    • 格式固定:只能處理 text/event-stream 格式
    • 解析規則:自動識別 data:, event:, id:, retry: 等 SSE 規範
  • Fetch ReadableStream 的解析
    • 手動處理:需要自己管理 buffer、處理不完整的行
    • 格式靈活:可以處理任意格式(JSON Lines、CSV、自定義格式)
    • 解析邏輯:完全由開發者控制

實際使用場景的建議

使用場景EventSourceFetch Stream原因
即時通知/推播首選⚠️ 過度設計瀏覽器原生優化 + 自動重連
需要 JWT 認證❌ 僅支援 URL 參數完全支援可自定義 headers
大檔案串流下載❌ 記憶體風險理想選擇支援 AbortController + 進度追蹤
AI 聊天機器人⚠️ 可用但受限更靈活支援 POST + 複雜 payload
上傳/下載進度❌ 無法計算可精確追蹤可取得 Content-Length
即時儀表板簡單高效⚠️ 複雜度高單向數據流,自動處理重連

Axios Streaming

限制

Axios 無法使用 stream 來直接處理真正的 streaming response
( 但 Node.js 中可以使用 stream ),這與瀏覽器底層 HTTP 請求實現的限制有關。

建議直接用 Fetch API


為什麼瀏覽器中的 Axios 不能直接處理流?

  1. 底層技術差異
  • Node.js 環境:

    Axios 使用 Node.js 原生 http 模組,支援 responseType: 'stream' 資料可以分塊處理

  • 瀏覽器環境:

    Axios 基於 XMLHttpRequest,不支援 responseType: 'stream' 只能設定:arraybuffer | blob | document | json | text

即使伺服器送出 streaming 資料,瀏覽器中的 Axios 也只能等整個回應完成後才能處理,無法做到真正的串流解析。

  1. 如果堅持用 Axios

    可以用 responseType: 'text' 配合手動 split 處理,但會有問題:

  • 記憶體風險: 需要自己管理緩衝區處理不完整資料
  • 效能損失: 手動字串分割,效率遠低於原生流式處理

信息

瀏覽器渲染的限制與挑戰

  • 瀏覽器最佳渲染:60fps(每幀16.67ms),在每秒60幀的情況下,瀏覽器每幀的可用時間僅有約16.7毫秒(1000毫秒/ 60幀)。
  • 在這16.7毫秒內,瀏覽器需要執行包括JavaScript、CSS樣式計算、佈局、繪製和複合(Composite)等所有渲染任務。
  • 如果某個任務(例如JavaScript計算)在16.7毫秒內無法完成,就可能導致掉幀,造成畫面卡頓不順暢。

前端 Streaming 批次處理

核心問題:高頻更新導致介面卡頓

在實際開發中,streaming 資料的到達速度往往超過瀏覽器的渲染能力:

正常網頁渲染:

每16.7ms內處理1次更新 → JavaScript執行 + DOM更新 + 重排重繪 時間充裕,能在預算內完成


高頻 Streaming渲染: 每16.7ms內可能要處理多次更新

  • 多個 chunk 同時到達
  • 每個 chunk 觸發 React 狀態更新
  • 連續的DOM操作和重排重繪
  • JavaScript 執行時間大幅增加

結果:單幀處理時間超過16.7ms → 掉幀 → 卡頓

// ❌ 問題場景:每個 chunk 都觸發 React 狀態更新
async function fetchStreamData() {
const response = await fetch('/api/stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;

const text = decoder.decode(value);
const lines = text.split('\n');

lines.forEach(line => {
if (line.startsWith('data: ')) {
const item = JSON.parse(line.substring(6));
setItems(prev => [...prev, item]); // 每秒可能觸發數百次重渲染!
}
});
}
}


解決方法

  1. 數量控制:累積 N 個項目才觸發一次更新

目的: 減少更新次數

if (currentBatch.length >= 30) {
// 30個一起處理,而非30次單獨處理
}
  1. 時間控制:最多每 X 毫秒更新一次

目的: 控制更新頻率,確保即使批量條件頻繁滿足,UI 更新仍然保持在可承受的頻率內。

throttle(updateUI, 300); // 300ms內最多執行一次
// 伺服器快速回傳大量條文
// 每批30條,但可能每20ms就有一批

時間線:
0ms -130條到達 → 立即顯示 ✅
20ms -230條到達 → throttle阻止 ❌
40ms -330條到達 → throttle阻止 ❌
60ms -430條到達 → throttle阻止 ❌
...
300ms - throttle到期 → 顯示累積的90條 ✅


❌ 沒有 throttle:
updateUI() 在 0ms, 20ms, 40ms, 60ms... 執行
= 每20ms一次 = 50/
→ 更新頻率接近瀏覽器60fps極限,容易超出處理能力

✅ 有 throttle (300ms)
updateUI() 只在 0ms, 300ms, 600ms 執行
= 每300ms一次 = 3.3/
→ 更新頻率遠低於瀏覽器處理能力,留有充足餘裕

資料處理架構

┌─────────────────┐
│ Stream Data │ ← 原始串流(不可控制頻率)
│ (Raw Chunks) │
└─────────┬───────┘

┌─────────▼───────┐
│ Batch Buffer │ ← 批量緩衝(數量控制)
│ [item1...item30]│
└─────────┬───────┘

┌─────────▼───────┐
│ Throttled Update│ ← 節流更新(時間控制)
│ (300ms max) │
└─────────┬───────┘

┌─────────▼───────┐
│ React State │ ← 最終狀態(平穩更新)
│ (UI) │
└─────────────────┘

完整解決方案

function useStreamingData() {
const [items, setItems] = useState([]);
const batchBuffer = useRef([]);

// 節流更新函式
const throttledUpdate = useCallback(
throttle((newItems) => {
setItems(prev => [...prev, ...newItems]);
batchBuffer.current = []; // 清空buffer
}, 300),
[]
);

const processStreamData = useCallback(async () => {
const response = await fetch('/api/stream');
const reader = response.body.getReader();
const decoder = new TextDecoder();

while (true) {
const { done, value } = await reader.read();
if (done) break;

const text = decoder.decode(value);
const lines = text.split('\n');

lines.forEach(line => {
if (line.startsWith('data: ')) {
const item = JSON.parse(line.substring(6));
batchBuffer.current.push(item);

// 批量觸發條件
if (batchBuffer.current.length >= 30) {
const itemsToUpdate = [...batchBuffer.current];
batchBuffer.current = []; // 立即清空,避免重複處理
throttledUpdate(itemsToUpdate);
}
}
});
}

// 處理剩餘項目
if (batchBuffer.current.length > 0) {
throttledUpdate([...batchBuffer.current]);
}
}, [throttledUpdate]);

return { items, processStreamData };
}

改善效果

  • 更新頻率:從每秒數百次降到每秒幾次
  • 載入速度:明顯提升(具體提升幅度因內容而異)
  • 記憶體使用:減少不必要的中間狀態累積
  • 使用者感受:從「完全卡死」變成「流暢載入」

總結

在實際開發中,選擇合適的 streaming 方案需要綜合考慮:

  • EventSource 適合需要簡單實現的即時通知場景,瀏覽器原生優化讓它在大多數情況下都能穩定運行。

  • Fetch + ReadableStream 則在需要複雜認證、自定義格式或精細控制的場景下展現優勢。

最重要的是,無論選擇哪種技術,都要重視前端的批量處理和節流機制 —> 這往往是決定使用者體驗的關鍵因素。

希望這篇文章能幫助你在面對大量資料串流時,做出更好的技術決策。