线上环境出过这么一件事:我们内部某套在线协同系统,客户端通过 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 秒;maxDelay最大 30 秒;jitter随机抖动 0 到 3000 毫秒;maxReconnectAttempts默认 20 次,超过后不再自动重连,等待用户操作或online事件再重置。
随机抖动是为了避免大量客户端同时断线重连时,在同一时间点向服务端发起风暴。假设 1000 个客户端的 Nginx 连接同时被重置,如果退避时间完全一致,每 2 秒就是一整波同时请求,加了抖动后请求会被均匀打散。
javascript复制_calculateDelay() {
const exp = Math.min(this._reconnectAttempts, 10);
const base = Math.min(this.maxReconnectDelay, this.minReconnectDelay * Math.pow(2, exp));
return base + Math.floor(Math.random() * this.reconnectJitter);
}
3.4 重连后的订阅恢复与离线补偿
实时系统里,客户端通常有一个或多个订阅频道。重连成功后,除了连接恢复,还要把业务上的订阅状态恢复回去。我在封装里预留了一个 onReconnected 回调,并把连接前后的时间戳传给回调,方便业务层做增量数据拉取。
javascript复制_fireReconnected() {
this._debug('[ws] reconnected after ' + this._reconnectAttempts + ' attempts');
this._reconnectAttempts = 0;
if (typeof this.options.onReconnected === 'function') {
this.options.onReconnected({ lastConnectedAt: this._lastConnectedAt });
}
}
lastConnectedAt 记录的是上一次连接关闭的时间,业务层可以根据这个时间去服务端拉取离线期间的增量数据。如果没有这个时间,就只能重新全量拉取,代价会高很多。
4. 二进制数据处理:协议设计、封包与粘包边界
4.1 为什么业务层选二进制
很多 WebSocket 场景用 JSON.stringify 已经够了,但实时通信系统一旦频率上来,JSON 的两个缺点就会暴露:
- 序列化和反序列化有 CPU 开销;
- 字符串体积大,同样一条实时行情数据,JSON 可能 200 字节,二进制可以控制在 40 字节以内。
对于服务端主动推送的高频消息,减少字节数等于减少带宽和内存 GC 压力。所以我的封装里把发送和接收都支持二进制协议,同时保留 JSON 文本通道用于控制消息。
4.2 二进制帧结构设计
一个完整的二进制帧分头部和负载两块。头部固定 16 字节,字段如下:
| 字节偏移 | 长度 | 字段 | 说明 |
|---|---|---|---|
| 0~3 | 4 | magic | 魔数,固定 0x5A,用于快速识别帧 |
| 4~5 | 2 | version | 协议版本号 |
| 6~7 | 2 | frameType | 消息类型,1=心跳,2=业务,3=订阅 |
| 8~11 | 4 | seq | 消息序号,用于去重或乱序处理 |
| 12~15 | 4 | payloadLength | 负载字节长度,最大 0xFFFFFFFF |
这个头部设计参考了业界常见的协议做法:魔数防止把错误数据当成合法帧,版本号方便后续协议升级,类型字段让接收方知道应该走哪套解析逻辑,序号对需要可靠有序的业务很重要。负载部分就是原始业务字节。
4.3 ArrayBuffer 与 DataView 配合解码
浏览器端用 DataView 读取二进制最稳,因为它可以明确指定字节序。下面这段代码负责解析一个完整的二进制帧:
javascript复制decodeBinaryFrame(buffer) {
const view = new DataView(buffer);
if (buffer.byteLength < 16) {
throw new Error('invalid frame: header length less than 16');
}
const magic = view.getUint32(0, false);
if (magic !== 0x5A) {
throw new Error('invalid frame: bad magic');
}
const version = view.getUint16(4, false);
const frameType = view.getUint16(6, false);
const seq = view.getUint32(8, false);
const payloadLength = view.getUint32(12, false);
if (buffer.byteLength < 16 + payloadLength) {
throw new Error('invalid frame: payload incomplete');
}
const payload = new Uint8Array(buffer, 16, payloadLength);
return {
version,
frameType,
seq,
payloadLength,
payload
};
}
这里用的都是大端字节序(false 参数),因为网络协议一般统一采用大端,免得后端同学在 C++ 或 Java 里还要做字节序转换。
4.4 WebSocket 消息与应用层帧的边界问题
搜索结果里经常有人搜“websocket 粘包”,这里要澄清一个概念:WebSocket 协议本身是有消息边界保序的,浏览器里一个 onmessage 收到的就是一个完整的 WebSocket 消息,不存在 TCP 那种粘包半包问题。但我自己的协议层仍然要处理两种情况:
- 一个 WebSocket 消息里可能包含多条业务帧(服务端做了批量聚合发送);
- 一个大业务帧可能被业务层拆成多个 WebSocket 消息发送(比如文件分片上传)。
第一种情况的处理是循环解析,读完一个帧后,根据 payloadLength 计算出下一帧的起始偏移,直到剩余字节不足 16 字节。第二种情况在 WebSocket 里可以用 binaryType 设置为 arraybuffer 后自行拼接,但我更推荐的做法是不要让帧跨 WebSocket 消息,文件分片场景用独立的 frameType 加序号自己组装,这样掉了一个分片也容易发现。
4.5 原始数据复用与内存注意
二进制消息如果频繁 new Uint8Array,会产生很多临时对象。在解析高频消息时,我倾向于只把 payload 的引用交给业务层,不在封装内部拷贝一份。业务层只读数据就没必要复制。如果业务层需要保存异步使用,必须自己复制一份,否则原始 ArrayBuffer 一旦被 get 操作回收,后续读取会出问题。
还有一个坑:WebSocket.binaryType 默认是 'blob',如果要处理 ArrayBuffer,必须在 onopen 之前设置:
javascript复制const ws = new WebSocket(url);
ws.binaryType = 'arraybuffer';
如果不设置,event.data 会是 Blob,转换回 ArrayBuffer 还需要多一步异步操作,高频场景下性能会明显变差。
5. 脱敏后的 WebSocketClient 完整源码
5.1 构造参数与对外事件
写这个类的时候,我尽量让接口简单。构造参数支持下列配置:
| 参数 | 默认值 | 说明 |
|---|---|---|
url |
必填 | WebSocket 地址 |
heartbeatInterval |
30000 | 心跳发送间隔 |
heartbeatTimeout |
60000 | 心跳超时时间 |
maxLostPong |
2 | 连续丢失 pong 次数上限 |
minReconnectDelay |
1000 | 最小重连间隔 |
maxReconnectDelay |
30000 | 最大重连间隔 |
reconnectJitter |
3000 | 抖动范围 |
maxReconnectAttempts |
20 | 最大重连次数 |
onopen |
无 | 连接建立回调 |
onmessage |
无 | 文本或二进制消息回调 |
onclose |
无 | 连接关闭回调 |
onerror |
无 | 错误回调 |
onreconnecting |
无 | 每次触发重连前回调 |
onreconnected |
无 | 重连成功回调 |
事件回调比原生 API 多的就是 onreconnecting 和 onreconnected,这两个回调在排查重连循环时非常有用。
5.2 核心类实现
下面是完整源码,去掉了公司内部的业务逻辑和敏感地址,保留通用能力:
javascript复制class WebSocketClient {
constructor(options) {
this.options = Object.assign(
{
url: '',
protocols: [],
heartbeatInterval: 30000,
heartbeatTimeout: 60000,
maxLostPong: 2,
minReconnectDelay: 1000,
maxReconnectDelay: 30000,
reconnectJitter: 3000,
maxReconnectAttempts: 20,
onopen: null,
onmessage: null,
onclose: null,
onerror: null,
onreconnecting: null,
onreconnected: null,
},
options
);
if (!this.options.url) {
throw new Error('WebSocketClient: url is required');
}
this.readyState = 'INIT';
this._ws = null;
this._heartbeatTimer = null;
this._reconnectTimer = null;
this._reconnectAttempts = 0;
this._waitingPong = false;
this._lostPongCount = 0;
this._manualClosed = false;
this._lastConnectedAt = 0;
this._bindGlobalEvents();
}
connect() {
if (this.readyState === 'CONNECTING' || this.readyState === 'OPEN') {
return;
}
this._manualClosed = false;
this._openSocket();
}
_openSocket() {
this.readyState = 'CONNECTING';
const protocols = Array.isArray(this.options.protocols)
? this.options.protocols
: [];
this._ws = protocols.length
? new WebSocket(this.options.url, protocols)
: new WebSocket(this.options.url);
this._ws.binaryType = 'arraybuffer';
this._ws.onopen = () => {
this.readyState = 'OPEN';
this._reconnectAttempts = 0;
this._lastConnectedAt = Date.now();
this._startHeartbeat();
if (typeof this.options.onopen === 'function') {
this.options.onopen({ timestamp: Date.now() });
}
if (this._reconnected) {
this._fireReconnected();
}
};
this._ws.onmessage = (event) => {
this._waitingPong = false;
this._lostPongCount = 0;
this._dispatchMessage(event.data);
};
this._ws.onerror = (error) => {
if (typeof this.options.onerror === 'function') {
this.options.onerror({ error, timestamp: Date.now() });
}
};
this._ws.onclose = (event) => {
this._stopHeartbeat();
this.readyState = 'CLOSED';
if (typeof this.options.onclose === 'function') {
this.options.onclose({
code: event.code,
reason: event.reason,
timestamp: Date.now(),
});
}
if (this._manualClosed) {
return;
}
this._scheduleReconnect();
};
}
_dispatchMessage(data) {
if (typeof data === 'string') {
this._handleTextMessage(data);
} else if (data instanceof ArrayBuffer) {
this._handleBinaryMessage(data);
} else {
if (typeof this.options.onmessage === 'function') {
this.options.onmessage({ type: 'unknown', data });
}
}
}
_handleTextMessage(text) {
let parsed = null;
try {
parsed = JSON.parse(text);
} catch (e) {
parsed = { raw: text };
}
if (parsed && (parsed.type === 'pong')) {
this._waitingPong = false;
this._lostPongCount = 0;
return;
}
if (typeof this.options.onmessage === 'function') {
this.options.onmessage({ type: 'text', data: parsed || text });
}
}
_handleBinaryMessage(buffer) {
let frame = null;
try {
frame = this.decodeBinaryFrame(buffer);
} catch (e) {
if (typeof this.options.onerror === 'function') {
this.options.onerror({
error: e,
frame: buffer,
timestamp: Date.now(),
});
}
return;
}
if (typeof this.options.onmessage === 'function') {
this.options.onmessage({ type: 'binary', frame });
}
}
send(data) {
if (this.readyState !== 'OPEN' || !this._ws) {
throw new Error('WebSocketClient: not connected');
}
if (typeof data === 'string') {
this._ws.send(data);
return;
}
if (data instanceof ArrayBuffer) {
this._ws.send(data);
return;
}
if (ArrayBuffer.isView(data)) {
this._ws.send(data.buffer.slice(
data.byteOffset,
data.byteOffset + data.byteLength
));
return;
}
this._ws.send(JSON.stringify(data));
}
sendRaw(data) {
if (this.readyState !== 'OPEN' || !this._ws) {
return false;
}
this._ws.send(data);
return true;
}
_encodeHeartbeat() {
return JSON.stringify({ type: 'ping', ts: Date.now() });
}
decodeBinaryFrame(buffer) {
const view = new DataView(buffer);
if (buffer.byteLength < 16) {
throw new Error('invalid frame: header length less than 16');
}
const magic = view.getUint32(0, false);
if (magic !== 0x5a) {
throw new Error('invalid frame: bad magic');
}
const version = view.getUint16(4, false);
const frameType = view.getUint16(6, false);
const seq = view.getUint32(8, false);
const payloadLength = view.getUint32(12, false);
if (buffer.byteLength < 16 + payloadLength) {
throw new Error('invalid frame: payload incomplete');
}
const payload = new Uint8Array(buffer, 16, payloadLength);
return {
version,
frameType,
seq,
payloadLength,
payload,
};
}
encodeBinaryFrame({ frameType, seq, payload }) {
const payloadLength = payload.byteLength;
const buffer = new ArrayBuffer(16 + payloadLength);
const view = new DataView(buffer);
view.setUint32(0, 0x5a, false);
view.setUint16(4, 1, false);
view.setUint16(6, frameType, false);
view.setUint32(8, seq || 0, false);
view.setUint32(12, payloadLength, false);
new Uint8Array(buffer, 16, payloadLength).set(payload);
return buffer;
}
close(code, reason) {
this._manualClosed = true;
this._stopHeartbeat();
this._clearReconnectTimer();
this.readyState = 'CLOSED';
if (this._ws) {
try {
this._ws.close(code || 1000, reason || 'manual close');
} catch (e) {
// ignore
}
}
}
destroy() {
this.close(1000, 'destroy');
this._unbindGlobalEvents();
this._ws = null;
}
_startHeartbeat() {
this._stopHeartbeat();
this._heartbeatTimer = setInterval(() => {
if (this.readyState !== 'OPEN' || !this._ws) {
return;
}
if (this._waitingPong) {
this._lostPongCount++;
if (this._lostPongCount >= this.options.maxLostPong) {
this._forceClose();
}
return;
}
this._waitingPong = true;
this.sendRaw(this._encodeHeartbeat());
}, this.options.heartbeatInterval);
}
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearInterval(this._heartbeatTimer);
this._heartbeatTimer = null;
}
this._waitingPong = false;
this._lostPongCount = 0;
}
_forceClose() {
if (this._ws) {
try {
this._ws.close(4000, 'heartbeat timeout');
} catch (e) {
// ignore
}
}
}
_scheduleReconnect() {
if (this._manualClosed) {
return;
}
if (this._reconnectAttempts >= this.options.maxReconnectAttempts) {
this._debug('[ws] max reconnect attempts reached');
return;
}
const delay = this._calculateDelay();
this.readyState = 'WAITING_RECONNECT';
this._reconnectAttempts++;
if (typeof this.options.onreconnecting === 'function') {
this.options.onreconnecting({
attempt: this._reconnectAttempts,
delay,
timestamp: Date.now(),
});
}
this._reconnectTimer = setTimeout(() => {
this._reconnectTimer = null;
if (this._manualClosed) {
return;
}
this._debug('[ws] reconnecting attempt ' + this._reconnectAttempts);
this._openSocket();
}, delay);
}
_calculateDelay() {
const exp = Math.min(this._reconnectAttempts, 10);
const base = Math.min(
this.options.maxReconnectDelay,
this.options.minReconnectDelay * Math.pow(2, exp)
);
return base + Math.floor(Math.random() * this.options.reconnectJitter);
}
_fireReconnected() {
this._reconnected = false;
if (typeof this.options.onreconnected === 'function') {
this.options.onreconnected({
timestamp: Date.now(),
lastConnectedAt: this._lastConnectedAt,
});
}
}
_onWindowOnline = () => {
if (
this.readyState !== 'OPEN' &&
this.readyState !== 'CONNECTING' &&
!this._manualClosed
) {
this._debug('[ws] network online, reconnect immediately');
this._clearReconnectTimer();
this._reconnectAttempts = 0;
this._openSocket();
}
};
_onVisibilityChange = () => {
if (document.visibilityState === 'visible') {
if (
this.readyState !== 'OPEN' &&
this.readyState !== 'CONNECTING' &&
!this._manualClosed
) {
this._debug('[ws] page visible, reconnect immediately');
this._reconnectAttempts = 0;
this._clearReconnectTimer();
this._openSocket();
}
}
};
_bindGlobalEvents() {
window.addEventListener('online', this._onWindowOnline);
document.addEventListener('visibilitychange', this._onVisibilityChange);
}
_unbindGlobalEvents() {
window.removeEventListener('online', this._onWindowOnline);
document.removeEventListener('visibilitychange', this._onVisibilityChange);
}
_clearReconnectTimer() {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
_debug(...args) {
if (this.options.debug) {
console.log('[WebSocketClient]', ...args);
}
}
}
if (typeof module !== 'undefined' && module.exports) {
module.exports = WebSocketClient;
}
5.3 接入示例
接入的时候,只需要实例化并指定业务回调:
javascript复制const client = new WebSocketClient({
url: 'wss://api.example.com/realtime',
heartbeatInterval: 30000,
maxReconnectAttempts: 20,
debug: true,
onopen() {
client.send({ type: 'subscribe', channel: 'trade', symbol: 'BTCUSDT' });
},
onmessage({ type, data }) {
if (type === 'text') {
// 处理文本控制消息
console.log('text message:', data);
} else if (type === 'binary') {
// data.frame 是解好的二进制帧
console.log('binary frame:', data.frame);
}
},
onreconnecting({ attempt, delay }) {
console.log(`reconnecting in ${delay}ms, attempt ${attempt}`);
},
onreconnected({ lastConnectedAt }) {
// 拉取离线增量
fetchIncrement(lastConnectedAt);
},
onerror({ error }) {
console.error('ws error:', error);
}
});
client.connect();
这套源码我实际上已经在一个内部项目里跑了两个多月,心跳频繁触发重连,但没再出现过假死连接拖垮页面的情况。
6. Nginx、服务端与浏览器:长连接运维排障清单
6.1 Nginx 代理 WebSocket 的关键配置
如果 WebSocket 经过 Nginx 代理,Upgrade 和 Connection 头必须显式配置,否则浏览器会一直停留在握手等待状态。这是一段可以直接用的配置:
nginx复制map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 443 ssl http2;
server_name api.example.com;
location /realtime {
proxy_pass http://10.0.1.10:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 300s;
proxy_send_timeout 300s;
proxy_buffering off;
}
}
proxy_read_timeout 默认只有 60 秒,如果不调大,Nginx 会在 60 秒内没有数据时主动断开连接。我们的心跳是 30 秒一次,理论上 60 秒也够,但为了留足余量,我会把超时调到 300 秒。proxy_buffering off 是为了让消息能及时推给客户端,避免代理缓冲导致实时性变差。
线上常见的 stream disconnected before completion: websocket closed by server before res,如果你看到这条日志,优先去查三个地方:Nginx 的 proxy_read_timeout、服务端的会话空闲超时配置、客户端的重连间隔。三者不匹配就会出现“服务端已经关了但客户端不知道”的半死状态。
6.2 Spring Boot 服务端集成要点
后端如果用的是 Spring Boot,@ServerEndpoint 虽然在类上标注一个端点就能用,但真正生产化需要关注几点:
- 每个连接都会创建一个
@ServerEndpoint实例,实例字段不能共享复杂数据,需要另外维护全局连接池; onClose和onError里要清理连接池,否则连接泄漏后内存会涨;- 设置 Session 空闲超时,
session.setMaxIdleTimeout(120_000),这个时间要大于客户端的重连间隔,小于等于客户端的心跳能覆盖的范围。
Spring Boot 默认自带的 WebSocket 容器对于纯 Java 应用够用,但高并发场景要考虑是否换成独立的 WebSocket 服务节点。二进制数据处理上,后端拿到 ByteBuffer 后用同样的 16 字节头部协议解析,字节序保持大端即可。
6.3 浏览器崩溃与内存泄漏的排查方向
搜索结果里有人提到“websocket 导致浏览器崩溃”,大部分情况不是 WebSocket 本身崩溃,而是页面在 onmessage 回调里做重活导致内存上涨。最典型的是以下几种:
| 现象 | 原因 | 处理方式 |
|---|---|---|
| 内存持续上涨 | 收到二进制消息后保存了大量 ArrayBuffer,但没有释放引用 |
业务层用完立即置空,不要长期持有原始 buffer |
| Tab 卡死 | 高频消息里做 DOM 更新或同步计算 | 用 requestAnimationFrame 合并渲染,或者做消息节流 |
| 连接数越来越多 | 前端没有清理旧实例,重连时重复 new WebSocket |
使用状态机封装,确保只有一个连接实例 |
| 白屏/页面异常 | 回调里抛异常被吞掉,后续逻辑错乱 | 在 onmessage 外层加 try/catch,单独走错误上报 |
还有一个容易被忽视的点:WebSocket 实例如果不再使用,必须调用 close() 并置空引用,否则浏览器可能不会立即回收连接对象。对于单页应用,这尤其重要,因为页面不刷新,长期驻留的页面里如果每次切换路由都新开一个 WebSocket,连接数很容易翻倍。
6.4 排障时必备的日志与监控
封装的调试日志我建议在生产环境默认关闭,但错误信息一定要上报。我在源码里保留了 onerror 和 debug 两个输出口,线上排查的时候可以临时打开 debug 看重连流程。更合适的做法是接入自己的埋点系统,把以下信息上报:
- 连接成功时间点、连接关闭代码与原因;
- 每次重连触发的类型(网络断开、心跳超时、页面可见时主动重连、服务端 close);
- 重连尝试次数和最终是否成功;
- 收到二进制帧的数量和平均大小;
- 心跳超时次数。
有了这些数据,你就能回答一个非常实际的问题:某个用户的连接是不是一直在掉线重连?掉线发生在哪个环节?是网络问题还是服务端主动断开?如果没有埋点,线上哪怕看到报错也只能靠猜,效率很低。
就我个人的实践来说,这套 WebSocket 封装最大的价值不是代码本身,而是把“连接是不可靠的”这个事实变成了一套可治理的策略:心跳负责感知,重连负责恢复,二进制负责效率,日志负责溯源。任何实时通信系统只要把这四件事做好,稳定性都会有一个很明显的提升。最后再提醒一句,如果你要参考这份源码,一定要先根据自己的业务调整心跳协议和二进制帧格式,尤其是帧类型编号和字节序,前后端必须保持一致,否则功能越是复杂,排查起来越痛苦。
