WebSocket 前端丢包处理实战:原理与完整代码实现

一、先搞懂:WebSocket 为什么会 "丢包"?

WebSocket 底层基于 TCP ,TCP 本身具备可靠传输、重传、不丢包特性。前端感知到的 "丢包" 99% 不是 TCP 丢包,而是业务层面丢失消息

常见丢包场景分类

  1. 网络抖动、断连、弱网WiFi 切换、信号差、路由器断流,WebSocket 连接被动关闭,中途消息收发中断;TCP 重传超时直接断开连接。
  2. 消息到达,但前端未消费消息回调阻塞、主线程卡死、大量消息堆积,业务代码丢弃旧消息。
  3. 后端队列溢出 / 限流服务端收到消息但处理不过来,主动丢弃消息;或者推送消息时客户端已离线。
  4. 前端发送太快,缓冲区溢出 websocket.send() 短时间高频调用,浏览器内部发送缓冲区满,部分消息发送失败。
  5. 连接重连成功,但会话断层 断线后自动重连,重连前后消息无法自动续传,历史推送消息丢失(最普遍业务痛点)。

关键认知

✅ TCP:链路正常时,数据包不会丢失、不会乱序;❌ WebSocket 应用层没有内置消息确认、消息重试、离线消息补发机制 。👉 丢包解决方案 = 在应用层实现一套可靠消息协议

二、可靠 WebSocket 核心设计方案(工业通用方案)

基础协议约定(前后端统一规范)

每条业务消息强制携带字段:

json

json 复制代码
{
  "msgId": "唯一消息ID(uuid/雪花ID)",
  "type": "data|ack|heartbeat",
  "data": {},
  "timestamp": 1750000000000
}
  1. ACK 确认机制 客户端收到业务消息 → 立即发送 ack 给后端;后端未收到 ack 判定消息未送达。
  2. 发送队列 + 等待 ACK 前端发送消息存入待确认队列;超时未收到 ack 自动重试,最大重试次数限制。
  3. **断线消息缓存(本地离线队列)**连接断开后,待发送消息存入 localStorage/indexedDB;重连成功后批量补发。
  4. **心跳检测(防假死连接)**很多场景网络断开但浏览器未触发 onclose(俗称僵连接),必须心跳探活。
  5. 消息幂等 重传会造成重复消息,依靠 msgId 做去重。
  6. **有序发送(可选)**业务要求消息按顺序到达时,队列禁止并行发送,等待上一条 ack 再发下一条。

三、完整实战代码(ES6 Class,可直接投入项目)

特性清单:

  • 心跳检测、自动重连、指数退避重连间隔
  • 消息 ACK 确认、超时重试、发送队列
  • 断线消息持久缓存(localStorage)
  • msgId 消息去重
  • 连接状态监听、优雅关闭

javascript

运行

kotlin 复制代码
class ReliableWebSocket {
  /**
   * @param {string} url ws地址
   * @param {Object} options
   */
  constructor(url, options = {}) {
    this.url = url;
    // 默认配置
    this.options = {
      heartbeatInterval: 10000,    // 心跳间隔 ms
      heartbeatTimeout: 5000,      // 心跳超时时间
      reconnectBaseDelay: 1000,    // 重连基础延时
      maxReconnectDelay: 16000,    // 最大重连间隔(指数退避)
      maxRetry: 3,                 // 单条消息最大重试次数
      ackTimeout: 3000,            // ack等待超时
      persistQueueKey: "ws_offline_queue",
      ...options
    };

    // 状态
    this.ws = null;
    this.isManualClose = false; // 是否主动关闭
    this.reconnectTimer = null;
    this.heartbeatTimer = null;
    this.heartbeatTimeoutTimer = null;
    this.reconnectDelay = this.options.reconnectBaseDelay;

    // 消息队列
    this.waitAckMap = new Map(); // {msgId: {data, retryCount, timer}} 等待ack消息
    this.offlineQueue = [];      // 断线待发送离线队列
    this.receivedMsgIds = new Set(); // 用于消息去重

    // 事件回调
    this.onMessageCallback = null;
    this.onOpenCallback = null;
    this.onCloseCallback = null;
    this.onErrorCallback = null;

    // 加载本地缓存的离线消息
    this._loadOfflineQueue();

    // 初始化连接
    this.connect();
  }

  // 建立连接
  connect() {
    if (this.ws && [WebSocket.OPEN, WebSocket.CONNECTING].includes(this.ws.readyState)) {
      return;
    }
    this.ws = new WebSocket(this.url);
    this._bindEvents();
  }

