Skip to content

WebSocket 连接管理与重连

WebSocket 连接可能因为网络波动、服务器重启等原因意外断开。为了保证应用的可靠性,需要实现心跳检测、断线重连和错误处理等机制。本节介绍 WebSocket 连接管理的核心策略,包括心跳机制、指数退避重连算法和完整的连接管理封装。

前置知识

阅读本节前,建议先了解:WebSocket 基础

基础概念

连接管理的重要性

WebSocket 是长连接,需要应对各种网络异常:

场景现象处理策略
网络断开无 close 事件触发心跳超时检测
服务器重启收到 close(1006)自动重连
网络切换(WiFi->4G)连接中断自动重连
服务器过载收到 close(1013)延迟重连
用户离开页面卸载主动关闭连接

语法与实现

心跳机制

javascript
class WebSocketWithHeartbeat {
  constructor(url, options = {}) {
    this.url = url;
    this.heartbeatInterval = options.heartbeatInterval || 30000; // 30 秒
    this.heartbeatTimeout = options.heartbeatTimeout || 5000;   // 5 秒超时
    this.reconnectInterval = options.reconnectInterval || 3000;
    this.maxReconnectAttempts = options.maxReconnectAttempts || Infinity;
    this.ws = null;
    this.heartbeatTimer = null;
    this.timeoutTimer = null;
    this.reconnectAttempts = 0;
    this.isManualClose = false;
  }

  connect() {
    this.ws = new WebSocket(this.url);
    this.reconnectAttempts++;

    this.ws.onopen = () => {
      console.log('WebSocket 连接已建立');
      this.reconnectAttempts = 0;
      this.startHeartbeat();
    };

    this.ws.onmessage = (event) => {
      if (event.data === 'pong') {
        // 收到心跳响应,重置超时计时器
        clearTimeout(this.timeoutTimer);
        return;
      }
      // 处理业务消息
      this.onMessage(event);
    };

    this.ws.onclose = (event) => {
      this.stopHeartbeat();
      if (!this.isManualClose) {
        console.log(`连接断开 (code: ${event.code}),尝试重连...`);
        this.reconnect();
      }
    };

    this.ws.onerror = (event) => {
      this.onError(event);
    };
  }

  // 启动心跳
  startHeartbeat() {
    this.stopHeartbeat();
    this.heartbeatTimer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send('ping');

        // 设置超时
        this.timeoutTimer = setTimeout(() => {
          console.warn('心跳超时,关闭连接');
          this.ws.close();
        }, this.heartbeatTimeout);
      }
    }, this.heartbeatInterval);
  }

  // 停止心跳
  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
    clearTimeout(this.timeoutTimer);
  }

  // 重连
  reconnect() {
    if (this.reconnectAttempts >= this.maxReconnectAttempts) {
      console.error('已达最大重连次数,停止重连');
      return;
    }

    const delay = Math.min(
      this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1),
      30000 // 最大延迟 30 秒
    );

    console.log(`${delay / 1000} 秒后重连...`);
    setTimeout(() => this.connect(), delay);
  }

  // 关闭连接
  close() {
    this.isManualClose = true;
    this.stopHeartbeat();
    if (this.ws) {
      this.ws.close(1000, '用户关闭');
    }
  }

  // 子类覆盖
  onMessage(event) {}
  onError(event) {}
}

指数退避重连

javascript
// 指数退避重连算法
class ExponentialBackoff {
  constructor(base = 1000, max = 30000, jitter = true) {
    this.base = base;
    this.max = max;
    this.jitter = jitter;
    this.attempt = 0;
  }

  // 获取下一次重连延迟
  getDelay() {
    const delay = Math.min(this.base * Math.pow(2, this.attempt), this.max);
    this.attempt++;

    if (this.jitter) {
      // 添加随机抖动(正负 25%)
      const jitter = delay * 0.25;
      return delay - jitter + Math.random() * jitter * 2;
    }

    return delay;
  }

