EP.05網路系列

WebSocket 與 SSE:
即時通訊的兩種選擇

WebSocket 雙向連線、Server-Sent Events 單向推送 — 聊天室 vs 即時更新,選對工具

Joseph Chen 2026 14 min read WebSocket · SSE · Socket.io · 即時通訊

「你有沒有試過用 setInterval 每秒打一個 API 來模擬即時通知? 能動,但就像用水桶一杓一杓地把海水舀空——你永遠在做無用功。 99% 的時候伺服器根本沒有新資料,你還是每秒發出一次請求。」

HTTP 的設計是「請求-回應」模式:客戶端主動問,伺服器被動答。 但即時應用需要的是「有事才通知」——伺服器主動推送。 這篇帶你理解 WebSocket 和 SSE 這兩個為即時通訊設計的協議, 以及在實際工程中如何選擇和實作它們。

Section 1:HTTP 的問題 — 為什麼需要 WebSocket

在 WebSocket 出現之前,開發者用「輪詢(Polling)」來實作即時功能。 這個方案能動,但代價很高。

用 HTTP Polling 模擬即時更新

javascript
// ❌ 每秒輪詢:浪費頻寬,有延遲
setInterval(async () => {
  const messages = await fetch('/api/messages').then(r => r.json());
  setMessages(messages);
}, 1000);

// 問題 1:每次都建立新的 TCP 連線(HTTP 握手 overhead)
// 問題 2:大多數時候伺服器沒有新資料(99% 的請求是無效的)
// 問題 3:延遲:最長可達 1 秒才能收到新訊息
// 問題 4:10 個用戶 × 1 秒 1 次 = 每秒 10 個請求
//         1000 個用戶 = 每秒 1000 個請求(純粹是在轟炸伺服器)

HTTP Polling 的問題

  • 每次請求都要 TCP 三次握手
  • 每次請求都要 HTTP Headers(幾百 bytes)
  • 大部分回應是「沒有新資料」
  • 延遲等於輪詢間隔(至少幾百 ms)
  • 伺服器資源被大量浪費

WebSocket 的優勢

  • 只需一次握手建立持久連線
  • 訊息 overhead 極小(2-10 bytes header)
  • 只在有新資料時傳輸
  • 延遲幾乎為零(毫秒級)
  • 伺服器可主動推送

HTTP Polling 像是你每秒打電話問「有新消息嗎」,對方說「沒有」,掛掉。

WebSocket 像是打了一個不掛斷的電話,有新消息對方直接說。 你不需要一直問,對方有事就通知你。

補充:還有一種折衷方案叫 Long Polling——伺服器收到請求後不立即回應, 等到有新資料或超時才回應。比 Polling 好一點,但依然有連線 overhead 問題。 現在已被 WebSocket / SSE 取代。

Section 2:WebSocket 協議原理

WebSocket 不是憑空建立的——它建立在 HTTP 之上,先用 HTTP 做「升級握手」, 然後把連線轉換成持久的雙向通道。這個設計讓 WebSocket 可以穿越大多數的防火牆和代理伺服器(因為一開始看起來像普通 HTTP)。

WebSocket 連線建立過程

text
步驟 1:客戶端發送 HTTP 升級請求

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket            ← 要求升級協議
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==   ← 隨機 Base64 key
Sec-WebSocket-Version: 13

---

步驟 2:伺服器回應 101,確認升級

HTTP/1.1 101 Switching Protocols    ← 101 表示「協議升級成功」
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=  ← 驗證 key

---

步驟 3:連線升級完成

TCP 連線保持開放,不再是 HTTP 請求/回應模式
→ 持久雙向通訊開始
→ 任何一方都可以隨時發送訊息框架(Frame)
→ 直到其中一方發送 Close Frame 才關閉

WebSocket 的關鍵特性

連線特性

  • 長連線:建立一次,持續使用,不需要每次握手
  • 雙向:客戶端和伺服器都可以主動發訊息
  • 低延遲:毫秒級,因為沒有 HTTP 握手 overhead
  • 有訊息邊界:不像 TCP stream,每個訊息是獨立的 Frame

