原生 WebSocket 没有自动重连、没有心跳检测、断网时 send 直接丢。
生产用都得自己包一层。下面是一个 50 行的实用实现。
1. 用法目标
const ws = new ResilientWS('wss://api.example.com/ws')
ws.on('message', (msg) => console.log(msg))
ws.on('open', () => console.log('connected'))
ws.on('close', () => console.log('disconnected'))
ws.send({ type: 'subscribe', channel: 'orders' })
// 如果当前断开,先排队;连上后自动发出去
2. 实现
type Listener<T> = (data: T) => void
interface ResilientWSOptions {
reconnectMaxDelay?: number // 默认 30s
heartbeatInterval?: number // 默认 25s
heartbeatMessage?: string // 默认 'ping'
}
export class ResilientWS {
private url: string
private ws: WebSocket | null = null
private listeners = new Map<string, Set<Listener<any>>>()
private queue: any[] = []
private retries = 0
private heartbeatTimer?: number
private reconnectTimer?: number
private opts: Required<ResilientWSOptions>
private alive = true
constructor(url: string, opts: ResilientWSOptions = {}) {
this.url = url
this.opts = {
reconnectMaxDelay: opts.reconnectMaxDelay ?? 30000,
heartbeatInterval: opts.heartbeatInterval ?? 25000,
heartbeatMessage: opts.heartbeatMessage ?? 'ping',
}
this.connect()
}
private connect() {
if (!this.alive) return
this.ws = new WebSocket(this.url)
this.ws.onopen = () => {
this.retries = 0
this.emit('open')
this.startHeartbeat()
// 重发排队的消息
this.queue.forEach(m => this.ws!.send(m))
this.queue = []
}
this.ws.onmessage = (e) => {
// 服务端 pong 不要传给用户
if (e.data === 'pong') return
try { this.emit('message', JSON.parse(e.data)) }
catch { this.emit('message', e.data) }
}
this.ws.onclose = () => {
this.stopHeartbeat()
this.emit('close')
this.scheduleReconnect()
}
this.ws.onerror = (e) => {
this.emit('error', e)
// 让 onclose 触发重连
}
}
private scheduleReconnect() {
if (!this.alive) return
// 指数退避:1s, 2s, 4s, 8s, ... 上限 reconnectMaxDelay
const delay = Math.min(
1000 * Math.pow(2, this.retries),
this.opts.reconnectMaxDelay
)
// 加 ±20% 抖动,避免多客户端同时重连打爆服务端
const jittered = delay * (0.8 + Math.random() * 0.4)
this.retries++
this.reconnectTimer = window.setTimeout(() => this.connect(), jittered)
}
private startHeartbeat() {
this.heartbeatTimer = window.setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(this.opts.heartbeatMessage)
}
}, this.opts.heartbeatInterval)
}
private stopHeartbeat() {
if (this.heartbeatTimer) clearInterval(this.heartbeatTimer)
}
send(data: any) {
const text = typeof data === 'string' ? data : JSON.stringify(data)
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(text)
} else {
this.queue.push(text)
}
}
on(event: string, fn: Listener<any>) {
if (!this.listeners.has(event)) this.listeners.set(event, new Set())
this.listeners.get(event)!.add(fn)
return () => this.listeners.get(event)?.delete(fn)
}
private emit(event: string, data?: any) {
this.listeners.get(event)?.forEach(fn => fn(data))
}
close() {
this.alive = false
this.stopHeartbeat()
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.ws?.close()
}
}
3. 心跳的作用
许多防火墙 / 反代会在空闲连接 60-300 秒后悄悄断(不发 close 帧)。
客户端 ws 的 readyState 还是 OPEN,但 send 出去服务端永远收不到。
每 25 秒主动发个 ping 字符串,服务端回 pong,强制流量保持连接活跃。
服务端配合:
# FastAPI 示例
@app.websocket('/ws')
async def ws(websocket: WebSocket):
await websocket.accept()
while True:
msg = await websocket.receive_text()
if msg == 'ping':
await websocket.send_text('pong')
continue
# 业务处理
4. 指数退避 + 抖动
重连立即重试会把服务端打爆:
- 1 秒后,2 秒后,4 秒后,8 秒后...
- 上限 30 秒,避免太久不重连
加随机抖动(jitter)避免"惊群"——大量客户端同时断线后同时重连。
5. 离线 / 在线监听
浏览器有 online / offline 事件:
window.addEventListener('online', () => {
// 用户网络恢复,立刻重连(不必等当前 backoff timer)
ws.forceReconnect()
})
window.addEventListener('offline', () => {
console.log('network offline')
})
加到 ResilientWS 里:
constructor(...) {
...
window.addEventListener('online', () => {
if (this.ws?.readyState !== WebSocket.OPEN) {
this.retries = 0
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
this.connect()
}
})
}
6. visibilitychange:tab 被切到后台
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
// tab 重新可见,确认连接还活着
if (this.ws?.readyState !== WebSocket.OPEN) ws.forceReconnect()
}
})
7. React Hook 封装
import { useEffect, useState } from 'react'
export function useWebSocket<T>(url: string) {
const [messages, setMessages] = useState<T[]>([])
const [connected, setConnected] = useState(false)
const [send, setSend] = useState<(d: any) => void>(() => () => {})
useEffect(() => {
const ws = new ResilientWS(url)
ws.on('open', () => setConnected(true))
ws.on('close', () => setConnected(false))
ws.on('message', (m) => setMessages(prev => [...prev, m]))
setSend(() => (d: any) => ws.send(d))
return () => ws.close()
}, [url])
return { messages, connected, send }
}
8. 选择:原生 WebSocket vs Socket.IO
- 原生 + ResilientWS(上):协议轻量,控制完整
- Socket.IO:自带 fallback (polling) + room / namespace / ack,
但流量大,跨语言客户端少 - SockJS:纯 ws fallback
- Centrifugo:高性能 ws 服务端 + 多语言客户端
后端 Python 用 FastAPI / Django Channels / Sanic 都好;
Node 用 ws 库 / socket.io。
9. 鉴权
WebSocket 没有标准 Authorization header(浏览器 API 不让设)。常见解:
- Token 作为 query:
wss://...?token=xxx。简单但 token 进 URL log。 - 首条消息发 token:连上后立刻
ws.send({ auth: token }),
服务端验证后才允许其它消息。 - Cookie:浏览器自动带 Cookie,服务端从 Cookie 取 session。
跨域要 CORS 配。
10. 调试
Chrome DevTools → Network → WS 标签:
- 看每条消息的方向 / 内容 / 时间
- 看连接打开 / 关闭事件
- 看 ping / pong 是否正常
踩过的坑
- 旧的 React Strict Mode 双调用 useEffect → 连两个 ws。要么用 ref
保存实例,要么 cleanup 函数里 close。 - 关掉 tab 时浏览器不会等 ws.close() 完成 → 服务端看到的是异常断开。
服务端不要 assume 客户端会优雅退出。 - 重连 storm:服务端挂了 → 1k 客户端同时重连 → 服务端起来又被打挂。
加抖动 + 上限 + 服务端限流。 - 心跳间隔 < 反代超时;nginx 默认 60s,心跳 25s 安全。
登录后参与评论。