  // 重置计数
  reset() {
    this.attempt = 0;
  }
}

// 使用示例
const backoff = new ExponentialBackoff(1000, 30000);
console.log('第 1 次:', backoff.getDelay()); // ~1000ms
console.log('第 2 次:', backoff.getDelay()); // ~2000ms
console.log('第 3 次:', backoff.getDelay()); // ~4000ms
console.log('第 4 次:', backoff.getDelay()); // ~8000ms
// ...直到 max (30000ms)

详细说明

完整的 WebSocket 管理器

javascript
/**
 * WebSocket 连接管理器
 * 功能:自动重连、心跳检测、事件管理、消息队列
 */
class WSManager {
  constructor(url, options = {}) {
    this.url = url;
    this.options = {
      heartbeatInterval: 30000,    // 心跳间隔
      heartbeatTimeout: 10000,     // 心跳超时
      reconnect: true,              // 自动重连
      reconnectDelay: 1000,         // 初始重连延迟
      maxReconnectDelay: 30000,     // 最大重连延迟
      maxReconnectAttempts: 10,     // 最大重连次数
      ...options
    };

    this.ws = null;
    this.state = 'disconnected';    // disconnected, connecting, connected
    this.reconnectAttempts = 0;
    this.heartbeatTimer = null;
    this.timeoutTimer = null;
    this.messageQueue = [];          // 离线消息队列
    this.listeners = new Map();      // 事件监听器
    this.isManualClose = false;
  }

  // 连接
  connect() {
    if (this.state === 'connecting' || this.state === 'connected') return;

    this.state = 'connecting';
    this._emit('connecting');

    try {
      this.ws = new WebSocket(this.url);
    } catch (e) {
      this.state = 'disconnected';
      this._emit('error', e);
      this._scheduleReconnect();
      return;
    }

    this.ws.onopen = () => {
      this.state = 'connected';
      this.reconnectAttempts = 0;
      this._startHeartbeat();
      this._emit('connected');
      this._flushQueue();
    };

    this.ws.onmessage = (event) => {
      if (event.data === '__pong__') {
        clearTimeout(this.timeoutTimer);
        return;
      }

      try {
        const data = JSON.parse(event.data);
        this._emit('message', data);
        // 按类型分发
        if (data.type) {
          this._emit(`message:${data.type}`, data);
        }
      } catch {
        this._emit('message', event.data);
      }
    };

    this.ws.onclose = (event) => {
      this.state = 'disconnected';
      this._stopHeartbeat();
      this._emit('disconnected', { code: event.code, reason: event.reason });

      if (!this.isManualClose && this.options.reconnect) {
        this._scheduleReconnect();
      }
    };

    this.ws.onerror = (event) => {
      this._emit('error', event);
    };
  }

  // 发送消息
  send(data) {
    const message = typeof data === 'string' ? data : JSON.stringify(data);

    if (this.state === 'connected') {
      this.ws.send(message);
    } else {
      // 连接未建立,加入队列
      this.messageQueue.push(message);
      if (this.state === 'disconnected') {
        this.connect();
      }
    }
  }

  // 关闭连接
  close(code = 1000, reason = '') {
    this.isManualClose = true;
    this._stopHeartbeat();
    this.messageQueue = [];
    if (this.ws) {
      this.ws.close(code, reason);
    }
  }

  // 事件监听
  on(event, callback) {
    if (!this.listeners.has(event)) {
      this.listeners.set(event, []);
    }
    this.listeners.get(event).push(callback);
    return this;
  }

  off(event, callback) {
    const cbs = this.listeners.get(event);
    if (cbs) {
      const index = cbs.indexOf(callback);
      if (index > -1) cbs.splice(index, 1);
    }
    return this;
  }

  // 内部方法
  _emit(event, data) {
    const cbs = this.listeners.get(event) || [];
    cbs.forEach(cb => cb(data));
  }