訊息框架(Frame)

  • Text Frame:UTF-8 文字(JSON 字串)
  • Binary Frame:二進位資料(圖片、檔案)
  • Ping/Pong Frame:心跳檢測,確認連線存活
  • Close Frame:優雅關閉連線,附帶原因碼

WebSocket URL 格式

ws://example.com/chat← HTTP 環境
wss://example.com/chat← HTTPS 環境(WebSocket Secure,必須用於生產)

Section 3:瀏覽器 WebSocket API

瀏覽器原生支援 WebSocket API,不需要任何額外套件。但直接使用原生 API 在生產環境有一些挑戰, 例如斷線重連、錯誤處理等,以下先展示如何正確封裝它。

WebSocket Manager:封裝重連邏輯

typescript
class WebSocketManager {
  private ws: WebSocket | null = null;
  private reconnectAttempts = 0;
  private maxReconnectAttempts = 5;
  private messageHandlers: ((data: any) => void)[] = [];

  connect(url: string) {
    this.ws = new WebSocket(url);

    this.ws.onopen = () => {
      console.log('WebSocket 連線成功');
      this.reconnectAttempts = 0;  // 重置重連計數
    };

    this.ws.onmessage = (event) => {
      try {
        const data = JSON.parse(event.data);
        // 通知所有訊息處理器
        this.messageHandlers.forEach(handler => handler(data));
      } catch {
        console.error('訊息解析失敗', event.data);
      }
    };

    this.ws.onerror = (error) => {
      console.error('WebSocket 錯誤', error);
      // onclose 會在 onerror 後自動觸發,在那裡處理重連
    };

    this.ws.onclose = (event) => {
      console.log(`連線關閉,code: ${event.code}, reason: ${event.reason}`);

      // 1000 = 正常關閉,不需要重連
      if (event.code !== 1000 && this.reconnectAttempts < this.maxReconnectAttempts) {
        // 指數退避(Exponential Backoff):
        // 第 1 次:1 秒後重試
        // 第 2 次:2 秒後重試
        // 第 3 次:4 秒後重試
        // 最長不超過 30 秒
        const delay = Math.min(1000 * 2 ** this.reconnectAttempts, 30000);
        console.log(`${delay / 1000} 秒後嘗試重連(第 ${this.reconnectAttempts + 1} 次)`);
        setTimeout(() => this.connect(url), delay);
        this.reconnectAttempts++;
      }
    };
  }

  send(data: object) {
    // 確認連線狀態再發送
    if (this.ws?.readyState === WebSocket.OPEN) {
      this.ws.send(JSON.stringify(data));
    } else {
      console.warn('WebSocket 尚未連線,無法發送訊息');
    }
  }

  onMessage(handler: (data: any) => void) {
    this.messageHandlers.push(handler);
    // 回傳取消訂閱函式
    return () => {
      this.messageHandlers = this.messageHandlers.filter(h => h !== handler);
    };
  }

  disconnect() {
    // 1000 = 正常關閉,阻止 onclose 觸發重連
    this.ws?.close(1000, '用戶主動關閉');
    this.ws = null;
  }
}

// WebSocket 連線狀態常數
// WebSocket.CONNECTING = 0 → 正在連線
// WebSocket.OPEN = 1       → 已連線,可以收發訊息
// WebSocket.CLOSING = 2    → 正在關閉
// WebSocket.CLOSED = 3     → 已關閉

React Hook 封裝

在 React 中,WebSocket 需要配合 useRefuseEffect來管理連線生命週期,確保元件卸載時正確關閉連線,避免 memory leak。

typescript
import { useState, useEffect, useRef, useCallback } from 'react';

interface Message {
  id: string;
  text: string;
  sender: string;
  timestamp: number;
}

