浏览器 WebSocket:心跳 + 自动重连 + 指数退避(生产级封装)

原生 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 不让设)。常见解:

  1. Token 作为 querywss://...?token=xxx。简单但 token 进 URL log。
  2. 首条消息发 token:连上后立刻 ws.send({ auth: token })
    服务端验证后才允许其它消息。
  3. 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 安全。
精确评价 共 0 人评价
可复现性
可复现 · 0 不可复现 · 0
文风
文风流畅 · 0 文风晦涩 · 0
立场
支持 · 0 反对 · 0

登录后即可对本帖作出评价。

评论区 0 条 · 所有人可在此交流

登录后参与评论。

还没有评论,来说两句。