本文 AI 產出,尚未審核

ExpressJS (TypeScript) – WebSocket 與 Real‑time

事件與頻道管理


簡介

在現代 Web 應用程式中,即時互動已成為不可或缺的功能。聊天系統、即時通知、多人協作白板、線上遊戲等,都需要在伺服器與客戶端之間以毫秒級的延遲傳遞訊息。
ExpressJS 本身是 HTTP 框架,但結合 WebSocket 後,就能在同一個 Node.js 程式中同時處理傳統的 REST API 與雙向的即時通訊。

本篇文章聚焦於 事件(Event)與頻道(Channel)管理——即如何在 TypeScript 撰寫的 Express 應用中,設計一套結構化、可擴充的即時訊息系統。從概念說明、實作範例,到常見陷阱與最佳實踐,讓你能快速上手並在真實專案中穩定運作。


核心概念

1. WebSocket 基礎與 Express 整合

WebSocket 是一種在單一 TCP 連線上提供全雙工(full‑duplex)通訊的協定。與傳統的 HTTP 請求不同,WebSocket 允許伺服器 主動 推送訊息給客戶端,且雙方都可以在任意時間傳送資料。

在 Express 中,我們通常使用 wssocket.io 兩大套件。本文以 ws 為例,因為它較輕量且完全遵循原生 WebSocket 規範,搭配 TypeScript 時可以自行定義事件型別,提升開發體驗。

npm i express ws
npm i -D @types/express @types/ws typescript ts-node-dev
// src/server.ts
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer, WebSocket } from 'ws';

const app = express();
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });

httpServer.listen(3000, () => console.log('🚀 Server listening on :3000'));

重點WebSocketServer 直接掛載在同一個 HTTP server 上,讓 Express 的路由與 WebSocket 能共享同一個埠口。


2. 事件(Event)與訊息結構

在即時系統裡,我們常把每筆訊息視為一個 事件,包含:

欄位 說明
type 事件類型(如 chat:messageuser:joined
payload 真正的資料內容(任意 JSON)
channel 目標頻道或房間(可選)
timestamp 產生時間(ISO string)

使用 TypeScript 定義型別,可避免在開發時傳遞錯誤的結構:

// src/types.ts
export type EventType = 
  | 'chat:message'
  | 'chat:join'
  | 'chat:leave'
  | 'notification';

export interface WSMessage<T = any> {
  type: EventType;
  channel?: string;          // 例如聊天室 ID、主題名稱
  payload: T;
  timestamp: string;        // ISO 8601
}

3. 頻道(Channel)概念

頻道(或稱「房間」)是把使用者分組的機制,只有同屬同一頻道的連線才會收到該頻道的訊息。
實作上,我們可以在 ws 中維護一個 Map<string, Set<WebSocket>>,鍵是頻道名稱,值是連線集合。

// src/channel.ts
import { WebSocket } from 'ws';

export class ChannelManager {
  private channels = new Map<string, Set<WebSocket>>();

  /** 加入頻道 */
  join(channel: string, ws: WebSocket) {
    if (!this.channels.has(channel)) {
      this.channels.set(channel, new Set());
    }
    this.channels.get(channel)!.add(ws);
  }

  /** 離開頻道 */
  leave(channel: string, ws: WebSocket) {
    this.channels.get(channel)?.delete(ws);
    if (this.channels.get(channel)?.size === 0) {
      this.channels.delete(channel);
    }
  }

  /** 向頻道廣播 */
  broadcast(channel: string, data: string) {
    this.channels.get(channel)?.forEach(client => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(data);
      }
    });
  }

  /** 取得使用者所在的所有頻道(可自行擴充) */
  getChannelsOf(ws: WebSocket): string[] {
    const list: string[] = [];
    for (const [name, set] of this.channels.entries()) {
      if (set.has(ws)) list.push(name);
    }
    return list;
  }
}

4. 事件路由(Event Router)

為了讓程式碼保持乾淨,我們把「收到什麼事件」與「要怎麼處理」分離,建立一個 事件路由