  _startHeartbeat() {
    this._stopHeartbeat();
    this.heartbeatTimer = setInterval(() => {
      if (this.ws.readyState === WebSocket.OPEN) {
        this.ws.send('__ping__');
        this.timeoutTimer = setTimeout(() => {
          this.ws.close();
        }, this.options.heartbeatTimeout);
      }
    }, this.options.heartbeatInterval);
  }

  _stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
    clearTimeout(this.timeoutTimer);
  }

  _scheduleReconnect() {
    if (this.reconnectAttempts >= this.options.maxReconnectAttempts) {
      this._emit('reconnect-failed');
      return;
    }

    const delay = Math.min(
      this.options.reconnectDelay * Math.pow(2, this.reconnectAttempts),
      this.options.maxReconnectDelay
    );
    this.reconnectAttempts++;

    this._emit('reconnecting', { attempt: this.reconnectAttempts, delay });
    setTimeout(() => this.connect(), delay);
  }

  _flushQueue() {
    while (this.messageQueue.length > 0) {
      const msg = this.messageQueue.shift();
      this.ws.send(msg);
    }
  }
}

// 使用示例
const ws = new WSManager('wss://example.com/ws');

ws.on('connected', () => {
  console.log('已连接');
  ws.send({ type: 'auth', token: 'abc123' });
});

ws.on('message', (data) => {
  console.log('收到消息:', data);
});

ws.on('message:notification', (data) => {
  console.log('通知:', data.content);
});

ws.on('reconnecting', ({ attempt, delay }) => {
  console.log(`第 ${attempt} 次重连,${delay/1000} 秒后`);
});

ws.on('disconnected', ({ code }) => {
  console.log(`断开连接: ${code}`);
});

ws.connect();

页面卸载时清理

javascript
// 页面关闭时主动断开 WebSocket
window.addEventListener('beforeunload', () => {
  if (ws) {
    ws.close(1001, '页面卸载');
  }
});

// 页面可见性变化处理
document.addEventListener('visibilitychange', () => {
  if (document.hidden) {
    // 页面不可见时停止心跳(节省资源)
    ws._stopHeartbeat();
  } else {
    // 页面可见时恢复心跳
    if (ws.state === 'connected') {
      ws._startHeartbeat();
    }
  }
});

实战示例

带连接状态的 WebSocket 客户端

