[JS] HTTP Streaming
工作上在優化法規條文頁面效能時,遇到了一個典型問題:
條文資料量龐大,一次全部載入會造成頁面卡頓,
但使用傳統的分頁又無法提供良好的瀏覽體驗。
本文將深入比較兩種主要的前端 streaming 方案,並探討其底層原理與架構考量。
EventSource 和 Fetch ReadableStream 比較
EventSource
讓服務器主動推送資料給客戶端 (單向溝通)
客戶端
// 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();
服務器端
// 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('連接發生錯誤,瀏覽器會自動重連');
*// 不需要寫任何重連邏輯!*
};
網路中斷時,瀏覽器會自動嘗試重新連接,完全不需要我們處理。
限制與踩坑經驗
- 認證
// 不能這樣做
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() 提供的更通用功能
- 連接數限制
// ❌ 開太多連接會卡住
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 個並發連接的限制,
Chrome 和 Firefox 都將此問題標記為「Won't fix」(不會修復)HTTP/2 有條件解決 SSE 的連接限制問題
- 從 6 個連接提升到 100 個(或更多)
- 需要服務器和客戶端都支持 HTTP/2
- 可能被 proxy 降級到 HTTP/1.1
Fetch + ReadableStream
功能實現
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、自定義格式)
- 解析邏輯:完全由開發者控制
實際使用場景的建議
| 使用場景 | EventSource | Fetch Stream | 原因 |
|---|---|---|---|
| 即時通知/推播 | ✅ 首選 | ⚠️ 過度設計 | 瀏覽器原生優化 + 自動重連 |
| 需要 JWT 認證 | ❌ 僅支援 URL 參數 | ✅ 完全支援 | 可自定義 headers |
| 大檔案串流下載 | ❌ 記憶體風險 | ✅ 理想選擇 | 支援 AbortController + 進度追蹤 |
| AI 聊天機器人 | ⚠️ 可用但受限 | ✅ 更靈活 | 支援 POST + 複雜 payload |
| 上傳/下載進度 | ❌ 無法計算 | ✅ 可精確追蹤 | 可取得 Content-Length |
| 即時儀表板 | ✅ 簡單高效 | ⚠️ 複雜度高 | 單向數據流,自動處理重連 |
Axios Streaming
限制
Axios 無法使用 stream 來直接處理真正的 streaming response
( 但 Node.js 中可以使用 stream ),這與瀏覽器底層 HTTP 請求實現的限制有關。
建議直接用 Fetch API
為什麼瀏覽器中的 Axios 不能直接處理流?
- 底層技術差異
Node.js 環境:
Axios 使用 Node.js 原生 http 模組,支援
responseType: 'stream'資料可以分塊處理瀏覽器環境:
Axios 基於 XMLHttpRequest,不支援
responseType: 'stream'只能設定:arraybuffer | blob | document | json | text
即使伺服器送出 streaming 資料,瀏覽器中的 Axios 也只能等整個回應完成後才能處理,無法做到真正的串流解析。
如果堅持用 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]); // 每秒可能觸發數百次重渲染!
}
});
}
}
解決方法
- 數量控制:累積 N 個項目才觸發一次更新
目的: 減少更新次數
if (currentBatch.length >= 30) {
// 30個一起處理,而非30次單獨處理
}
- 時間控制:最多每 X 毫秒更新一次
目的: 控制更新頻率,確保即使批量條件頻繁滿足,UI 更新仍然保持在可承受的頻率內。
throttle(updateUI, 300); // 300ms內最多執行一次
// 伺服器快速回傳大量條文
// 每批30條,但可能每20ms就有一批
時間線:
0ms - 第1批30條到達 → 立即顯示 ✅
20ms - 第2批30條到達 → throttle阻止 ❌
40ms - 第3批30條到達 → throttle阻止 ❌
60ms - 第4批30條到達 → 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 則在需要複雜認證、自定義格式或精細控制的場景下展現優勢。
最重要的是,無論選擇哪種技術,都要重視前端的批量處理和節流機制 —> 這往往是決定使用者體驗的關鍵因素。
希望這篇文章能幫助你在面對大量資料串流時,做出更好的技術決策。