function useWebSocket(url: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [isConnected, setIsConnected] = useState(false);
  const wsRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    wsRef.current = new WebSocket(url);

    wsRef.current.onopen = () => setIsConnected(true);

    wsRef.current.onmessage = (event) => {
      const msg = JSON.parse(event.data) as Message;
      // 使用 functional update 避免 stale closure 問題
      setMessages(prev => [...prev, msg]);
    };

    wsRef.current.onclose = () => setIsConnected(false);

    // Cleanup:元件卸載時關閉連線(非常重要!)
    // 如果忘記這個,每次元件 remount 都會建立新連線
    return () => {
      wsRef.current?.close(1000, '元件卸載');
    };
  }, [url]);  // url 改變時重新建立連線

  const sendMessage = useCallback((text: string) => {
    if (wsRef.current?.readyState === WebSocket.OPEN) {
      wsRef.current.send(JSON.stringify({
        type: 'message',
        text,
        timestamp: Date.now(),
      }));
    }
  }, []);

  return { messages, sendMessage, isConnected };
}

// 使用方式
function ChatRoom({ roomId }: { roomId: string }) {
  const { messages, sendMessage, isConnected } = useWebSocket(
    `wss://api.example.com/chat/${roomId}`
  );

  return (
    <div>
      <div className="text-sm text-gray-500">
        {isConnected ? '✅ 已連線' : '🔴 連線中斷'}
      </div>
      <div className="messages">
        {messages.map(msg => (
          <div key={msg.id}>{msg.text}</div>
        ))}
      </div>
      <button onClick={() => sendMessage('Hello!')}>發送</button>
    </div>
  );
}

Section 4:Server-Sent Events(SSE)— 單向推送的輕量選擇

SSE 是建立在 HTTP 之上的單向串流協議:伺服器可以持續推送訊息給客戶端,但客戶端不能反向傳訊息。 聽起來像限制,但很多場景其實只需要這樣——通知、報價、進度更新都是伺服器單向推送的需求。

SSE 的優勢

  • 建立在標準 HTTP 上,無需特殊協議升級
  • 原生支援斷線自動重連
  • 瀏覽器支援良好,包含舊版
  • 通過 HTTP/2 可多工(multiplexing)
  • 實作簡單,後端一個 Stream 就夠

SSE 的限制

  • 只能伺服器 → 客戶端(單向)
  • 只能傳文字(不能傳 Binary)
  • 每個連線佔用一個 HTTP 連線
  • IE / Edge 舊版不支援(現在基本無影響)

SSE 的使用場景

即時通知股票報價進度條AI 串流輸出(ChatGPT 效果)社群媒體 Feed 更新伺服器狀態監控

後端實作(Next.js App Router API Route)

SSE 的關鍵在於 Content-Type: text/event-stream 和 特定的資料格式(data: ...\n\n)。 每個事件以兩個換行符結尾。

typescript
// app/api/events/route.ts(Next.js App Router)
export async function GET(req: Request) {
  const stream = new ReadableStream({
    start(controller) {
      // 輔助函式:推送一個 SSE 事件
      const send = (data: object, event?: string) => {
        if (event) {
          controller.enqueue(`event: ${event}
`);
        }
        controller.enqueue(`data: ${JSON.stringify(data)}

`);
        // 格式:
        //   event: eventName  (可選,客戶端可用 addEventListener 監聽)
        //   data: { ... }     (必須)
        //   id: 123           (可選,斷線重連後告訴伺服器上次收到哪條)
        //                     (兩個 

 表示一個完整事件結束)
      };

      // 範例 1:每秒推送股票價格
      const priceInterval = setInterval(() => {
        send({
          symbol: 'AAPL',
          price: (150 + Math.random() * 10).toFixed(2),
          change: (Math.random() * 2 - 1).toFixed(2),
          time: new Date().toISOString(),
        }, 'price-update');
      }, 1000);

      // 範例 2:推送系統通知
      setTimeout(() => {
        send({
          type: 'notification',
          message: '您的訂單已出貨',
          orderId: 'ORD-12345',
        }, 'notification');
      }, 3000);

      // 客戶端斷線時清理資源(非常重要,否則 interval 會一直跑)
      req.signal.addEventListener('abort', () => {
        clearInterval(priceInterval);
        controller.close();
      });
    },
  });

  return new Response(stream, {
    headers: {
      'Content-Type': 'text/event-stream',  // 必須
      'Cache-Control': 'no-cache',           // 不能被快取
      'Connection': 'keep-alive',            // 保持連線
      'X-Accel-Buffering': 'no',             // 關閉 Nginx 緩衝(重要!)
    },
  });
}

