6. WebSocket 客户端实现:浏览器端建立连接、发送消息、接收消息、处理断开重连

好,咱们今天来聊聊浏览器端的 WebSocket 客户端。说实话,这部分是我个人觉得整个信令链路里最「接地气」的一环。为什么?因为服务器写得再漂亮,最终还是要跟浏览器里的 JavaScript 打交道。你想想看,用户打开一个网页,点一下「加入房间」,背后就是这一套逻辑在跑。

我刚开始做 WebRTC 项目时,总觉得客户端就是 new 一个 WebSocket 对象,然后 onmessage 里处理一下就行了。结果呢?线上用户反馈说「视频连不上」,排查了半天,发现是 WebSocket 断连后没有自动重连,信令丢了。嗯,从那以后,我再也不敢小看客户端实现了。

6.1 建立连接:从 URL 到 readyState

浏览器端建立 WebSocket 连接,核心就一行代码:

const ws = new WebSocket('ws://localhost:8080/ws');

但这里有几个坑,我一个个说。

第一,协议选择。 开发环境用 ws://,生产环境一定要用 wss://(加密)。我曾经见过一个团队,上线忘了改协议,结果所有信令都被中间人截获了。WebRTC 的媒体流虽然是加密的,但信令里携带的 SDP 和 ICE 候选信息一旦泄露,攻击者就能伪造连接。

第二,连接状态。 WebSocket 对象有一个 readyState 属性,取值如下:

常量 含义
CONNECTING 0 正在建立连接
OPEN 1 连接已建立,可以通信
CLOSING 2 正在关闭连接
CLOSED 3 连接已关闭或无法建立

我个人习惯在发送消息前先检查 readyState:

function sendMessage(ws, msg) {
  if (ws.readyState !== WebSocket.OPEN) {
    console.warn('连接未就绪,消息已缓存');
    // 这里可以推入队列,等重连后发送
    return false;
  }
  ws.send(JSON.stringify(msg));
  return true;
}

第三,连接超时。 原生 WebSocket 没有内置超时机制。我建议自己加一个定时器:

function connectWithTimeout(url, timeout = 5000) {
  return new Promise((resolve, reject) => {
    const ws = new WebSocket(url);
    const timer = setTimeout(() => {
      ws.close();
      reject(new Error('连接超时'));
    }, timeout);
    ws.onopen = () => {
      clearTimeout(timer);
      resolve(ws);
    };
    ws.onerror = (err) => {
      clearTimeout(timer);
      reject(err);
    };
  });
}
小技巧: 如果服务器地址是动态的(比如从房间服务获取),记得在连接前先验证 URL 格式。我踩过坑——后端返回的地址多了个空格,结果 WebSocket 一直连不上,排查了半小时。

6.2 发送消息:序列化与队列管理

WebSocket 发送的数据必须是字符串或二进制数据。对于信令,我们通常用 JSON 格式。这里有个细节:一定要先序列化再发送

// 正确做法
ws.send(JSON.stringify({ type: 'join', roomId: '123' }));

// 错误做法——发送了对象,浏览器会报错
ws.send({ type: 'join', roomId: '123' });

为什么强调这个?因为我在 code review 时见过有人直接传对象,浏览器默默把它转成了 [object Object] 字符串,服务器收到后解析失败,整个信令流程卡住了。

消息队列 是另一个容易被忽略的点。连接还没建立时,用户可能已经触发了发送操作。比如用户快速点击「加入房间」按钮,而 WebSocket 还在 CONNECTING 状态。这时候如果不做队列,消息就丢了。

class SignalingClient {
  constructor(url) {
    this.ws = null;
    this.url = url;
    this.pendingMessages = [];
    this.isConnected = false;
  }

  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => {
      this.isConnected = true;
      // 发送积压的消息
      while (this.pendingMessages.length) {
        this.ws.send(this.pendingMessages.shift());
      }
    };
  }

  send(msg) {
    const data = JSON.stringify(msg);
    if (this.isConnected) {
      this.ws.send(data);
    } else {
      this.pendingMessages.push(data);
    }
  }
}
核心原则: 信令消息必须保证有序、不丢失。WebSocket 本身是可靠的(基于 TCP),但客户端逻辑不能假设「连接永远在线」。

