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 庫(如ws、socket.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-dev或nodemon+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.clients為Set<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/Sub 或 Message 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');
}
},
});
注意:
ws的verifyClient已在 v8 被標記為 deprecated,新版建議使用handleProtocols或自行在connection事件前檢查。這裡僅示範概念,實務上可改用express-ws或socket.io內建的中介層。
常見陷阱與最佳實踐
| 陷阱 | 可能的後果 | 解決方案 / 最佳實踐 |
|---|---|---|
忘記檢查 readyState |
嘗試向已關閉的連線傳送會拋錯,導致伺服器崩潰 | 在每次 send 前 if (ws.readyState === WebSocket.OPEN) |
| 單機記憶體保存所有連線 | 大量使用者時記憶體爆炸,且無法跨多台實例共享狀態 | 使用 Redis、NATS 或 Kafka 做訊息分發與房間同步 |
| 未設定心跳(ping/pong) | 客戶端斷線卻未被偵測,資源無法釋放 | 設定 setInterval(() => ws.ping(), 30_000),並在 pong 事件更新最後回應時間 |
| 直接把大量資料寫入 WS | 佔用頻寬、造成卡頓 | 使用 二進位 (binary) 傳輸、分片 (fragmentation) 或 壓縮 (permessage-deflate) |
把業務邏輯寫在 connection 事件裡 |
代碼難以維護、測試困難 | 把訊息處理抽離為 service 或 controller,使用 DI(依賴注入)或 NestJS 之類的框架 |
| 未處理例外 | 例外未捕獲會導致整個 WebSocket 伺服器關閉 | 包裝 try/catch,或使用 process.on('unhandledRejection') 監控全局錯誤 |
其他最佳實踐
- 使用 TypeScript 型別:為訊息協議(protocol)建立介面,如
interface ChatMessage { action: 'msg'; room: string; msg: string; },可在編譯階段捕捉錯誤。 - 分層設計:把 WebSocket 伺服器、訊息路由、業務服務 分離,讓單元測試更容易。
- 安全性:
- TLS/SSL:在生產環境務必使用
wss://(HTTPS 版的 WebSocket)。 - 來源檢查(Origin):在
verifyClient或handleProtocols中驗證origin欄位。 - 訊息驗證:所有傳入的 JSON 必須走 schema validation(如
ajv)以防止注入攻擊。
- TLS/SSL:在生產環境務必使用
- 監控與日誌:將 連線數、訊息速率、錯誤率 透過 Prometheus、Grafana 或 Elastic 監控,便於即時調整資源。
- 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 的即時應用開發之路上,玩得開心、寫得順利! 🚀