前端實作(EventSource API)

typescript
import { useState, useEffect } from 'react';

// 通用的 SSE Hook
function useSSE<T>(url: string) {
  const [data, setData] = useState<T | null>(null);
  const [error, setError] = useState<string | null>(null);
  const [isConnected, setIsConnected] = useState(false);

  useEffect(() => {
    const eventSource = new EventSource(url);

    eventSource.onopen = () => setIsConnected(true);

    // 監聽預設事件(對應後端沒有設 event 名稱的)
    eventSource.onmessage = (event) => {
      setData(JSON.parse(event.data) as T);
    };

    // 監聽自訂事件(對應後端設了 event: price-update 的)
    eventSource.addEventListener('price-update', (event) => {
      setData(JSON.parse(event.data) as T);
    });

    eventSource.addEventListener('notification', (event) => {
      const notification = JSON.parse(event.data);
      alert(`通知:${notification.message}`);
    });

    eventSource.onerror = () => {
      setIsConnected(false);
      setError('連線中斷');
      // EventSource 會自動嘗試重連(預設 3 秒後)
      // 如果不想重連,呼叫 eventSource.close()
    };

    return () => {
      eventSource.close();  // 元件卸載時關閉
    };
  }, [url]);

  return { data, error, isConnected };
}

// 使用範例:即時股票報價
interface StockData {
  symbol: string;
  price: string;
  change: string;
  time: string;
}

function StockTicker() {
  const { data: stock, isConnected } = useSSE<StockData>('/api/events');

  return (
    <div className="p-4">
      <div className="text-xs text-gray-400 mb-2">
        {isConnected ? '● 即時更新中' : '○ 連線中...'}
      </div>
      {stock && (
        <div>
          <span className="text-2xl font-bold">{stock.symbol}</span>
          <span className="text-xl ml-3">${stock.price}</span>
          <span className={stock.change.startsWith('-') ? 'text-red-500 ml-2' : 'text-green-500 ml-2'}>
            {stock.change}
          </span>
        </div>
      )}
    </div>
  );
}

SSE 的自動重連機制

瀏覽器的 EventSource 原生支援自動重連——斷線後會自動嘗試重新連線。 伺服器可以用 retry: 5000 指定重連間隔(毫秒)。 如果事件有 id: 欄位,重連時會帶 Last-Event-ID header, 讓伺服器知道從哪條事件繼續推送,避免資料遺失。

Section 5:Socket.io — 生產環境的首選

直接使用原生 WebSocket API 在生產環境有以下挑戰:需要自己實作重連邏輯、自己管理 heartbeat、 不同環境(有些代理不支援 WebSocket)行為不一致、沒有 room / namespace 概念、 規模化需要自己處理跨伺服器的訊息廣播。

Socket.io 是一個封裝在 WebSocket 上的函式庫,解決了所有這些問題, 並在 WebSocket 不可用時自動退回 HTTP Long Polling。

後端(Node.js + Socket.io)

typescript
// server.ts(獨立 Node.js 伺服器)
import { createServer } from 'http';
import { Server, Socket } from 'socket.io';

const httpServer = createServer();
const io = new Server(httpServer, {
  cors: {
    origin: 'http://localhost:3000',
    methods: ['GET', 'POST'],
  },
  // 心跳設定:每 25 秒 ping 一次,10 秒內沒回應就斷線
  pingTimeout: 10000,
  pingInterval: 25000,
});

