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 中,我們通常使用 ws 或 socket.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:message、user: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'));
說明
- 事件路由 讓每種
type的處理邏輯獨立,未來新增功能(例如notification)只需要router.register一行。 - ChannelManager 只負責「誰在頻道」以及「如何廣播」的細節,保持單一職責。
- 斷線時自動清理頻道,避免 記憶體泄漏。
常見陷阱與最佳實踐
| 陷阱 | 為什麼會發生 | 解決方案 / 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. 缺乏驗證與授權 | 任意客戶端都能加入任意頻道、偽造訊息。 | 在連線階段使用 JWT、Cookie Session,在每個事件處理前 驗證 msg.channel 是否有權限。 |
| 6. 事件名稱硬編碼 | 隨著功能擴增,容易出現拼寫錯誤或不一致。 | 建立 Enum(或 const object)集中管理事件名稱。 |
| 7. 不當的錯誤回饋 | 客戶端無法得知失敗原因,除錯成本高。 | 統一 錯誤訊息結構({ error: string, code?: number }),並在 router.dispatch 中捕捉例外。 |
其他最佳實踐
- 使用 TypeScript 型別:在事件與 payload 上做好型別定義,編譯期即可捕捉錯誤。
- 心跳機制(Ping/Pong):
ws自帶心跳,設定heartbeatInterval防止「幽靈連線」佔用資源。 - 水平擴展:若需要跨多台 Node 實例,使用 Redis Pub/Sub 或 NATS 作為訊息中介,讓頻道資訊在所有實例間同步。
- 日誌與監控:將每筆事件寫入結構化日誌(如 Elastic Stack),配合 Prometheus 收集連線數、頻道大小等指標。
實際應用場景
| 場景 | 需要的事件/頻道 | 為什麼 WebSocket 重要 |
|---|---|---|
| 即時聊天系統 | chat:join、chat:leave、chat:message |
用戶必須在毫秒內看到別人的訊息,HTTP 輪詢無法滿足流暢體驗。 |
| 線上多人協作白板 | board:draw、board:clear、board:cursor |
每筆繪圖操作需要即時同步到所有參與者,頻道可對應不同的白板 ID。 |
| 即時通知中心 | notification(單向) |
系統事件(如訂單成立)要立即推送給特定使用者或管理員,避免使用者自行刷新。 |
| 線上遊戲 | game:move、game:start、game:end |
高頻率、低延遲的訊息是遊戲體驗的關鍵;頻道可對應房間或比賽編號。 |
| IoT 即時儀表板 | sensor:update、device:status |
裝置狀態變化要即時呈現在前端,大量感測資料可透過頻道分組。 |
小技巧:在上述場景中,若需要 歷史訊息(例如聊天紀錄),仍可以使用傳統的 REST API 讀取資料庫,再在 WebSocket 中只負責「新訊息」的即時推播,兩者結合最為彈性。
總結
- 事件與頻道 是構建可擴充、易維護即時系統的核心概念。
- 透過 TypeScript 型別、事件路由、以及 ChannelManager,我們可以把 WebSocket 的業務邏輯抽離成獨立模組,讓程式碼保持單一職責、易於測試。
- 常見的陷阱(如記憶體泄漏、未授權的頻道存取)只要在設計階段加入 驗證、清理、心跳 機制,就能大幅降低生產環境的風險。
- 真實專案中,WebSocket 常與 REST API、資料庫、訊息佇列 結合,形成完整的即時架構;而水平擴展則需要藉助 Redis Pub/Sub 或 NATS 之類的分散式系統。
掌握了上述概念與實作範例,你就能在 ExpressJS + TypeScript 的環境下,快速建立從簡易聊天室到大型即時協作平台的各種應用。祝開發順利,期待看到你用 WebSocket 為使用者帶來即時、流暢的互動體驗! 🚀