一、先搞懂:WebSocket 为什么会 "丢包"?
WebSocket 底层基于 TCP ,TCP 本身具备可靠传输、重传、不丢包特性。前端感知到的 "丢包" 99% 不是 TCP 丢包,而是业务层面丢失消息。
常见丢包场景分类
- 网络抖动、断连、弱网WiFi 切换、信号差、路由器断流,WebSocket 连接被动关闭,中途消息收发中断;TCP 重传超时直接断开连接。
- 消息到达,但前端未消费消息回调阻塞、主线程卡死、大量消息堆积,业务代码丢弃旧消息。
- 后端队列溢出 / 限流服务端收到消息但处理不过来,主动丢弃消息;或者推送消息时客户端已离线。
- 前端发送太快,缓冲区溢出
websocket.send()短时间高频调用,浏览器内部发送缓冲区满,部分消息发送失败。 - 连接重连成功,但会话断层 断线后自动重连,重连前后消息无法自动续传,历史推送消息丢失(最普遍业务痛点)。
关键认知
✅ TCP:链路正常时,数据包不会丢失、不会乱序;❌ WebSocket 应用层没有内置消息确认、消息重试、离线消息补发机制 。👉 丢包解决方案 = 在应用层实现一套可靠消息协议。
二、可靠 WebSocket 核心设计方案(工业通用方案)
基础协议约定(前后端统一规范)
每条业务消息强制携带字段:
json
json
{
"msgId": "唯一消息ID(uuid/雪花ID)",
"type": "data|ack|heartbeat",
"data": {},
"timestamp": 1750000000000
}
- ACK 确认机制 客户端收到业务消息 → 立即发送
ack给后端;后端未收到 ack 判定消息未送达。 - 发送队列 + 等待 ACK 前端发送消息存入待确认队列;超时未收到 ack 自动重试,最大重试次数限制。
- **断线消息缓存(本地离线队列)**连接断开后,待发送消息存入 localStorage/indexedDB;重连成功后批量补发。
- **心跳检测(防假死连接)**很多场景网络断开但浏览器未触发
onclose(俗称僵连接),必须心跳探活。 - 消息幂等 重传会造成重复消息,依靠
msgId做去重。 - **有序发送(可选)**业务要求消息按顺序到达时,队列禁止并行发送,等待上一条 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();
});
五、后端配套要点(前端方案生效必备)
只改前端无法完整解决丢包,后端必须配合实现:
- 收到客户端
ping返回pong; - 收到客户端业务消息,返回对应
msgId的ack; - 后端推送业务消息后,等待客户端 ack,超时重试推送;
- 服务端维护会话离线消息池(可选),客户端重连后主动拉取遗漏消息;
- 严格处理消息幂等,防止重复推送造成业务 bug。
六、常见坑与优化方案
1. localStorage 存储上限问题
localStorage 大约 5MB,大量离线消息会溢出;✅ 海量场景改用 IndexedDB。
2. 消息乱序问题
当前代码消息并行发送;如果业务要求顺序:增加有序队列,当上一条收到 ack,再取出下一条发送。
3. 页面刷新离线队列丢失
代码已持久化到 localStorage;注意区分发送中消息 和待推送消息。
4. 重连后大量消息瞬间并发刷屏
✅ 添加节流,补发消息间隔增加延时,防止后端瞬间被打满。
5. receivedMsgIds 无限膨胀
代码内置简单清理策略;长期运行建议使用 LRU 缓存。
七、进阶拓展方向
- 实现消息优先级队列(重要消息优先补发)
- 增加消息过期时间,超时直接丢弃不再重试
- 增加消息发送状态回调(pending/success/fail)
- 集成断线提示 UI、网络状态监控
- 封装 Vue/React Hook 版本