// Middleware:連線時驗證 JWT
io.use(async (socket, next) => {
  const token = socket.handshake.auth.token;
  try {
    const user = await verifyJWT(token);
    socket.data.user = user;  // 把用戶資訊掛到 socket 上
    next();
  } catch {
    next(new Error('認證失敗'));
  }
});

io.on('connection', (socket: Socket) => {
  const user = socket.data.user;
  console.log(`用戶 ${user.name} 連線,ID: ${socket.id}`);

  // 加入聊天室(Socket.io 的 Room 概念)
  socket.on('join-room', (roomId: string) => {
    socket.join(roomId);
    // 通知聊天室其他人
    socket.to(roomId).emit('user-joined', {
      userId: user.id,
      userName: user.name,
    });
  });

  // 離開聊天室
  socket.on('leave-room', (roomId: string) => {
    socket.leave(roomId);
    socket.to(roomId).emit('user-left', { userId: user.id });
  });

  // 發送訊息給聊天室所有人
  socket.on('send-message', ({ roomId, message }: { roomId: string; message: string }) => {
    const msg = {
      id: crypto.randomUUID(),
      text: message,
      sender: { id: user.id, name: user.name },
      timestamp: Date.now(),
    };

    // io.to(roomId) 廣播給聊天室所有人(包含自己)
    // socket.to(roomId) 廣播給除自己以外的所有人
    io.to(roomId).emit('new-message', msg);
  });

  // 打字中提示
  socket.on('typing', ({ roomId }: { roomId: string }) => {
    socket.to(roomId).emit('user-typing', { userName: user.name });
  });

  socket.on('disconnect', (reason) => {
    console.log(`用戶 ${user.name} 斷線,原因:${reason}`);
    // reason: 'transport close'(網路問題)/ 'client namespace disconnect'(主動離開)
  });
});

httpServer.listen(4000, () => console.log('Socket.io 伺服器啟動於 port 4000'));

前端(React + Socket.io-client)

tsx
import { useEffect, useRef, useState, useCallback } from 'react';
import { io, Socket } from 'socket.io-client';

interface Message {
  id: string;
  text: string;
  sender: { id: string; name: string };
  timestamp: number;
}

// 把 Socket 連線抽成 Hook
function useSocketRoom(roomId: string, token: string) {
  const [messages, setMessages] = useState<Message[]>([]);
  const [onlineUsers, setOnlineUsers] = useState<string[]>([]);
  const [typingUser, setTypingUser] = useState<string | null>(null);
  const socketRef = useRef<Socket | null>(null);

  useEffect(() => {
    // 建立連線,帶 JWT token
    socketRef.current = io('http://localhost:4000', {
      auth: { token },
    });

    const socket = socketRef.current;

    socket.on('connect', () => {
      socket.emit('join-room', roomId);
    });

    socket.on('new-message', (msg: Message) => {
      setMessages(prev => [...prev, msg]);
    });

    socket.on('user-joined', ({ userName }: { userName: string }) => {
      setOnlineUsers(prev => [...prev, userName]);
    });

    socket.on('user-left', ({ userId }: { userId: string }) => {
      setOnlineUsers(prev => prev.filter(u => u !== userId));
    });

    socket.on('user-typing', ({ userName }: { userName: string }) => {
      setTypingUser(userName);
      // 3 秒後清除打字提示
      setTimeout(() => setTypingUser(null), 3000);
    });

    return () => {
      socket.emit('leave-room', roomId);
      socket.disconnect();
    };
  }, [roomId, token]);

  const sendMessage = useCallback((text: string) => {
    socketRef.current?.emit('send-message', { roomId, message: text });
  }, [roomId]);

  const notifyTyping = useCallback(() => {
    socketRef.current?.emit('typing', { roomId });
  }, [roomId]);

  return { messages, onlineUsers, typingUser, sendMessage, notifyTyping };
}