6.3 接收消息:onmessage 与协议解析

接收消息用 onmessage 回调。但这里有个容易被忽视的问题:消息可能分片到达。虽然 WebSocket 底层已经帮我们做了重组,但如果你用了一些代理或中间件,还是有可能收到不完整的消息。

我建议在接收端做一层简单的校验:

ws.onmessage = (event) => {
  try {
    const msg = JSON.parse(event.data);
    // 检查消息结构是否完整
    if (!msg.type) {
      console.warn('收到无效消息,缺少 type 字段', msg);
      return;
    }
    handleMessage(msg);
  } catch (e) {
    console.error('消息解析失败', e);
    // 这里可以触发重连或上报异常
  }
};

实际项目中,我习惯把消息处理做成一个分发器:

const handlers = {
  'join': (msg) => { /* 处理加入房间 */ },
  'offer': (msg) => { /* 处理 SDP offer */ },
  'ice-candidate': (msg) => { /* 处理 ICE 候选 */ },
  'error': (msg) => { /* 显示错误提示 */ },
};

function handleMessage(msg) {
  const handler = handlers[msg.type];
  if (handler) {
    handler(msg);
  } else {
    console.warn('未知消息类型:', msg.type);
  }
}

这样做的好处是,新增一种信令类型时,只需要加一个 handler 函数,不用改主逻辑。我在一个中型项目中用了这种模式,后来加「屏幕共享」信令时,只花了 10 分钟就搞定了。

6.4 断开重连:心跳与指数退避

这是整个客户端实现里最考验工程能力的部分。网络不稳定、服务器重启、用户切换网络……任何情况都可能导致 WebSocket 断开。如果不做重连,用户就得刷新页面,体验极差。

第一步:检测断开。 WebSocket 提供了 onclose 和 onerror 事件:

ws.onclose = (event) => {
  console.log('连接关闭,code:', event.code, 'reason:', event.reason);
  // 1000 是正常关闭,其他 code 需要重连
  if (event.code !== 1000) {
    reconnect();
  }
};

ws.onerror = (err) => {
  console.error('WebSocket 错误', err);
  // onerror 之后通常会触发 onclose,所以重连逻辑放在 onclose 里
};

第二步:心跳机制。 为什么需要心跳?因为有些代理或防火墙会在一段时间无数据后自动断开连接。我见过最坑的情况是——用户开着页面去吃饭,回来发现视频断了,但 WebSocket 的 onclose 根本没触发(因为连接被静默掐断了)。

// 每隔 30 秒发送一次心跳
const HEARTBEAT_INTERVAL = 30000;
let heartbeatTimer = null;

function startHeartbeat(ws) {
  heartbeatTimer = setInterval(() => {
    if (ws.readyState === WebSocket.OPEN) {
      ws.send(JSON.stringify({ type: 'ping' }));
    }
  }, HEARTBEAT_INTERVAL);
}

function stopHeartbeat() {
  clearInterval(heartbeatTimer);
}

服务器收到 ping 后,应该回复 pong。如果客户端连续几次没收到 pong,就可以判定连接已断开,主动触发重连。

第三步:指数退避重连。 不要一断开就立即重连,更不要用固定间隔重连。如果服务器挂了,所有客户端同时重连会造成「惊群效应」。指数退避是标准做法:

class ReconnectManager {
  constructor(maxRetries = 10, baseDelay = 1000) {
    this.maxRetries = maxRetries;
    this.baseDelay = baseDelay;
    this.retryCount = 0;
    this.timer = null;
  }

  reset() {
    this.retryCount = 0;
    clearTimeout(this.timer);
  }

  attempt(callback) {
    if (this.retryCount >= this.maxRetries) {
      console.error('重连次数已达上限,请手动刷新');
      return;
    }
    const delay = this.baseDelay * Math.pow(2, this.retryCount) + Math.random() * 1000;
    console.log(`第 ${this.retryCount + 1} 次重连,等待 ${Math.round(delay)}ms`);
    this.timer = setTimeout(() => {
      this.retryCount++;
      callback();
    }, delay);
  }
}

