本文 AI 產出,尚未審核

JavaScript 課程 – Fetch 與網路請求(Networking)

主題:WebSocket 與即時通訊


簡介

在現代 Web 應用程式中,即時性已成為使用者體驗的關鍵要素。聊天系統、即時遊戲、即時儀表板、協作編輯等功能,都需要在瀏覽器與伺服器之間保持持續、低延遲的雙向通訊。傳統的 HTTP 請求(如 fetch、XMLHttpRequest)是 一次性 的「請求—回應」模式,無法滿足這類需求。

WebSocket 正是為了解決這個問題而設計的。它在單一 TCP 連線上提供 全雙工(full‑duplex) 通訊,讓瀏覽器與伺服器可以在任意時刻互相傳送資料,而不必每次都重新建立連線。本文將從概念、實作、常見陷阱與最佳實踐,帶你一步一步掌握 WebSocket,並透過實務範例了解如何在 JavaScript 中運用即時通訊。


核心概念

1. WebSocket 的工作原理

  1. 握手(Handshake)
    • 客戶端先以 HTTP/1.1 發送 Upgrade: websocket 的請求,伺服器回應 101 Switching Protocols,完成協議切換。
  2. 建立持久連線
    • 握手成功後,TCP 連線升級為 WebSocket,雙方可以開始互相傳送 frames(資料框)。
  3. 全雙工通訊
    • 客戶端與伺服器皆可隨時 send 訊息,且不需要等待對方的回應。
  4. 關閉連線
    • 任一方傳送 Close frame,並等待對方回應後正式斷線。

小提醒: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 黑名單、連線上限。

最佳實踐:

  1. 抽象化 WebSocket 客戶端:封裝成類別或模組,統一管理事件、心跳與重連。
  2. 使用 Promise 或 async/await 包裝請求-回應模式:雖然 WebSocket 本質是事件驅動,但可自行實作 request-response 的簡易協議,提升程式可讀性。
  3. 記錄連線狀態:利用 navigator.connection 等 API 判斷網路類型,適時調整心跳頻率。
  4. *伺服器端使用 ping/pong*:大多數 WebSocket 框架(如 ws、socket.io)已內建心跳,確保雙方都能偵測斷線。
  5. 安全考量:驗證來源(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:

  1. 握手與協議升級:了解 Upgrade: websocket 的流程。
  2. 全雙工資料傳送:文字與二進位皆可直接 send。
  3. 心跳與重連:實作指數退避與心跳機制,提升連線穩定性。
  4. 安全與效能:使用 wss://、驗證 Origin、限制連線數。
  5. 實務範例:從簡易聊天、檔案上傳、多人白板到大型即時遊戲,皆可套用相同的概念。

只要把 WebSocket 客戶端 抽象成可重用的模組,配合適當的協議設計(如 JSON、Protobuf),你就能在各種即時應用場景中快速構建高效、可靠的解決方案。祝你開發順利,打造出更多令人驚豔的即時互動體驗! 🚀