// 聊天室元件
function ChatRoom({ roomId, token }: { roomId: string; token: string }) {
  const [input, setInput] = useState('');
  const { messages, typingUser, sendMessage, notifyTyping } = useSocketRoom(roomId, token);

  const handleSend = () => {
    if (input.trim()) {
      sendMessage(input.trim());
      setInput('');
    }
  };

  return (
    <div className="flex flex-col h-screen">
      <div className="flex-1 overflow-y-auto p-4">
        {messages.map(msg => (
          <div key={msg.id} className="mb-3">
            <span className="text-xs text-gray-500">{msg.sender.name}</span>
            <p className="bg-white rounded-lg p-3 shadow-sm">{msg.text}</p>
          </div>
        ))}
        {typingUser && (
          <p className="text-xs text-gray-400 italic">{typingUser} 正在輸入...</p>
        )}
      </div>
      <div className="p-4 border-t flex gap-2">
        <input
          value={input}
          onChange={e => { setInput(e.target.value); notifyTyping(); }}
          onKeyDown={e => e.key === 'Enter' && handleSend()}
          placeholder="輸入訊息..."
          className="flex-1 border rounded-lg px-3 py-2"
        />
        <button onClick={handleSend} className="bg-teal-600 text-white px-4 py-2 rounded-lg">
          發送
        </button>
      </div>
    </div>
  );
}

Socket.io 解決了哪些原生 WebSocket 的痛點

✅ 自動重連,有指數退避邏輯

✅ 內建 Heartbeat(Ping/Pong)

✅ Namespace:同一連線多個頻道

✅ Room:分組廣播

✅ 降級支援:WebSocket → Long Polling

✅ Acknowledgement:確認訊息已收到

✅ Redis Adapter:多節點水平擴展

✅ 完整的 TypeScript 支援

Section 6:選擇指南

現在你了解了三種方案的特性,以下這份表格幫你在具體需求下快速做出選擇:

需求場景建議方案原因
聊天室、即時訊息Socket.io雙向通訊,需要 Room 管理,生產環境穩定
即時通知(系統推送)SSE單向推送,實作簡單,HTTP 友好
股票報價、即時數據流SSE伺服器單向推送,低延遲,簡單易維護
多人協作(Google Docs 類)WebSocket需要雙向低延遲,操作變換同步
進度條、任務狀態更新SSE伺服器推進度,客戶端不需要傳資料
遊戲、低延遲互動WebSocket毫秒級延遲,雙向高頻訊息
AI 串流輸出(打字效果)SSE伺服器逐步推 token,ChatGPT 就是這樣做的
需要水平擴展的生產環境Socket.io + RedisRedis Adapter 讓多個 Node.js 實例可以廣播

生產環境注意事項

連線管理

  • 連線數限制:每個 WebSocket 連線佔用伺服器 file descriptor 和記憶體。 一台普通伺服器能穩定支援約 1 萬個並發連線,超過需要水平擴展。
  • 記憶體估算:每個 Socket.io 連線約佔 20-30 KB 記憶體。 10 萬個連線 ≈ 2-3 GB RAM。

架構考量

  • Load Balancer:WebSocket 需要設定 sticky session(同一用戶的請求路由到同一節點), 或使用 Redis Pub/Sub 讓所有節點共享訊息。
  • HTTPS 環境:生產環境必須用 wss://(WebSocket Secure)。 純 HTTP 的 ws:// 在 HTTPS 頁面會被瀏覽器阻擋(Mixed Content)。

水平擴展架構(Socket.io + Redis)

typescript
import { createAdapter } from '@socket.io/redis-adapter';
import { createClient } from 'redis';

// 生產環境:用 Redis 讓多個 Node.js 實例可以互相廣播
const pubClient = createClient({ url: process.env.REDIS_URL });
const subClient = pubClient.duplicate();

await Promise.all([pubClient.connect(), subClient.connect()]);

io.adapter(createAdapter(pubClient, subClient));
// 現在 io.to(roomId).emit() 會廣播到所有節點上的連線
WebSocket
Server-Sent Events
Socket.io
即時通訊
Network
Node.js