// src/router.ts
import { WSMessage } from './types';
import { ChannelManager } from './channel';
import { WebSocket } from 'ws';

type Handler = (msg: WSMessage, ws: WebSocket, cm: ChannelManager) => void;

export class EventRouter {
  private handlers = new Map<string, Handler>();

  register(type: string, handler: Handler) {
    this.handlers.set(type, handler);
  }

  async dispatch(raw: string, ws: WebSocket, cm: ChannelManager) {
    let msg: WSMessage;
    try {
      msg = JSON.parse(raw);
    } catch {
      ws.send(JSON.stringify({ error: 'Invalid JSON' }));
      return;
    }

    const handler = this.handlers.get(msg.type);
    if (handler) {
      await handler(msg, ws, cm);
    } else {
      ws.send(JSON.stringify({ error: `No handler for ${msg.type}` }));
    }
  }
}

5. 完整範例:聊天室系統

以下示範如何把上述概念組合成一個簡易的聊天室(支援加入/離開頻道、發送訊息、離線通知):

// src/index.ts
import express from 'express';
import { createServer } from 'http';
import { WebSocketServer, WebSocket } from 'ws';
import { ChannelManager } from './channel';
import { EventRouter } from './router';
import { WSMessage } from './types';

const app = express();
const httpServer = createServer(app);
const wss = new WebSocketServer({ server: httpServer });

const channelMgr = new ChannelManager();
const router = new EventRouter();

/* ---------- 事件處理器 ---------- */
// 1️⃣ 加入頻道
router.register('chat:join', (msg, ws, cm) => {
  const channel = msg.channel!;
  cm.join(channel, ws);
  const reply: WSMessage = {
    type: 'chat:join',
    channel,
    payload: { userId: ws['id'] ?? 'anonymous' },
    timestamp: new Date().toISOString(),
  };
  cm.broadcast(channel, JSON.stringify(reply));
});

// 2️⃣ 離開頻道
router.register('chat:leave', (msg, ws, cm) => {
  const channel = msg.channel!;
  cm.leave(channel, ws);
  const reply: WSMessage = {
    type: 'chat:leave',
    channel,
    payload: { userId: ws['id'] ?? 'anonymous' },
    timestamp: new Date().toISOString(),
  };
  cm.broadcast(channel, JSON.stringify(reply));
});

// 3️⃣ 發送訊息
router.register('chat:message', (msg, ws, cm) => {
  const channel = msg.channel!;
  const broadcastMsg: WSMessage = {
    type: 'chat:message',
    channel,
    payload: {
      userId: ws['id'] ?? 'anonymous',
      text: msg.payload.text,
    },
    timestamp: new Date().toISOString(),
  };
  cm.broadcast(channel, JSON.stringify(broadcastMsg));
});

/* ---------- WebSocket 連線處理 ---------- */
wss.on('connection', (ws: WebSocket) => {
  // 為每條連線掛上簡易的 ID(實務上會用 JWT 或 session)
  ws['id'] = `u${Math.random().toString(36).substr(2, 9)}`;

  ws.on('message', data => {
    router.dispatch(data.toString(), ws, channelMgr);
  });

  ws.on('close', () => {
    // 客戶端斷線時自動離開所有頻道
    const channels = channelMgr.getChannelsOf(ws);
    channels.forEach(ch => channelMgr.leave(ch, ws));
  });
});

/* ---------- Express API(示範) ---------- */
app.get('/', (_, res) => res.send('Express + WebSocket (TypeScript) Demo'));

/* ---------- 啟動伺服器 ---------- */
httpServer.listen(3000, () => console.log('🚀 Server listening on http://localhost:3000'));

說明

  1. 事件路由 讓每種 type 的處理邏輯獨立,未來新增功能(例如 notification)只需要 router.register 一行。
  2. ChannelManager 只負責「誰在頻道」以及「如何廣播」的細節,保持單一職責。
  3. 斷線時自動清理頻道,避免 記憶體泄漏

常見陷阱與最佳實踐

