聊天室、協作編輯、即時看盤,這些功能的共通點是伺服器要能主動把資料推給瀏覽器,而不是等瀏覽器來問。HTTP 的請求-回應模型做不到這件事,硬要做就得靠輪詢,每隔幾秒打一次 API,既浪費頻寬又有延遲。WebSocket 就是為了解決這個問題而生的:一條 TCP 連線上跑全雙工通訊,兩邊都能隨時送資料。

這篇用 Python 寫後端、原生 JavaScript 寫前端,建一個能在多個瀏覽器分頁之間互傳訊息的聊天室。程式碼都是可以直接跑的,而且特別避開了 websockets 套件新版本上的幾個地雷——網路上很多教學還在用舊版 API,照抄到新環境會直接報錯。

WebSocket 到底是什麼

WebSocket 是一個獨立的網路協定(ws:// 或加密的 wss://),握手階段借用 HTTP 升級連線,握手完成後就脫離 HTTP,變成一條雙向的長連線。

跟輪詢比起來,它的好處很實際:

  • 伺服器能主動推播,不必等客戶端發問
  • 連線建立後省掉每次 HTTP 標頭的開銷
  • 延遲低,適合聊天、通知、即時資料這類場景

要注意的是 WebSocket 不會自動重連,連線斷了就是斷了,這部分要自己處理,文末會提。

環境準備

後端只需要 Python 3.7 以上和 websockets 套件:

1
pip install websockets

前端不用任何框架,一個 HTML 檔加一段 JavaScript 就夠。

這裡先講一個踩過的坑。websockets 在 13.0 之後改了 API:連線處理函式的第二個參數 path 被棄用,到 14.0 直接移除;同時推薦的伺服器啟動方式也從 asyncio.get_event_loop() 換成了 asyncio.run()。如果你照著舊教學寫 async def handler(websocket, path),在新版上會跳 DeprecationWarning,更新到 14 以後甚至連線都接不起來。下面的程式碼用的是新版寫法。

後端實作

建一個 server.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import asyncio
import logging

from websockets.asyncio.server import serve, broadcast

logging.basicConfig(level=logging.INFO)

connected = set()


async def chat(websocket):
client_id = id(websocket)
connected.add(websocket)
logging.info("新客戶端連線: %s", client_id)
try:
async for message in websocket:
logging.info("收到 %s 的訊息: %s", client_id, message)
others = connected - {websocket}
broadcast(others, f"用戶 {client_id}: {message}")
finally:
connected.discard(websocket)
logging.info("客戶端斷線: %s", client_id)


async def main():
async with serve(chat, "localhost", 8765) as server:
logging.info("伺服器啟動於 ws://localhost:8765")
await server.serve_forever()


if __name__ == "__main__":
asyncio.run(main())

幾個重點:

  • from websockets.asyncio.server import serve, broadcast 用的是新版 asyncio 實作的匯入路徑。舊的 import websockets; websockets.serve(...) 走的是 legacy 實作,雖然還能用但已不建議。
  • chat 只接一個參數 websocket,這是 13.0 之後的正確簽名。
  • 廣播沒有自己寫迴圈一個個 send,而是用套件內建的 broadcast()。原因是:手寫迴圈時如果某個連線中途斷掉,send 會丟例外,整個迴圈就中斷,後面的人都收不到訊息。broadcast() 內部會跳過已關閉的連線,不會因為一個壞掉的連線拖垮整批廣播。
  • connected.discard()connected.remove() 安全,集合裡沒有該元素時不會丟 KeyError
  • 啟動用 asyncio.run(main()) 搭配 async with serve(...),連線生命週期和關閉清理都交給 context manager 管,不用自己拼 event loop。

前端實作

建一個 index.html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="utf-8">
<title>WebSocket 聊天室</title>
<style>
#messages { height: 300px; overflow-y: scroll; border: 1px solid #ccc; padding: 10px; }
.status { color: #888; font-style: italic; }
.error { color: red; }
</style>
</head>
<body>
<h1>WebSocket 聊天室</h1>
<div id="messages"></div>
<input type="text" id="messageInput" placeholder="輸入訊息...">
<button onclick="sendMessage()">發送</button>

<script>
const socket = new WebSocket('ws://localhost:8765');
const messagesDiv = document.getElementById('messages');
const messageInput = document.getElementById('messageInput');

socket.onopen = function () {
addMessage('已連線到伺服器', 'status');
};

socket.onmessage = function (event) {
addMessage(event.data, 'message');
};

socket.onclose = function (event) {
addMessage('與伺服器斷線(代碼 ' + event.code + ')', 'status');
};

socket.onerror = function () {
// error 事件是一個 generic Event,沒有 message 屬性,
// 瀏覽器基於安全性不會在此暴露錯誤細節,所以這裡只能提示「發生錯誤」。
// 真正的斷線原因要看 close 事件的 code。
addMessage('WebSocket 連線發生錯誤', 'error');
};

function sendMessage() {
const message = messageInput.value;
if (message) {
socket.send(message);
addMessage('你: ' + message, 'message');
messageInput.value = '';
}
}

function addMessage(message, type) {
const messageElement = document.createElement('p');
messageElement.textContent = message;
messageElement.className = type;
messagesDiv.appendChild(messageElement);
messagesDiv.scrollTop = messagesDiv.scrollHeight;
}
</script>
</body>
</html>

這裡有個很多教學會寫錯的地方:onerror 的回呼參數。

WebSocket 的 error 事件依規範是一個 generic Event不是 ErrorEvent,所以它沒有 message 屬性。如果你寫 'WebSocket錯誤: ' + error.message,畫面上會顯示「WebSocket錯誤: undefined」,反而誤導人。瀏覽器基於安全考量也不會在這個事件裡透露錯誤細節。

要知道連線為什麼掛掉,正確的做法是看 close 事件的 code(例如 1006 通常代表非正常關閉),上面 onclose 就把代碼一併顯示出來了。

跑起來測試

啟動伺服器:

1
python server.py

然後在瀏覽器直接打開 index.html,再開幾個分頁模擬不同的人。在任一個分頁送訊息,其他分頁應該都會立刻收到「用戶 xxxxx: ...」。終端機那邊也會印出連線、收訊、斷線的日誌。

如果送出後其他分頁沒反應,先看終端機有沒有 DeprecationWarningTypeError——那幾乎都是 handler 簽名或啟動方式還停在舊版的徵兆。

從這裡再往上走

這個聊天室能跑,但離正式環境還差不少。幾個值得補的方向:

  • **改用 wss://**:正式環境一定要走加密連線,否則訊息在路上是明文。通常會在 Nginx 之類的反向代理上掛 TLS,再轉發到後端的 ws
  • 加身分驗證:握手時帶 token 驗證身分,現在這版任何人連上就能發言,而且「用戶」名稱只是物件的記憶體位址,並非真正的帳號。
  • 重連機制:前端在 onclose 裡做指數退避重連,斷線後自動補上,使用者才不會默默掉線。
  • 結構化訊息:純文字夠示範,但要傳「誰、什麼時間、什麼類型」這種資料,改用 JSON 會清楚很多,前後端各自 JSON.parse / json.dumps
  • 水平擴展:單機的 connected 集合撐不了多台伺服器。要擴展時,得靠 Redis Pub/Sub 之類的外部訊息匯流排,讓跨機器的連線也能互通。

把這個能跑的版本當底,照上面幾點一條條加,就能慢慢長成一個真正能上線的即時系統。重點是先讓最小可用版本動起來——用對的 API、廣播別被單一斷線拖垮、錯誤事件別亂讀屬性,這三件事做對了,後面的功能才好往上疊。