JavaScript 課程 – Fetch 與網路請求(Networking)
主題:WebSocket 與即時通訊
簡介
在現代 Web 應用程式中,即時性已成為使用者體驗的關鍵要素。聊天系統、即時遊戲、即時儀表板、協作編輯等功能,都需要在瀏覽器與伺服器之間保持持續、低延遲的雙向通訊。傳統的 HTTP 請求(如 fetch、XMLHttpRequest)是 一次性 的「請求—回應」模式,無法滿足這類需求。
WebSocket 正是為了解決這個問題而設計的。它在單一 TCP 連線上提供 全雙工(full‑duplex) 通訊,讓瀏覽器與伺服器可以在任意時刻互相傳送資料,而不必每次都重新建立連線。本文將從概念、實作、常見陷阱與最佳實踐,帶你一步一步掌握 WebSocket,並透過實務範例了解如何在 JavaScript 中運用即時通訊。
核心概念
1. WebSocket 的工作原理
- 握手(Handshake)
- 客戶端先以 HTTP/1.1 發送
Upgrade: websocket的請求,伺服器回應101 Switching Protocols,完成協議切換。
- 客戶端先以 HTTP/1.1 發送
- 建立持久連線
- 握手成功後,TCP 連線升級為 WebSocket,雙方可以開始互相傳送 frames(資料框)。
- 全雙工通訊
- 客戶端與伺服器皆可隨時
send訊息,且不需要等待對方的回應。
- 客戶端與伺服器皆可隨時
- 關閉連線
- 任一方傳送
Closeframe,並等待對方回應後正式斷線。
- 任一方傳送
小提醒:WebSocket 仍然是基於 TCP,因而繼承了 TCP 的可靠性與順序性,但不具備 HTTP 的「快取」與「代理」機制。
2. 建立 WebSocket 連線的基本語法
// 建立 WebSocket 物件,指定伺服器端點 (URL 必須以 ws:// 或 wss:// 開頭)
const socket = new WebSocket('wss://example.com/chat');
// 監聽開啟連線事件
socket.addEventListener('open', () => {
console.log('🔗 連線已建立');
});
// 監聽訊息事件
socket.addEventListener('message', event => {
console.log('📨 收到訊息:', event.data);
});
// 監聽關閉事件
socket.addEventListener('close', event => {
console.log('❌ 連線已關閉', event.code, event.reason);
});
// 監聽錯誤事件
socket.addEventListener('error', err => {
console.error('⚠️ WebSocket 錯誤', err);
});
註:
wss://為加密版(相當於 HTTPS),在正式環境中強烈建議使用。
3. 資料傳送與接收
- 文字訊息:
socket.send('Hello')會以 UTF‑8 編碼傳送字串。 - 二進位資料:
socket.send(blob)、socket.send(arrayBuffer),瀏覽器會自動以 binary frame 發送。 - 自訂協議:常見做法是使用 JSON 包裝訊息,便於在前端與後端解析。
// 以 JSON 格式傳送聊天訊息
function sendChatMessage(content) {
const payload = {
type: 'chat',
timestamp: Date.now(),
data: content,
};
socket.send(JSON.stringify(payload));
}
4. 心跳機制(Ping/Pong)與連線維護
WebSocket 本身支援 ping/pong 控制幀,但瀏覽器端無法直接發送 ping。常見做法是自行在應用層實作心跳訊息:
let heartbeatInterval;
// 每 30 秒發送一次心跳
function startHeartbeat() {
heartbeatInterval = setInterval(() => {
socket.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
}, 30000);
}
// 收到伺服器回應後可重置計時
socket.addEventListener('message', event => {
const msg = JSON.parse(event.data);
if (msg.type === 'heartbeat-ack') {
console.log('✅ 心跳回應 OK');
}
});
5. 重連機制
網路斷線、伺服器重啟等情況會導致 close 事件。為了提升使用者體驗,需在客戶端實作自動重連:
let reconnectAttempts = 0;
const MAX_RETRIES = 5;
function connect() {
const ws = new WebSocket('wss://example.com/chat');
ws.addEventListener('open', () => {
console.log('🔗 重新連線成功');
reconnectAttempts = 0; // 重置計數
startHeartbeat();
});
ws.addEventListener('close', () => {
if (reconnectAttempts < MAX_RETRIES) {
const delay = Math.pow(2, reconnectAttempts) * 1000; // 指數退避
console.warn(`❌ 連線中斷,${delay / 1000}s 後嘗試重連`);
setTimeout(() => {
reconnectAttempts++;
connect();
}, delay);
} else {
console.error('🚨 超過最大重連次數,請手動刷新頁面');
}
});
// 其他事件處理...
return ws;
}
// 初始連線
let socket = connect();
程式碼範例
以下提供 五個實用範例,從最簡單的文字聊天到結合二進位檔案傳輸與 UI 更新的完整示範。
範例 1:最基礎的文字回音(Echo)伺服器
說明:此範例用於驗證本機或測試伺服器的 WebSocket 是否正常工作。伺服器會把收到的訊息原封不動回傳。
// client.js
const socket = new WebSocket('wss://echo.websocket.org'); // 公開測試服務
socket.addEventListener('open', () => {
console.log('🔗 已連線至 Echo 伺服器');
socket.send('Hello WebSocket!'); // 發送測試訊息
});
socket.addEventListener('message', event => {
console.log('📨 伺服器回傳:', event.data);
});
提示:
echo.websocket.org已於 2023 年停止服務,請自行搭建簡易 Node.js echo 伺服器或使用其他測試端點。
範例 2:聊天室 UI(簡易版)
說明:使用
HTML+CSS+JavaScript建立即時聊天介面,訊息以 JSON 包裝,並支援自動滾動。
<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<title>簡易聊天室</title>
<style>
body { font-family:Arial, sans-serif; margin:20px; }
#log { height:300px; overflow-y:auto; border:1px solid #ccc; padding:10px; }
.msg { margin:5px 0; }
.self { color:#0066cc; }
.peer { color:#333; }
</style>
</head>
<body>
<div id="log"></div>
<input id="input" type="text" placeholder="輸入訊息…" />
<button id="sendBtn">發送</button>
<script>
const socket = new WebSocket('wss://example.com/chat');
const log = document.getElementById('log');
const input = document.getElementById('input');
const sendBtn = document.getElementById('sendBtn');
// 接收訊息
socket.addEventListener('message', e => {
const { type, data, sender } = JSON.parse(e.data);
if (type === 'chat') {
const div = document.createElement('div');
div.className = 'msg ' + (sender === 'me' ? 'self' : 'peer');
div.textContent = `${sender}: ${data}`;
log.appendChild(div);
log.scrollTop = log.scrollHeight; // 自動滾動
}
});
// 發送訊息
function send() {
const text = input.value.trim();
if (!text) return;
const payload = { type: 'chat', data: text, sender: 'me' };
socket.send(JSON.stringify(payload));
input.value = '';
}
sendBtn.addEventListener('click', send);
input.addEventListener('keypress', e => e.key === 'Enter' && send());
</script>
</body>
</html>
範例 3:二進位檔案傳輸(圖片上傳)
說明:將使用者選取的圖片轉成
Blob,直接透過 WebSocket 傳送給伺服器,伺服器回傳成功訊息後顯示預覽。
<input type="file" id="fileInput" accept="image/*" />
<img id="preview" style="max-width:200px; margin-top:10px;" />
<script>
const socket = new WebSocket('wss://example.com/upload');
const fileInput = document.getElementById('fileInput');
const preview = document.getElementById('preview');
socket.addEventListener('open', () => console.log('🔗 上傳通道已建立'));
socket.addEventListener('message', e => {
const msg = JSON.parse(e.data);
if (msg.type === 'upload-success') {
preview.src = URL.createObjectURL(fileInput.files[0]); // 本地預覽
alert('圖片已上傳成功!');
}
});
fileInput.addEventListener('change', () => {
const file = fileInput.files[0];
if (!file) return;
// 直接傳送 Blob
socket.send(file);
});
</script>
注意:伺服器端必須根據
Content-Type或二進位長度自行判斷檔案類型,並妥善儲存。
範例 4:心跳與自動重連(完整範例)
說明:結合前面提到的心跳與指數退避重連機制,確保長時間連線的穩定性。
const WS_URL = 'wss://example.com/realtime';
let ws;
let heartbeatTimer;
let reconnectAttempts = 0;
const MAX_RETRY = 8;
// 建立連線
function initWebSocket() {
ws = new WebSocket(WS_URL);
ws.addEventListener('open', () => {
console.log('🔗 連線已開啟');
reconnectAttempts = 0;
startHeartbeat();
});
ws.addEventListener('message', e => {
const msg = JSON.parse(e.data);
if (msg.type === 'heartbeat-ack') {
console.log('✅ 心跳回應');
} else {
// 其他業務處理
console.log('📨 收到訊息', msg);
}
});
ws.addEventListener('close', () => {
console.warn('❌ 連線關閉,嘗試重連...');
stopHeartbeat();
scheduleReconnect();
});
ws.addEventListener('error', err => {
console.error('⚠️ WebSocket 錯誤', err);
ws.close(); // 觸發 close 以統一處理重連
});
}
// 心跳:每 20 秒發一次
function startHeartbeat() {
heartbeatTimer = setInterval(() => {
ws.send(JSON.stringify({ type: 'heartbeat', ts: Date.now() }));
}, 20000);
}
function stopHeartbeat() {
clearInterval(heartbeatTimer);
}
// 重連:指數退避
function scheduleReconnect() {
if (reconnectAttempts >= MAX_RETRY) {
console.error('🚨 已達最大重連次數,請手動刷新');
return;
}
const delay = Math.pow(2, reconnectAttempts) * 1000; // 1,2,4,8...
setTimeout(() => {
reconnectAttempts++;
console.log(`🔁 第 ${reconnectAttempts} 次重連...`);
initWebSocket();
}, delay);
}
// 初始化
initWebSocket();
範例 5:使用 WebSocket 建立「多人協作白板」的基礎架構
說明:此範例展示如何把使用者的繪圖指令(座標、顏色)以 JSON 廣播給所有連線的客戶端,實現即時同步。
<canvas id="board" width="600" height="400" style="border:1px solid #ccc;"></canvas>
<script>
const canvas = document.getElementById('board');
const ctx = canvas.getContext('2d');
const socket = new WebSocket('wss://example.com/whiteboard');
let drawing = false;
canvas.addEventListener('mousedown', e => {
drawing = true;
const { offsetX, offsetY } = e;
ctx.beginPath();
ctx.moveTo(offsetX, offsetY);
sendDraw({ type: 'start', x: offsetX, y: offsetY });
});
canvas.addEventListener('mousemove', e => {
if (!drawing) return;
const { offsetX, offsetY } = e;
ctx.lineTo(offsetX, offsetY);
ctx.stroke();
sendDraw({ type: 'draw', x: offsetX, y: offsetY });
});
canvas.addEventListener('mouseup', () => {
drawing = false;
ctx.closePath();
sendDraw({ type: 'end' });
});
// 發送繪圖指令
function sendDraw(data) {
socket.send(JSON.stringify({ action: 'draw', payload: data }));
}
// 接收其他使用者的指令
socket.addEventListener('message', e => {
const msg = JSON.parse(e.data);
if (msg.action !== 'draw') return;
const p = msg.payload;
if (p.type === 'start') {
ctx.beginPath();
ctx.moveTo(p.x, p.y);
} else if (p.type === 'draw') {
ctx.lineTo(p.x, p.y);
ctx.stroke();
} else if (p.type === 'end') {
ctx.closePath();
}
});
</script>
延伸:實務上會加入 使用者 ID、顏色選擇、撤銷/重做 等功能,並在伺服器端使用 房間(room) 機制分流。
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方案 |
|---|---|---|
忘記處理 close 事件 |
斷線後 UI 仍顯示「已連線」狀態,使用者無法得知失聯。 | 在 close 事件中更新 UI、停止心跳、啟動自動重連。 |
直接在 onmessage 中做大量運算 |
會阻塞 UI,造成卡頓。 | 使用 Web Worker 處理繁重計算或把資料拆分為小塊。 |
未設定 binaryType |
取得二進位資料時會得到 Blob 或 ArrayBuffer,若預期不一致會出錯。 |
明確設定 socket.binaryType = 'arraybuffer'(或 'blob')。 |
| 心跳頻率過高 | 造成不必要的流量與伺服器負擔。 | 依需求選擇 15~30 秒的間隔,並在伺服器端設定合理的逾時時間。 |
| 直接把使用者輸入的字串送出 | 可能遭受 XSS 或 JSON 注入。 | 先 JSON.stringify,或在伺服器端進行 白名單驗證。 |
忘記使用 wss:// |
在非加密環境下,瀏覽器會阻止不安全的 WebSocket 連線。 | 於正式環境一定使用 TLS(wss://),開發時可自行簽發憑證測試。 |
| 未限制同時連線數 | Bot 或惡意攻擊可能導致資源耗盡。 | 在伺服器層面加入 Rate Limiting、IP 黑名單、連線上限。 |
最佳實踐:
- 抽象化 WebSocket 客戶端:封裝成類別或模組,統一管理事件、心跳與重連。
- 使用
Promise或async/await包裝請求-回應模式:雖然 WebSocket 本質是事件驅動,但可自行實作request-response的簡易協議,提升程式可讀性。 - 記錄連線狀態:利用
navigator.connection等 API 判斷網路類型,適時調整心跳頻率。 - *伺服器端使用
ping/pong*:大多數 WebSocket 框架(如ws、socket.io)已內建心跳,確保雙方都能偵測斷線。 - 安全考量:驗證來源(
Origin)與子協議(Sec-WebSocket-Protocol)以防止跨站 WebSocket 攻擊(CSWS)。
實際應用場景
| 場景 | 為什麼適合使用 WebSocket | 典型實作 |
|---|---|---|
| 即時聊天 / 訊息推播 | 需要雙向即時傳遞文字、表情、檔案。 | socket.io、ws + JSON 協議 |
| 多人線上遊戲 | 高頻率、低延遲的座標與狀態同步。 | 二進位 ArrayBuffer + 客製化壓縮 |
| 即時儀表板 / 金融行情 | 大量資料持續推送,避免輪詢。 | Server‑Sent Events(SSE)可作備援;WebSocket 為首選 |
| 協作編輯(文件、白板) | 多使用者同時編輯,需要即時合併與衝突解決。 | OT(Operational Transformation)或 CRDT + WebSocket |
| IoT 裝置監控 | 裝置端常保持長時間連線,伺服器推送指令。 | MQTT over WebSocket 或直接使用 WebSocket |
| 遠端控制台 / 終端機 | 需要即時回傳指令執行結果。 | xterm.js + WebSocket 形成互動式終端機 |
案例分享:某電商平台在促銷期間使用 WebSocket 實作「秒殺倒數」與「即時庫存更新」功能,將 HTTP 輪詢的平均延遲從 2.4 秒降至 120 毫秒,同時減少伺服器的 70% 請求量。
總結
WebSocket 為現代 Web 提供了 全雙工、低延遲 的即時通訊管道,彌補了傳統 HTTP 請求的不足。掌握以下核心要點,即可在前端開發中自信地使用 WebSocket:
- 握手與協議升級:了解
Upgrade: websocket的流程。 - 全雙工資料傳送:文字與二進位皆可直接
send。 - 心跳與重連:實作指數退避與心跳機制,提升連線穩定性。
- 安全與效能:使用
wss://、驗證Origin、限制連線數。 - 實務範例:從簡易聊天、檔案上傳、多人白板到大型即時遊戲,皆可套用相同的概念。
只要把 WebSocket 客戶端 抽象成可重用的模組,配合適當的協議設計(如 JSON、Protobuf),你就能在各種即時應用場景中快速構建高效、可靠的解決方案。祝你開發順利,打造出更多令人驚豔的即時互動體驗! 🚀