本文 AI 產出,尚未審核

ExpressJS (TypeScript) – WebSocket 與 Real‑time:與 Express Server 整合


簡介

在現代的 Web 應用程式中,即時(real‑time)互動已成為不可或缺的功能。聊天系統、即時通知、協同編輯、線上遊戲等,都需要伺服器能夠在毫秒級別推送訊息給客戶端。
傳統的 HTTP 請求是單向的、請求‑回應模式,無法滿足「伺服器主動推送」的需求。WebSocket 正是為此而生,它在單一 TCP 連線上同時支援雙向全雙工通訊,讓前端與後端可以即時交換資料。

本單元將說明 如何在 Express(使用 TypeScript)中整合 WebSocket,一步步帶你從環境設定、基本範例、到最佳實踐與常見陷阱,讓你能在專案中快速加入即時功能。


核心概念

1. WebSocket 與 HTTP 的關係

  • 升級(Upgrade):WebSocket 連線是由普通的 HTTP 請求升級而來。客戶端發送 Upgrade: websocket 的請求,伺服器回應 101 Switching Protocols 後,連線即切換為 WebSocket。
  • 雙向全雙工:升級成功後,客戶端與伺服器皆可隨時 push 訊息,無需再發起新的 HTTP 請求。
  • 保持連線:WebSocket 連線在整個會話期間保持開啟,除非主動關閉或因網路問題斷線。

重點:在 Express 中直接使用 app.listen() 只能建立 HTTP 伺服器,若要支援 WebSocket,必須先取得底層的 http.Server 物件,然後把它交給 WebSocket 庫(如 wssocket.io)進行升級。


2. 常見的 WebSocket 庫

特色 適合情境
ws 輕量、原生 WebSocket API、相容性好 需要自行管理事件、想保留較低的抽象層
socket.io 自動回退(fallback)至長輪詢、內建房間與命名空間 需要快速開發、需要跨瀏覽器兼容性
@nestjs/websockets NestJS 框架的官方支援 已使用 NestJS 的大型專案

本教學以 ws 為例,因為它最貼近原始 WebSocket 規範,且與 Express 整合方式最直觀。


3. TypeScript 與 Express 的型別支援

  • express 本身提供完整的型別宣告 (@types/express)。
  • ws 也有官方型別 (@types/ws)。
  • 透過 ts-node-devnodemon + tsc,可以在開發階段即時編譯與重啟。

程式碼範例

以下示範 從零開始 建立一個支援 WebSocket 的 Express + TypeScript 專案,並提供 5 個實用範例。

3.1. 初始化專案與安裝相依套件

mkdir express-ws-demo
cd express-ws-demo
npm init -y

# 安裝 Express、ws 以及 TypeScript 相關套件
npm i express ws
npm i -D typescript ts-node-dev @types/express @types/ws

建立 tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "module": "commonjs",
    "outDir": "./dist",
    "rootDir": "./src",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true
  }
}

package.json 加入開發指令:

"scripts": {
  "dev": "ts-node-dev --respawn src/index.ts",
  "build": "tsc",
  "start": "node dist/index.js"
}

3.2. 建立基本的 Express + HTTP Server

src/index.ts

import express, { Request, Response } from 'express';
import http from 'http';

const app = express();
const port = 3000;

// 基本路由
app.get('/', (req: Request, res: Response) => {
  res.send('Hello Express + TypeScript');
});

// 取得底層的 http.Server,稍後會交給 ws 使用
const server = http.createServer(app);

server.listen(port, () => {
  console.log(`🚀 Server listening on http://localhost:${port}`);
});

說明http.createServer(app) 讓我們同時擁有 Express(處理 REST API)與 原始 HTTP Server(供 WebSocket 升級使用)。

3.3. 加入 ws 並建立最簡單的 Echo 伺服器

import { WebSocketServer, WebSocket } from 'ws';

// 建立 WebSocketServer,將 http.Server 作為底層
const wss = new WebSocketServer({ server });

wss.on('connection', (ws: WebSocket, req) => {
  console.log('🔗 New client connected:', req.socket.remoteAddress);

  // 直接把收到的訊息回傳(Echo)
  ws.on('message', (data) => {
    console.log('📥 Received:', data.toString());
    ws.send(`Echo: ${data}`);
  });

  ws.on('close', () => {
    console.log('❌ Client disconnected');
  });
});