html
<!DOCTYPE html>
<html lang="zh-CN">
<head>
  <meta charset="UTF-8">
  <title>WebSocket 连接管理</title>
  <style>
    body { font-family: -apple-system, sans-serif; padding: 20px; }
    .card {
      max-width: 500px; margin: 0 auto; background: white;
      border-radius: 12px; padding: 24px;
      box-shadow: 0 2px 8px rgba(0,0,0,0.1);
    }
    h2 { text-align: center; }

    .status-panel {
      display: grid; grid-template-columns: 1fr 1fr 1fr;
      gap: 12px; margin: 20px 0;
    }

    .status-item {
      text-align: center; padding: 12px;
      background: #f8f9fa; border-radius: 8px;
    }

    .status-value { font-size: 20px; font-weight: 700; color: #1a73e8; }
    .status-label { font-size: 12px; color: #888; margin-top: 4px; }

    .status-value.connected { color: #4CAF50; }
    .status-value.disconnected { color: #F44336; }
    .status-value.connecting { color: #FF9800; }

    .event-log {
      height: 250px; overflow-y: auto; padding: 12px;
      background: #1a1a2e; border-radius: 8px;
      font-family: monospace; font-size: 12px; color: #0f0;
    }

    .btn {
      padding: 10px 20px; border: none; border-radius: 8px;
      cursor: pointer; font-size: 14px; font-weight: 600;
      color: white; display: block; width: 100%; margin-top: 12px;
    }
  </style>
</head>
<body>
  <div class="card">
    <h2>WebSocket 连接状态</h2>

    <div class="status-panel">
      <div class="status-item">
        <div class="status-value disconnected" id="stateValue">断开</div>
        <div class="status-label">连接状态</div>
      </div>
      <div class="status-item">
        <div class="status-value" id="reconnectValue">0</div>
        <div class="status-label">重连次数</div>
      </div>
      <div class="status-item">
        <div class="status-value" id="msgCount">0</div>
        <div class="status-label">收到消息</div>
      </div>
    </div>

    <button class="btn" style="background:#4CAF50;" id="toggleBtn" onclick="toggleConnection()">
      连接
    </button>

    <div class="event-log" id="eventLog">
      等待操作...
    </div>
  </div>

  <script>
    let wsManager = null;
    let connected = false;
    let msgCount = 0;

    function addLog(text, color = '#0f0') {
      const log = document.getElementById('eventLog');
      const time = new Date().toLocaleTimeString();
      log.innerHTML += `<div style="color:${color}">[${time}] ${text}</div>`;
      log.scrollTop = log.scrollHeight;
    }

    function updateUI(state) {
      const stateEl = document.getElementById('stateValue');
      const states = { connected: ['已连接', 'connected'], disconnected: ['断开', 'disconnected'], connecting: ['连接中', 'connecting'] };
      const [text, cls] = states[state] || ['断开', 'disconnected'];
      stateEl.textContent = text;
      stateEl.className = `status-value ${cls}`;
    }

    function toggleConnection() {
      if (connected) {
        wsManager.close();
        connected = false;
        document.getElementById('toggleBtn').textContent = '连接';
        document.getElementById('toggleBtn').style.background = '#4CAF50';
      } else {
        startConnection();
      }
    }

    function startConnection() {
      wsManager = new WSManager('wss://echo.websocket.org', {
        heartbeatInterval: 15000,
        reconnect: true,
        maxReconnectAttempts: 5
      });

      wsManager.on('connecting', () => {
        addLog('正在连接...', '#ff0');
        updateUI('connecting');
      });

      wsManager.on('connected', () => {
        connected = true;
        document.getElementById('toggleBtn').textContent = '断开';
        document.getElementById('toggleBtn').style.background = '#F44336';
        addLog('连接已建立', '#4CAF50');
        updateUI('connected');
      });

      wsManager.on('message', (data) => {
        msgCount++;
        document.getElementById('msgCount').textContent = msgCount;
      });

      wsManager.on('reconnecting', ({ attempt, delay }) => {
        document.getElementById('reconnectValue').textContent = attempt;
        addLog(`第 ${attempt} 次重连,${(delay/1000).toFixed(1)}s 后`, '#FF9800');
      });

      wsManager.on('disconnected', ({ code }) => {
        connected = false;
        document.getElementById('toggleBtn').textContent = '连接';
        document.getElementById('toggleBtn').style.background = '#4CAF50';
        addLog(`连接断开 (code: ${code})`, '#F44336');
        updateUI('disconnected');
      });

      wsManager.on('error', () => {
        addLog('连接错误', '#F44336');
      });

      wsManager.on('reconnect-failed', () => {
        addLog('重连失败,已达最大次数', '#F44336');
      });

      wsManager.connect();
    }
  </script>
</body>
</html>

注意事项

  1. 避免无限重连:设置最大重连次数和最大延迟
  2. 指数退避:避免重连风暴导致服务器压力
  3. 页面卸载清理beforeunload 时主动关闭连接
  4. 用户离开暂停:页面不可见时可以暂停心跳
  5. 区分手动关闭和异常断开:避免手动关闭后触发重连

最佳实践

  1. 封装完整的连接管理器,统一处理所有事件
  2. 使用心跳检测"假死"连接(有连接但无数据传输)
  3. 为离线消息建立队列,重连后自动发送
  4. 使用指数退避算法控制重连频率
  5. 提供清晰的连接状态 UI 反馈

下一节

继续学习:WebSocket 实战

参考链接