线上环境出过这么一件事:我们内部某套在线协同系统,客户端通过 WebSocket 维持实时状态同步,平时好好的,一到用户网络切换或者公司无线波动,连接就悄悄断掉,服务端日志刷出一片 stream disconnected before completion: websocket closed by server before response,前端页面没有任何提示,用户问“为什么我这边总是卡着不动”,我们排查半天才发现是长连接假死。后来我干脆抽时间重写了一个通用的 WebSocket 封装,把心跳检测、智能重连、二进制数据处理全部揉进去,做完之后线上故障率明显下降,这篇文章就把这套封装的设计思路和脱敏后的源码整理出来,给需要做实时通信系统的前端、全栈甚至后端同事一个可以直接参考的实践。
这套封装解决的是三个最头疼的问题:连接断了怎么快速发现、发现之后怎么安全地连回来、以及数据量大时怎么用二进制消息降低开销。适用对象不只是大型聊天室、消息推送,也包括协同工具、行情刷新、IoT 设备控制这类对实时性和稳定性都有要求的系统。原生 WebSocket 只提供基础能力,真正的生产级能力需要自己去补,下面我从问题出发,把每一步设计讲清楚。
1. 线上断连事故复盘:原生WebSocket的四个缺口
1.1 连接假死:服务端和客户端都以为对方还活着
最早我们直接用 new WebSocket(),连接成功后就不再管它了。TCP 连接本质上是一个“不主动发数据就不知道断没断”的通道。如果用户电脑休眠、网络切换、公司出口 NAT 超时,连接不会立即触发 close 事件,服务端也没有感知,两边都以为连接还活着,实际上数据早就过不去了。这种“幽灵连接”比直接断开的危害更大,因为它不会触发任何回调,业务层完全不知道要重建连接。
1.2 原生 API 不提供自动重连
WebSocket 的关闭事件是终态,关闭之后不会自动恢复,必须自己重新 new WebSocket。如果只是简单地在 onclose 里重连,网络没恢复时会疯狂创建连接,服务端被打死,前端也卡死。生产环境需要的是有退避策略、有最大次数限制、能感知网络状态的重连机制。
1.3 二进制数据没有一个约定好的处理规范
WebSocket 消息可以发送字符串、ArrayBuffer、Blob,但原生 API 不会帮你区分消息类型,更不会帮你处理字节序、多段数据拼接。如果服务端推送的是二进制行情数据或者音视频分片,消息 onmessage 拿到一堆 ArrayBuffer,你得自己判断这一段是什么、拆多少字节作为头、剩余多少字节作为负载。这些逻辑如果不放在一个公共封装里,每个业务页面都要写一遍,而且很容易写出字节错位。
1.4 页面生命周期与连接生命周期是分离的
用户在浏览器里切后台、最小化、锁屏,浏览器可能会冻结页面计时器或者主动断开空闲长连接,等用户切回来时连接已经失效。原生 API 不会帮你处理 visibilitychange,也不会在页面恢复可见时自动重连。一个生产级封装必须把状态管理、网络监听、页面可见性变化全部纳入连接生命周期。
这四个缺口叠加导致的问题就是:连接质量不可控、排障困难、用户感知差。自研封装不是要做一个复杂的框架,而是把长连接从“能通”升级为“可维护、可恢复、可观测”。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 心跳检测:应用层探活的最小成本方案
2.1 为什么 TCP 层的 keepalive 不够用
很多人会说,TCP 不是有 keepalive 吗?但系统默认的 TCP keepalive 探活周期通常要几个小时,而且它只能证明网络路径通,不能证明应用进程还活着、消息处理正常。我们做实时通信,要的是分钟级别甚至秒级别地发现连接异常。这个探测必须在应用层完成,也就是自己要有一个“定时发请求、定时响应请求”的机制。这里顺便提一下搜索结果里经常出现的 websocket closed by server before response,它的本质就是在请求还没有完成时,服务端已经关闭了连接,常见原因就是服务端空闲超时,应用层心跳恰恰能避免这种被动超时。
2.2 浏览器端的 Ping/Pong 限制与业务心跳方案
WebSocket 协议本身有 Ping/Pong 控制帧,服务端可以主动发 Ping,浏览器底层会自动回 Pong,但浏览器的 WebSocket API 并不暴露“发送 Ping 控制帧”的方法。所以前端封装里,我选择业务层心跳:客户端定时发送一条低频 JSON 消息(比如 {"type":"ping","ts":...}),服务端收到后返回 {"type":"pong","ts":...},客户端在超时窗口内没收到 pong,就判定连接已死,主动关闭连接去触发重连。
为什么非要主动关闭连接?因为不关的话,连接就一直挂在假死状态。主动 socket.close() 会触发 onclose,再复用状态机里的重连逻辑,整个链路是闭环的。
2.3 心跳间隔、超时与容错次数怎么定
心跳间隔设置要看服务端和中间设备的空闲超时时间。常见的 Nginx 代理配置中,proxy_read_timeout 默认 60 秒,服务端如果设置 60 秒空闲就关闭连接,那么心跳就必须小于这个时间,一般取 30 秒,这样能保证代理设备不会认为连接空闲。心跳超时判定我一般取“两次间隔加一个余量”,也就是发出心跳后 60 秒内没收到 pong,就认为连接已经不可用。
为了减少误判,我不建议发一次没过就立刻断开,因为网络抖动可能只是延迟。我的做法是连续两次都没有收到 pong 才真正关闭连接。心跳计数在每次收到任意消息时都会重置,因为只要有一条业务数据在传输,就说明连接是活的,没必要额外探活。
2.4 心跳实现代码
下面是封装里的核心心跳逻辑,用了一个 _waitingPong 标记来避免并发累计:
javascript复制_startHeartbeat() {
this._stopHeartbeat();
this._heartbeatTimer = setInterval(() => {
if (this._ws.readyState !== WebSocket.OPEN) {
return;
}
if (this._waitingPong) {
this._lostPongCount++;
if (this._lostPongCount >= this.maxLostPong) {
this._debug('[heartbeat] pong timeout, close and reconnect');
this._ws.close();
}
return;
}
this._waitingPong = true;
this._lastPingTs = Date.now();
this.sendRaw(this._encodeHeartbeat());
}, this.heartbeatInterval);
}
收到任何消息时重置:
javascript复制_handleMessage(event) {
this._waitingPong = false;
this._lostPongCount = 0;
// 处理数据...
}
这样设计的好处是,只要服务端还在正常回业务数据,心跳就不会因为发出去的 ping 丢失而被误杀。
3. 智能重连:带状态机与退避算法的恢复策略
3.1 重连触发点不能只有一个 onclose
如果把所有重连逻辑只挂在 onclose 里,会漏掉很多场景。我的封装里把重连触发点拆成四个:
onclose触发,关闭原因不是客户端主动destroy;- 心跳超时,主动关闭连接后触发;
window的online事件,网络从断网恢复;visibilitychange变为可见,且连接已经是关闭状态。
navigator.onLine 是一个参考值,它并不完全可靠,但它变为 true 时是触发重连的很好时机。切后台时连接断掉,切回来时通过可见性事件主动检查一次,比等待下一次业务请求更及时。
3.2 状态机设计:避免多次重连续建
重连最容易出的问题就是重复创建连接。onclose 里调 connect(),但是连接失败时 onerror 和 onclose 都会触发,如果不做状态判断,一次失败会调两次 connect(),每来一次事件就多开一个 WebSocket 对象,最后资源泄漏。
我的封装里维护了一个简单的状态机:
| 状态 | 含义 | 可执行动作 |
|---|---|---|
INIT |
初始状态 | connect() |
CONNECTING |
正在建立连接 | 忽略再次调用 |
OPEN |
连接已建立 | send()、close() |
WAITING_RECONNECT |
等待下一次重连计时器 | 可取消或立即重连 |
CLOSED |
已手动销毁 | 不可再调用 |
每次 connect() 前检查当前状态,只有 INIT、CLOSED 或者 WAITING_RECONNECT 状态允许发起新连接。onclose 里不会直接调 connect(),而是进入 _scheduleReconnect(),这个函数内部判断是否达到重连上限、计算退避时间、启动单次定时器。
3.3 指数退避与随机抖动
如果服务端短暂不可用,客户端高频重连只会让服务端更难恢复。我的策略是指数退避加随机抖动。退避公式如下:
code复制delay = min(maxDelay, baseDelay * 2^attempt) + random(0, jitter)
具体参数:
baseDelay初始 1 秒;- `m