重點connection 事件在每次 WebSocket 握手成功 時觸發,ws 參數即是該連線的實例,可用來收發訊息。

3.4. 客戶端範例(HTML + JavaScript)

public/index.html

<!DOCTYPE html>
<html lang="zh-TW">
<head>
  <meta charset="UTF-8">
  <title>WebSocket Echo Demo</title>
</head>
<body>
  <h1>WebSocket Echo Demo</h1>
  <input id="msg" placeholder="輸入訊息" />
  <button id="sendBtn">送出</button>
  <pre id="log"></pre>

  <script>
    const logEl = document.getElementById('log');
    const ws = new WebSocket(`ws://${location.host}`);

    ws.onopen = () => log('✅ 連線已開啟');
    ws.onmessage = (e) => log('🔔 收到:' + e.data);
    ws.onclose = () => log('❌ 連線已關閉');

    document.getElementById('sendBtn').onclick = () => {
      const msg = (document.getElementById('msg') as HTMLInputElement).value;
      ws.send(msg);
      log('📤 送出:' + msg);
    };

    function log(message) {
      logEl.textContent += message + '\n';
    }
  </script>
</body>
</html>

src/index.ts 加入靜態檔案服務:

app.use(express.static('public'));

現在啟動 npm run dev,瀏覽器開啟 http://localhost:3000,即可測試 Echo 功能。

3.5. Broadcast(廣播)範例:聊天室的基礎

wss.on('connection', (ws) => {
  ws.on('message', (msg) => {
    // 將收到的訊息廣播給所有連線的客戶端
    wss.clients.forEach((client) => {
      if (client.readyState === WebSocket.OPEN) {
        client.send(`[Broadcast] ${msg}`);
      }
    });
  });
});

說明wss.clientsSet<WebSocket>,可遍歷所有已連線的客戶端。務必檢查 readyState,避免向已關閉的連線發送訊息。

3.6. 使用房間(Room)概念篩選廣播對象

雖然 ws 本身沒有內建「房間」功能,我們可以自行維護一個 Map<string, Set<WebSocket>>

type RoomMap = Map<string, Set<WebSocket>>;
const rooms: RoomMap = new Map();

// 加入房間
function joinRoom(roomId: string, ws: WebSocket) {
  if (!rooms.has(roomId)) rooms.set(roomId, new Set());
  rooms.get(roomId)!.add(ws);
}

// 從房間移除
function leaveRoom(roomId: string, ws: WebSocket) {
  rooms.get(roomId)?.delete(ws);
}

// 廣播給特定房間
function broadcastToRoom(roomId: string, data: string) {
  rooms.get(roomId)?.forEach((client) => {
    if (client.readyState === WebSocket.OPEN) client.send(data);
  });
}

// 連線事件中使用
wss.on('connection', (ws) => {
  ws.on('message', (raw) => {
    const payload = JSON.parse(raw.toString());

    // 簡易協議:{action: 'join', room: 'room1'}
    if (payload.action === 'join') {
      joinRoom(payload.room, ws);
      ws.send(`已加入房間 ${payload.room}`);
    } else if (payload.action === 'msg') {
      broadcastToRoom(payload.room, `[${payload.room}] ${payload.msg}`);
    }
  });

  ws.on('close', () => {
    // 移除所有房間的引用(簡化版)
    rooms.forEach((set) => set.delete(ws));
  });
});

實務技巧:在正式專案中,建議使用 Redis Pub/SubMessage Queue 來同步多台伺服器的房間狀態,避免單點記憶體限制。

3.7. 與 Express 路由結合:驗證與授權

以下示範在 WebSocket 握手階段 讀取 Express 的 JWT 中介軟體,確保只有已驗證的使用者能建立連線:

import jwt from 'jsonwebtoken';
import { IncomingMessage } from 'http';

function verifyToken(req: IncomingMessage): string | null {
  const token = req.headers['sec-websocket-protocol']; // 客戶端可於此傳遞 token
  if (!token) return null;
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET!);
    return (payload as any).userId;
  } catch {
    return null;
  }
}

// 在建立 wsServer 時加入驗證
const wss = new WebSocketServer({
  server,
  verifyClient: (info, done) => {
    const userId = verifyToken(info.req);
    if (userId) {
      // 把 userId 暫存於 req 供後續使用
      (info.req as any).userId = userId;
      done(true);
    } else {
      done(false, 401, 'Unauthorized');
    }
  },
});