陷阱 為什麼會發生 解決方案 / Best Practice
1. 直接在 ws.on('message') 內寫大量業務邏輯 會讓程式變得難以維護,且難以測試。 使用 事件路由(如上)或 中介層,將解析、驗證、業務分離。
2. 忘記檢查 WebSocket.OPEN 若客戶端已關閉仍呼叫 send,會拋出例外,導致其他連線被中斷。 在廣播前 if (client.readyState === WebSocket.OPEN)
3. 記憶體泄漏:未從頻道集合中移除斷線客戶端 Set 仍持有已斷線的 ws 物件,隨時間累積。 ws.on('close') 內呼叫 ChannelManager.leave,或在 broadcast 時過濾已關閉的連線。
4. 資料過大或頻繁傳送 會造成網路擁塞、伺服器 CPU 飆升。 限速(rate‑limit)訊息壓縮ws 支援 permessage‑deflate)或 分批發送
5. 缺乏驗證與授權 任意客戶端都能加入任意頻道、偽造訊息。 在連線階段使用 JWTCookie Session,在每個事件處理前 驗證 msg.channel 是否有權限。
6. 事件名稱硬編碼 隨著功能擴增,容易出現拼寫錯誤或不一致。 建立 Enum(或 const object)集中管理事件名稱。
7. 不當的錯誤回饋 客戶端無法得知失敗原因,除錯成本高。 統一 錯誤訊息結構{ error: string, code?: number }),並在 router.dispatch 中捕捉例外。

其他最佳實踐

  1. 使用 TypeScript 型別:在事件與 payload 上做好型別定義,編譯期即可捕捉錯誤。
  2. 心跳機制(Ping/Pong)ws 自帶心跳,設定 heartbeatInterval 防止「幽靈連線」佔用資源。
  3. 水平擴展:若需要跨多台 Node 實例,使用 Redis Pub/SubNATS 作為訊息中介,讓頻道資訊在所有實例間同步。
  4. 日誌與監控:將每筆事件寫入結構化日誌(如 Elastic Stack),配合 Prometheus 收集連線數、頻道大小等指標。

實際應用場景

場景 需要的事件/頻道 為什麼 WebSocket 重要
即時聊天系統 chat:joinchat:leavechat:message 用戶必須在毫秒內看到別人的訊息,HTTP 輪詢無法滿足流暢體驗。
線上多人協作白板 board:drawboard:clearboard:cursor 每筆繪圖操作需要即時同步到所有參與者,頻道可對應不同的白板 ID。
即時通知中心 notification(單向) 系統事件(如訂單成立)要立即推送給特定使用者或管理員,避免使用者自行刷新。
線上遊戲 game:movegame:startgame:end 高頻率、低延遲的訊息是遊戲體驗的關鍵;頻道可對應房間或比賽編號。
IoT 即時儀表板 sensor:updatedevice:status 裝置狀態變化要即時呈現在前端,大量感測資料可透過頻道分組。

小技巧:在上述場景中,若需要 歷史訊息(例如聊天紀錄),仍可以使用傳統的 REST API 讀取資料庫,再在 WebSocket 中只負責「新訊息」的即時推播,兩者結合最為彈性。


總結

  • 事件與頻道 是構建可擴充、易維護即時系統的核心概念。
  • 透過 TypeScript 型別事件路由、以及 ChannelManager,我們可以把 WebSocket 的業務邏輯抽離成獨立模組,讓程式碼保持單一職責、易於測試。
  • 常見的陷阱(如記憶體泄漏、未授權的頻道存取)只要在設計階段加入 驗證、清理、心跳 機制,就能大幅降低生產環境的風險。
  • 真實專案中,WebSocket 常與 REST API、資料庫、訊息佇列 結合,形成完整的即時架構;而水平擴展則需要藉助 Redis Pub/SubNATS 之類的分散式系統。

掌握了上述概念與實作範例,你就能在 ExpressJS + TypeScript 的環境下,快速建立從簡易聊天室到大型即時協作平台的各種應用。祝開發順利,期待看到你用 WebSocket 為使用者帶來即時、流暢的互動體驗! 🚀