WebSocket 实时应用
本教程共 76 篇 · 第 73 篇 · 更新于 2026-07-25 · 约 7 分钟阅读
73. WebSocket 实时应用
本节目标:用 ws 构建聊天室,广播、心跳和 Socket.IO 对比。
HTTP 是「请求-响应」模式:客户端问,服务器答,对话结束。如果想做聊天室、股票行情、协同编辑,HTTP 就很别扭了——要么客户端不断轮询(浪费带宽),要么服务器没法主动推数据。WebSocket 解决了这个问题:它在客户端和服务器之间建立一条持久连接,双方随时都能发消息。
Node.js 生态里有两个主流选择:ws 库轻量纯粹,只实现 WebSocket 协议;Socket.IO 功能更丰富,带自动重连、房间、命名空间等高级特性。这节我们先用 ws 搭一个完整聊天室,再对比 Socket.IO 的适用场景。
WebSocket 握手
WebSocket 连接不是凭空建立的。它先走一次 HTTP 请求,服务器返回 101 Switching Protocols,之后连接从 HTTP 升级为 WebSocket。这个过程叫「握手」。
Client Server
| ---- HTTP GET --------> |
| Upgrade: websocket |
| <--- 101 Switching --- |
| Protocols |
| <=== WebSocket =========> |
| 双向数据帧传输 |
一旦握手成功,后续通信就不再需要 HTTP 头,数据帧非常轻量。
用 ws 搭建聊天室
安装依赖:
npm install ws express
服务端(server.js)
import express from 'express'
import { WebSocketServer } from 'ws'
import http from 'http'
import path from 'path'
const app = express()
const server = http.createServer(app)
// 托管静态文件和聊天页面
app.use(express.static('public'))
const wss = new WebSocketServer({ server })
// 存储连接和用户名
const clients = new Map()
function broadcast(data, excludeWs = null) {
const message = JSON.stringify(data)
wss.clients.forEach(ws => {
if (ws !== excludeWs && ws.readyState === 1) {
ws.send(message)
}
})
}
wss.on('connection', (ws) => {
let username = null
ws.on('message', (raw) => {
let msg
try {
msg = JSON.parse(raw)
} catch {
return
}
if (msg.type === 'join') {
username = msg.username || 'Anonymous'
clients.set(ws, username)
ws.send(JSON.stringify({
type: 'system',
text: `Welcome, ${username}!`
}))
broadcast({
type: 'system',
text: `${username} joined the chat`
}, ws)
}
if (msg.type === 'chat' && username) {
broadcast({
type: 'chat',
username,
text: msg.text,
time: new Date().toLocaleTimeString()
})
}
})
ws.on('close', () => {
const name = clients.get(ws)
clients.delete(ws)
if (name) {
broadcast({
type: 'system',
text: `${name} left the chat`
})
}
})
ws.on('error', console.error)
})
// 心跳:每 30 秒 ping 一次,清理死连接
const interval = setInterval(() => {
wss.clients.forEach(ws => {
if (!ws.isAlive) {
return ws.terminate()
}
ws.isAlive = false
ws.ping()
})
}, 30000)
wss.on('close', () => clearInterval(interval))
server.listen(3000, () => {
console.log('Chat server at http://localhost:3000')
})
客户端(public/index.html)
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>WebSocket Chat</title>
<style>
body { font-family: sans-serif; max-width: 600px; margin: 40px auto; padding: 0 20px; }
#messages { border: 1px solid #ddd; height: 400px; overflow-y: auto; padding: 10px; margin-bottom: 10px; }
.msg { margin: 6px 0; }
.system { color: #888; font-style: italic; }
.chat .name { font-weight: bold; color: #333; }
.chat .time { color: #999; font-size: 12px; margin-left: 8px; }
#form { display: flex; gap: 8px; }
input { flex: 1; padding: 10px; font-size: 16px; }
button { padding: 10px 20px; font-size: 16px; cursor: pointer; }
#login { margin-bottom: 20px; }
#chat { display: none; }
</style>
</head>
<body>
<div id="login">
<h2>Enter your name</h2>
<input id="username" placeholder="Your name" value="Tom">
<button onclick="join()">Join</button>
</div>
<div id="chat">
<div id="messages"></div>
<form id="form" onsubmit="send(event)">
<input id="input" placeholder="Type a message..." autocomplete="off">
<button>Send</button>
</form>
</div>
<script>
let ws
function join() {
const name = document.getElementById('username').value.trim() || 'Anonymous'
document.getElementById('login').style.display = 'none'
document.getElementById('chat').style.display = 'block'
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
ws = new WebSocket(`${protocol}//${location.host}`)
ws.onopen = () => {
ws.send(JSON.stringify({ type: 'join', username: name }))
}
ws.onmessage = (event) => {
const msg = JSON.parse(event.data)
const div = document.createElement('div')
div.className = 'msg ' + msg.type
if (msg.type === 'system') {
div.textContent = msg.text
} else {
div.innerHTML = `<span class="name">${msg.username}</span>` +
`<span class="time">${msg.time}</span>` +
`<div>${escapeHtml(msg.text)}</div>`
}
const box = document.getElementById('messages')
box.appendChild(div)
box.scrollTop = box.scrollHeight
}
ws.onclose = () => {
appendSystem('Disconnected from server')
}
}
function send(e) {
e.preventDefault()
const input = document.getElementById('input')
const text = input.value.trim()
if (!text || !ws) return
ws.send(JSON.stringify({ type: 'chat', text }))
input.value = ''
}
function appendSystem(text) {
const div = document.createElement('div')
div.className = 'msg system'
div.textContent = text
document.getElementById('messages').appendChild(div)
}
function escapeHtml(str) {
return str.replace(/[&<>"]/g, c => ({'&':'&','<':'<','>':'>','"':'"'}[c]))
}
</script>
</body>
</html>
创建 public/ 目录,把 index.html 放进去,然后 node server.js。打开多个浏览器标签页,输入名字加入聊天,消息会实时同步到所有客户端。
代码要点拆解
1. 广播(Broadcast)
ws 库没有内置的 broadcast 方法,需要遍历 wss.clients 自己实现。注意过滤掉发送者(可选)和未就绪的连接:
wss.clients.forEach(ws => {
if (ws.readyState === 1) { // 1 = OPEN
ws.send(message)
}
})
2. 心跳(Heartbeat)
WebSocket 连接可能因网络波动「假死」——客户端以为还连着,其实服务器早就收不到了。心跳机制就是定时发 ping 帧,如果对方不回复 pong,就断开连接。
ws.isAlive = true
ws.on('pong', () => { ws.isAlive = true })
// 定时检查
if (!ws.isAlive) ws.terminate()
ws.isAlive = false
ws.ping()
3. 消息协议
裸字符串做通信协议容易乱,建议统一用 JSON,每个消息带 type 字段:
{ "type": "join", "username": "Tom" }
{ "type": "chat", "text": "Hello everyone" }
{ "type": "system", "text": "Tom joined the chat" }
什么时候选 Socket.IO
ws 轻量纯粹,但功能也基础。如果你的需求更复杂,Socket.IO 能省不少事:
- 自动降级:浏览器不支持 WebSocket 时,自动切到 HTTP 长轮询
- 自动重连:断线后自动尝试恢复连接
- 房间(Room):用
socket.join('room1')就能做群聊,不需要自己维护映射表 - 命名空间(Namespace):同一个服务器跑多套逻辑隔离的 WebSocket 服务
import { Server } from 'socket.io'
import { createServer } from 'http'
const httpServer = createServer()
const io = new Server(httpServer)
io.on('connection', (socket) => {
socket.join('lobby')
io.to('lobby').emit('message', 'Someone joined')
})
httpServer.listen(3000)
Socket.IO 的代价是协议 overhead 更大,而且客户端必须加载 Socket.IO 的库,不能直接用浏览器原生 WebSocket。如果你的场景是内部系统、现代浏览器可控,原生 ws 更干净;如果需要兼容旧浏览器或想要开箱即用的房间/重连功能,Socket.IO 更合适。
安全提醒
WebSocket 不遵循浏览器的同源策略(CORS),握手阶段的 HTTP 请求会带 Cookie,但后续数据帧不会自动受 CORS 保护。你需要在连接建立时做身份校验:
wss.on('connection', (ws, req) => {
const token = new URL(req.url, 'http://localhost').searchParams.get('token')
if (!verifyToken(token)) {
ws.close(1008, 'Invalid token')
return
}
})
另外,千万别信任客户端发来的任何数据。聊天室示例里的 escapeHtml 就是为了防止 XSS——如果直接把用户输入的 HTML 插进页面,攻击者可以注入 <script> 偷走 Cookie。