注意wsverifyClient 已在 v8 被標記為 deprecated,新版建議使用 handleProtocols 或自行在 connection 事件前檢查。這裡僅示範概念,實務上可改用 express-wssocket.io 內建的中介層。


常見陷阱與最佳實踐

陷阱 可能的後果 解決方案 / 最佳實踐
忘記檢查 readyState 嘗試向已關閉的連線傳送會拋錯,導致伺服器崩潰 在每次 sendif (ws.readyState === WebSocket.OPEN)
單機記憶體保存所有連線 大量使用者時記憶體爆炸,且無法跨多台實例共享狀態 使用 RedisNATSKafka 做訊息分發與房間同步
未設定心跳(ping/pong) 客戶端斷線卻未被偵測,資源無法釋放 設定 setInterval(() => ws.ping(), 30_000),並在 pong 事件更新最後回應時間
直接把大量資料寫入 WS 佔用頻寬、造成卡頓 使用 二進位 (binary) 傳輸分片 (fragmentation)壓縮 (permessage-deflate)
把業務邏輯寫在 connection 事件裡 代碼難以維護、測試困難 把訊息處理抽離為 servicecontroller,使用 DI(依賴注入)或 NestJS 之類的框架
未處理例外 例外未捕獲會導致整個 WebSocket 伺服器關閉 包裝 try/catch,或使用 process.on('unhandledRejection') 監控全局錯誤

其他最佳實踐

  1. 使用 TypeScript 型別:為訊息協議(protocol)建立介面,如 interface ChatMessage { action: 'msg'; room: string; msg: string; },可在編譯階段捕捉錯誤。
  2. 分層設計:把 WebSocket 伺服器訊息路由業務服務 分離,讓單元測試更容易。
  3. 安全性
    • TLS/SSL:在生產環境務必使用 wss://(HTTPS 版的 WebSocket)。
    • 來源檢查(Origin):在 verifyClienthandleProtocols 中驗證 origin 欄位。
    • 訊息驗證:所有傳入的 JSON 必須走 schema validation(如 ajv)以防止注入攻擊。
  4. 監控與日誌:將 連線數、訊息速率、錯誤率 透過 PrometheusGrafanaElastic 監控,便於即時調整資源。
  5. Graceful Shutdown:在 process.on('SIGTERM') 時,先關閉 HTTP、WebSocket,等待所有客戶端斷線再結束。

實際應用場景

場景 為何需要 WebSocket 實作要點
即時聊天 必須即時推送訊息、顯示「正在輸入」狀態 使用房間(room)管理不同聊天室、加入心跳保活、透過 Redis Pub/Sub 跨多實例同步訊息
線上遊戲 需要毫秒級別的狀態同步 使用二進位 (binary) 傳輸、分片、伺服器端頻繁更新遊戲狀態、使用 UDP 替代方案(如 WebRTC)作更低延遲
即時通知(如訂單狀態、系統警示) 讓使用者不必手動刷新即可獲得最新資訊 只要在特定事件觸發時 broadcast 給相關使用者,配合 JWT 驗證保護訊息
協同編輯(Google Docs 類) 多人同時編輯文件,需要即時合併變更 使用 OT(Operational Transformation)或 CRDT 演算法,訊息頻繁且需保證順序,一般會加上序列號與確認機制
儀表板即時圖表 大量感測器資料即時顯示 透過 ws 推送 JSON/CSV,前端使用 Chart.js、ECharts 直接更新圖表,注意資料壓縮與批次發送

總結

  • WebSocket 為即時雙向通訊提供了最直接、效能最好的解決方案。
  • Express + TypeScript 中,我們透過 http.createServer 取得底層伺服器,然後把它交給 ws 建立 WebSocketServer,完成 升級與整合
  • 本文提供了 Echo、Broadcast、Room、驗證 四個實用範例,並說明了 型別、心跳、錯誤處理 等關鍵細節。
  • 常見陷阱包括 未檢查 readyState、單機記憶體瓶頸、缺乏心跳 等,對應的最佳實踐則是使用 Redis、TLS、型別安全、監控 等手段。
  • 無論是聊天、通知、協同編輯或即時儀表板,WebSocket 都是不可或缺的基礎建設,只要遵循上述的 安全、可擴充、可維護 原則,就能在專案中穩定、快速地交付即時功能。

祝你在 Express + TypeScript 的即時應用開發之路上,玩得開心、寫得順利! 🚀