做聊天、协作编辑、实时推送时,第一个要回答的问题就是「用什么通道」。很多教程讲完 API 就结束了,但真正上线后翻车的点几乎全在心跳、重连、鉴权和代理配置上。这篇文章从握手讲到生产加固,目标是让你一次做对。
为什么是 WebSocket:四种通道对比
| 方案 | 方向 | 实时性 | 开销 | 适用场景 |
|---|---|---|---|---|
| 短轮询 | 客户端拉 | 差(间隔决定) | 高(大量空请求) | 兼容性兜底、低频状态 |
| 长轮询 | 客户端拉 | 较好 | 较高(请求生命周期长) | 旧浏览器、消息频率低 |
| SSE | 服务端推(单向) | 好 | 低 | 通知流、日志流、AI 流式输出 |
| WebSocket | 双向 | 好 | 低(一次握手长连接) | 聊天、协作、游戏、行情 |
选型口径:只需要服务端往下推就用 SSE(自动重连、走普通 HTTP 基础设施);需要双向低延迟交互才上 WebSocket。
握手:从 HTTP 升级到 101
WebSocket 的起点是一个普通的 HTTP 请求,带上特殊的 Upgrade 头。服务器同意升级时返回 101 Switching Protocols,之后这条 TCP 连接就不再说 HTTP 了:
# 客户端请求(关键头)
GET /ws HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
# 服务端同意升级
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
所以 WebSocket 的连接地址是 ws://(加密版 wss://),但底层握手复用了 80/443 端口,能穿过大多数只有 HTTP 出入口的网络。
浏览器 API:四行核心代码
const ws = new WebSocket('wss://example.com/ws?token=' + encodeURIComponent(token));
ws.addEventListener('open', () => {
console.log('已连接');
ws.send(JSON.stringify({ type: 'hello', room: 'lobby' }));
});
ws.addEventListener('message', (event) => {
const msg = JSON.parse(event.data); // 约定 JSON 协议,别裸传字符串
switch (msg.type) {
case 'chat': renderChat(msg.payload); break;
case 'pong': lastPongAt = Date.now(); break; // 心跳回执
default: console.warn('未知消息类型', msg.type);
}
});
ws.addEventListener('close', (event) => {
console.log('连接关闭', event.code, event.reason);
// 注意:这里不直接重连,交给重连管理器统一处理(见下文)
});
// ws.send() 只能在 open 之后调用;关闭后调用 send 会抛错
一个高频错误:在 open 之前调用 send。构造 WebSocket 是异步的,正确的做法是把「待发送队列」缓存起来,open 后统一 flush。
服务端:Node.js + ws 最小可用实现
// npm i ws
const { WebSocketServer } = require('ws');
const wss = new WebSocketServer({ port: 3000, path: '/ws' });
wss.on('connection', (ws, req) => {
// 每连接一个状态对象:用户、房间、心跳时间
ws.meta = { userId: null, aliveAt: Date.now() };
ws.on('message', (raw) => {
let msg;
try {
msg = JSON.parse(raw);
} catch {
return ws.close(1008, 'invalid json'); // 协议错误直接断开
}
switch (msg.type) {
case 'auth':
ws.meta.userId = verifyToken(msg.token); // 见下文鉴权
break;
case 'ping':
ws.send(JSON.stringify({ type: 'pong' }));
break;
case 'chat':
if (!ws.meta.userId) return ws.close(1008, 'unauthorized');
broadcast(msg.room, { type: 'chat', payload: msg.payload });
break;
}
});
ws.on('close', () => cleanup(ws));
});
function broadcast(room, data) {
const text = JSON.stringify(data);
for (const client of wss.clients) {
if (client.readyState === client.OPEN) client.send(text);
}
}
生产环境还要补三块:房间关系表(谁在哪个房间)、消息限流(防刷屏)、背压处理(ws.bufferedAmount 过大说明客户端消费不过来,该丢推送或限速)。
心跳保活:让死连接暴露出来
TCP 连接可能因为网络切换、NAT 超时、代理静默丢弃而「假活」——双方都不知道对方已经没了。所以要有应用层心跳:
// 服务端:30 秒一轮,没回 pong 的直接终止
const HEARTBEAT_MS = 30000;
setInterval(() => {
for (const ws of wss.clients) {
if (Date.now() - ws.meta.aliveAt > HEARTBEAT_MS * 2) {
ws.terminate(); // 强制断开,比 close 更彻底
continue;
}
if (ws.readyState === ws.OPEN) {
ws.ping(); // 协议层 ping(浏览器不可见,自动回 pong)
}
}
}, HEARTBEAT_MS);
// 浏览器端看不到协议 ping,需要应用层心跳配合(或仅用服务端 ping 保活 + 客户端超时检测)
// 浏览器端:应用层心跳 + 超时判定(简单可靠)
function startHeartbeat(ws) {
const HEARTBEAT_MS = 25000;
let lastPongAt = Date.now();
const timer = setInterval(() => {
if (Date.now() - lastPongAt > HEARTBEAT_MS * 2.5) {
ws.close(4000, 'heartbeat timeout'); // 触发重连流程
return;
}
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, HEARTBEAT_MS);
return {
onPong: () => { lastPongAt = Date.now(); },
stop: () => clearInterval(timer),
};
}
参数经验:心跳间隔 20–30 秒;服务端判定超时 = 间隔 × 2;Nginx 的 proxy_read_timeout 必须大于心跳间隔,否则连接会被代理先静默掐掉(配置见下文)。
断线重连:指数退避 + 抖动
断线是常态(服务发布、网络切换、手机锁屏),重连逻辑必须写成「无限重试但不过载」:
function createReconnectingSocket(url) {
let ws = null;
let attempt = 0;
let closedByUser = false;
const listeners = { message: [] };
function connect() {
ws = new WebSocket(url);
ws.addEventListener('open', () => {
attempt = 0; // 成功后重置退避
heartbeat = startHeartbeat(ws);
flushQueue(); // 补发断线期间排队的消息
});
ws.addEventListener('message', (e) => {
const msg = JSON.parse(e.data);
if (msg.type === 'pong') return heartbeat.onPong();
listeners.message.forEach((fn) => fn(msg));
});
ws.addEventListener('close', () => {
heartbeat && heartbeat.stop();
if (closedByUser) return;
scheduleReconnect();
});
}
function scheduleReconnect() {
attempt += 1;
// 1s, 2s, 4s, 8s... 封顶 30s;加 0~1s 抖动避免惊群
const base = Math.min(1000 * 2 ** (attempt - 1), 30000);
const delay = base + Math.random() * 1000;
console.log(`第 ${attempt} 次重连将在 ${Math.round(delay)}ms 后`);
setTimeout(connect, delay);
}
let queue = [];
function send(obj) {
const text = JSON.stringify(obj);
if (ws && ws.readyState === WebSocket.OPEN) ws.send(text);
else queue.push(text); // 断线期间进队列,重连后补发
}
function flushQueue() {
const pending = queue;
queue = [];
pending.forEach((text) => ws.send(text));
}
connect();
return {
send,
onMessage: (fn) => listeners.message.push(fn),
close: () => { closedByUser = true; ws && ws.close(1000, 'bye'); },
};
}
三个注意点:退避封顶(不然重试间隔会涨到几天)、加随机抖动(服务端重启时避免所有客户端同一毫秒回归)、区分主动关闭和被动断开(用户主动离开就不该重连)。
鉴权与安全
- 用 wss://:明文 ws 会被中间人完整读取;安静信道上也要加密,别省这一步;
- Token 放在首条消息里(推荐)或 URL query(会进日志,注意脱敏);浏览器 API 无法自定义握手头,这是现实约束;
- 握手后立即校验:约定 5 秒内没完成 auth 的连接直接断开,防止匿名连接占资源;
- 校验 Origin:WebSocket 不受同源策略约束,攻击者页面可以直连你的服务,必须用 Origin 白名单挡住;
- 消息也要校验:大小限制(如 64KB)、类型白名单、频率限制,别假设客户端会守规矩。
// Node 侧 Origin 校验示例
wss.on('connection', (ws, req) => {
const origin = req.headers.origin || '';
const allowed = ['https://example.com', 'https://www.example.com'];
if (!allowed.includes(origin)) {
ws.close(1008, 'origin not allowed');
return;
}
// ...继续
});
部署:Nginx 必须放行的两个头
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
location /ws/ {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_read_timeout 300s; # 要大于心跳间隔!
}
多实例部署时还有两个问题:粘性会话(同一用户要落到同一实例,或把广播改成 Redis Pub/Sub 跨实例分发)和优雅停机(收到 SIGTERM 先给所有连接发「即将重启」消息再关,客户端会平滑重连)。
总结
把生产环境必须做对的清单收个尾:wss 加密 → 首条消息鉴权 → Origin 白名单 → 20–30 秒心跳 → 指数退避 + 抖动重连 → 断线消息队列补发 → Nginx 升级头与超时 → 多实例广播方案。协议本身半小时能学会,把这份清单做完,才算真正「上线可用」。