  // 绑定原生ws事件
  _bindEvents() {
    this.ws.onopen = () => {
      console.log("WebSocket 连接成功");
      this.isManualClose = false;
      this.reconnectDelay = this.options.reconnectBaseDelay;
      this._startHeartbeat();
      // 重连成功,补发离线消息
      this._resendOfflineQueue();
      this.onOpenCallback?.();
    };

    this.ws.onmessage = (event) => {
      try {
        const msg = JSON.parse(event.data);
        this._handleMessage(msg);
      } catch (err) {
        console.error("消息解析失败", err);
      }
    };

    this.ws.onclose = (evt) => {
      console.warn("连接关闭", evt);
      this._clearAllTimer();
      // 主动关闭则不重连
      if (!this.isManualClose) {
        this._reconnect();
      }
      this.onCloseCallback?.();
    };

    this.ws.onerror = (err) => {
      console.error("WebSocket异常", err);
      this.onErrorCallback?.(err);
    };
  }

  // 统一处理收到的消息
  _handleMessage(msg) {
    const { type, msgId } = msg;
    switch (type) {
      // 心跳响应
      case "pong":
        this._clearHeartbeatTimeout();
        break;
      // 后端ack回执
      case "ack":
        this._handleAck(msgId);
        break;
      // 业务数据消息
      case "data":
        // 幂等去重
        if (this.receivedMsgIds.has(msgId)) return;
        this.receivedMsgIds.add(msgId);
        // 限制去重缓存大小,防止内存溢出
        if (this.receivedMsgIds.size > 200) {
          this.receivedMsgIds.clear();
        }
        // 发送ack给后端
        this._sendAck(msgId);
        // 向外抛出业务数据
        this.onMessageCallback?.(msg.data, msg);
        break;
    }
  }

  // 发送ack回执
  _sendAck(msgId) {
    this._sendRaw({
      type: "ack",
      msgId
    });
  }

  // 处理ack,清除等待队列,停止重试
  _handleAck(msgId) {
    const item = this.waitAckMap.get(msgId);
    if (item) {
      clearTimeout(item.timer);
      this.waitAckMap.delete(msgId);
    }
  }

  // 底层原始发送,不做ack逻辑
  _sendRaw(data) {
    if (this.ws.readyState !== WebSocket.OPEN) return false;
    try {
      this.ws.send(JSON.stringify(data));
      return true;
    } catch (e) {
      console.error("send失败", e);
      return false;
    }
  }

  /**
   * 对外发送可靠消息(核心API)
   * @param {Object} payload 业务数据
   * @returns {string} msgId
   */
  send(payload) {
    const msgId = this._generateMsgId();
    const message = {
      type: "data",
      msgId,
      timestamp: Date.now(),
      data: payload
    };

    // 连接未打开,存入离线队列持久化
    if (this.ws.readyState !== WebSocket.OPEN) {
      this.offlineQueue.push(message);
      this._saveOfflineQueue();
      return msgId;
    }

    // 正常发送,加入ack等待队列
    this._sendRaw(message);
    this._startAckWait(message);
    return msgId;
  }

  // 启动ack超时检测,超时自动重试
  _startAckWait(message) {
    const { msgId } = message;
    const task = {
      data: message,
      retryCount: 0,
      timer: null
    };

    const retryFunc = () => {
      task.retryCount++;
      if (task.retryCount > this.options.maxRetry) {
        console.error(`消息${msgId}达到最大重试次数,发送失败`);
        this.waitAckMap.delete(msgId);
        return;
      }
      console.warn(`消息${msgId}ack超时,第${task.retryCount}次重发`);
      this._sendRaw(task.data);
      task.timer = setTimeout(retryFunc, this.options.ackTimeout);
    };

    task.timer = setTimeout(retryFunc, this.options.ackTimeout);
    this.waitAckMap.set(msgId, task);
  }

  // 补发离线消息
  _resendOfflineQueue() {
    while (this.offlineQueue.length > 0) {
      const msg = this.offlineQueue.shift();
      this._sendRaw(msg);
      this._startAckWait(msg);
    }
    this._saveOfflineQueue();
  }

  // 持久化离线队列
  _saveOfflineQueue() {
    localStorage.setItem(this.options.persistQueueKey, JSON.stringify(this.offlineQueue));
  }

  // 读取本地离线队列
  _loadOfflineQueue() {
    const str = localStorage.getItem(this.options.persistQueueKey);
    if (str) {
      try {
        this.offlineQueue = JSON.parse(str);
      } catch (e) {
        this.offlineQueue = [];
      }
    }
  }

  // 心跳启动
  _startHeartbeat() {
    this._clearHeartbeatTimer();
    this.heartbeatTimer = setInterval(() => {
      // 发送ping
      this._sendRaw({ type: "ping" });
      // 设置pong超时
      this.heartbeatTimeoutTimer = setTimeout(() => {
        console.warn("心跳超时,关闭僵连接");
        this.ws.close();
      }, this.options.heartbeatTimeout);
    }, this.options.heartbeatInterval);
  }