这里我加了一个随机因子(Math.random() * 1000),防止多个客户端同时重连。实际项目中,我还见过更复杂的策略——根据网络类型(WiFi/4G)动态调整重连间隔。

注意: 重连成功后,需要重新发送之前未完成的消息(比如加入房间请求)。另外,如果用户主动关闭页面,应该停止重连,避免在后台无限尝试。

6.5 完整客户端示例

把上面所有内容整合起来,一个生产可用的 WebSocket 客户端大概长这样:

class WebSocketClient {
  constructor(url) {
    this.url = url;
    this.ws = null;
    this.reconnectManager = new ReconnectManager();
    this.messageQueue = [];
    this.handlers = {};
    this.heartbeatTimer = null;
  }

  connect() {
    this.ws = new WebSocket(this.url);
    this.ws.onopen = () => {
      console.log('连接成功');
      this.reconnectManager.reset();
      this.startHeartbeat();
      this.flushQueue();
    };
    this.ws.onmessage = (event) => this.dispatch(event);
    this.ws.onclose = (event) => {
      this.stopHeartbeat();
      if (event.code !== 1000) {
        this.reconnectManager.attempt(() => this.connect());
      }
    };
    this.ws.onerror = () => {
      // onclose 会处理重连
    };
  }

  send(type, payload) {
    const msg = JSON.stringify({ type, payload, timestamp: Date.now() });
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
      this.ws.send(msg);
    } else {
      this.messageQueue.push(msg);
    }
  }

  flushQueue() {
    while (this.messageQueue.length) {
      this.ws.send(this.messageQueue.shift());
    }
  }

  dispatch(event) {
    try {
      const msg = JSON.parse(event.data);
      const handler = this.handlers[msg.type];
      if (handler) handler(msg.payload);
    } catch (e) {
      console.error('消息处理异常', e);
    }
  }

  on(type, callback) {
    this.handlers[type] = callback;
  }

  startHeartbeat() {
    this.heartbeatTimer = setInterval(() => {
      this.send('ping');
    }, 30000);
  }

  stopHeartbeat() {
    clearInterval(this.heartbeatTimer);
  }

  close() {
    this.reconnectManager.reset();
    this.stopHeartbeat();
    this.ws.close(1000, '用户主动关闭');
  }
}

使用方式也很直观:

const client = new WebSocketClient('wss://signaling.example.com/ws');
client.on('join', (payload) => { /* 处理加入 */ });
client.on('offer', (payload) => { /* 处理 offer */ });
client.connect();
避坑指南: 我曾经在生产环境遇到一个问题——重连成功后,旧连接的 onmessage 还在触发。排查后发现是忘了清理旧事件的引用。解决方案:每次重连时,重新创建 WebSocket 实例,不要复用。

6.6 知识体系总览

下面这张图总结了浏览器端 WebSocket 客户端的核心模块和流程:

WebSocket 客户端核心模块 连接管理 new WebSocket(url) readyState 监控 消息收发 send() / onmessage JSON 序列化/反序列化 重连机制 onclose / onerror 指数退避重连 连接超时处理 Promise + setTimeout 协议选择 ws/wss URL 合法性校验 消息队列与分发 pendingMessages 缓存 handler 分发器 消息结构校验 心跳保活 30s 间隔 ping/pong 静默断开检测 重连次数上限 稳定的信令通信 有序、可靠、自动恢复

从图上可以看得很清楚:连接管理、消息收发、重连机制是三个并行的核心模块。它们各自有子模块,最终汇聚成稳定的信令通信能力。我个人觉得,重连机制是最能体现工程水平的——写一个能用的 WebSocket 客户端不难,但写一个能在弱网环境下稳定工作的客户端,需要花不少心思。

好了,浏览器端的 WebSocket 客户端就讲到这里。记住一句话:信令是 WebRTC 的骨架,而客户端是实现骨架血肉的关键。下一节我们会把服务器和客户端串起来,做一个完整的信令交互演示。


公众号:蓝海资料掘金营,微信deep3321