  _clearHeartbeatTimeout() {
    clearTimeout(this.heartbeatTimeoutTimer);
  }

  _clearHeartbeatTimer() {
    clearInterval(this.heartbeatTimer);
    clearTimeout(this.heartbeatTimeoutTimer);
  }

  // 重连机制:指数退避
  _reconnect() {
    if (this.reconnectTimer) return;
    console.log(`${this.reconnectDelay}ms后尝试重连`);
    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;
      this.connect();
      // 指数退避,上限控制
      this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.options.maxReconnectDelay);
    }, this.reconnectDelay);
  }

  // 清理全部定时器
  _clearAllTimer() {
    this._clearHeartbeatTimer();
    clearTimeout(this.reconnectTimer);
    // 清除所有ack等待定时器
    this.waitAckMap.forEach(item => clearTimeout(item.timer));
  }

  // 主动关闭连接(不再自动重连)
  close() {
    this.isManualClose = true;
    this._clearAllTimer();
    if (this.ws) {
      this.ws.close(1000, "manual close");
    }
  }

  // 生成消息唯一ID
  _generateMsgId() {
    return Date.now() + "_" + Math.random().toString(36).slice(2);
  }

  // 外部注册监听
  onMessage(fn) { this.onMessageCallback = fn }
  onOpen(fn) { this.onOpenCallback = fn }
  onClose(fn) { this.onCloseCallback = fn }
  onError(fn) { this.onErrorCallback = fn }
}

四、使用示例

javascript

运行

javascript 复制代码
// 实例化
const wsClient = new ReliableWebSocket("wss://xxx.com/ws");

wsClient.onOpen(() => {
  console.log("连接就绪,可以发消息");
  wsClient.send({ action: "chat", content: "Hello WebSocket" });
});

wsClient.onMessage((data, rawMsg) => {
  console.log("收到业务消息:", data);
});

wsClient.onClose(() => {
  console.log("连接断开");
});

// 页面卸载主动关闭
window.addEventListener("beforeunload", () => {
  wsClient.close();
});

五、后端配套要点(前端方案生效必备)

只改前端无法完整解决丢包,后端必须配合实现:

  1. 收到客户端 ping 返回 pong
  2. 收到客户端业务消息,返回对应 msgIdack
  3. 后端推送业务消息后,等待客户端 ack,超时重试推送;
  4. 服务端维护会话离线消息池(可选),客户端重连后主动拉取遗漏消息;
  5. 严格处理消息幂等,防止重复推送造成业务 bug。

六、常见坑与优化方案

1. localStorage 存储上限问题

localStorage 大约 5MB,大量离线消息会溢出;✅ 海量场景改用 IndexedDB。

2. 消息乱序问题

当前代码消息并行发送;如果业务要求顺序:增加有序队列,当上一条收到 ack,再取出下一条发送。

3. 页面刷新离线队列丢失

代码已持久化到 localStorage;注意区分发送中消息待推送消息

4. 重连后大量消息瞬间并发刷屏

✅ 添加节流,补发消息间隔增加延时,防止后端瞬间被打满。

5. receivedMsgIds 无限膨胀

代码内置简单清理策略;长期运行建议使用 LRU 缓存。

七、进阶拓展方向

  1. 实现消息优先级队列(重要消息优先补发)
  2. 增加消息过期时间,超时直接丢弃不再重试
  3. 增加消息发送状态回调(pending/success/fail)
  4. 集成断线提示 UI、网络状态监控
  5. 封装 Vue/React Hook 版本
相关推荐
秋天的一阵风1 小时前
✨ 原来文本转换可以这么丝滑!UnifiedJS 实战指南来了
前端·github·markdown
南川琼语1 小时前
HTTP——AJAX
前端·javascript·ajax
用户47949283569152 小时前
专升本前端毕业 1 年,从初创到大厂,我的开源项目上了 github trending,顺便聊聊做开源的收获
前端·后端·github
Ashley的成长之路2 小时前
前端性能优化实战手册·第1篇:从 Lighthouse 60 到 95 的完整路径
前端·性能优化
思码梁田2 小时前
CSS letter-spacing 属性详解:掌控字符之间的呼吸感
前端·css
大尚来也2 小时前
React Hooks全解析:告别繁琐Class组件
前端·react.js·前端框架
不简说2 小时前
JS 代码技巧 vol.2 — 20 个数组/对象/字符串/正则的实用技巧
前端·javascript·面试
小小编程路3 小时前
C++ 块作用域详解:从基础到实战
前端·javascript·vue.js
虚惊一场3 小时前
MarkView:纯前端 Markdown 实时预览工具的架构设计
前端·javascript·vue.js