结论:typescript 封装 websocket 工具类的核心是同步管理 readystate、重连状态与心跳定时器的生命周期。需用私有 status 字段主动维护状态机,仅在异常关闭时重连并加退避策略,心跳应发结构化 json 且配对处理 pong,所有定时器必须显式清理。

直接说结论:TypeScript 封装 WebSocket 工具类,核心不是“写得漂亮”,而是把 readyState、重连状态、心跳定时器这三者的生命周期对齐,否则类型再强也拦不住 Cannot read property 'send' of null。
为什么 WebSocket.readyState 不能只靠类型断言来保护
很多封装一开始用 private ws: WebSocket | null = null,再加个 if (this.ws?.readyState === WebSocket.OPEN) 就以为安全了——但实际运行中,onclose 触发后 ws 还没被置为 null,而重连逻辑又已启动新实例,旧 ws 可能还在触发 onmessage,导致数据错乱或重复订阅。
- 真实错误现象:
WebSocket is already in CLOSING or CLOSED state出现在send()调用时,但类型检查完全不报错 - 关键点:TypeScript 的类型系统无法约束运行时状态流转,
WebSocketStatus枚举只是对readyState的镜像,不等于状态机 - 建议做法:用私有字段
private status: WebSocketStatus = WebSocketStatus.CLOSED主动维护状态,并在onopen/onclose中同步更新,所有发送前检查这个字段而非直接读this.ws?.readyState
ManagedWebSocket 类里重连逻辑怎么避免无限递归调用
自动重连看似简单,但常见写法是 onclose → setTimeout(() => this.open(), interval),一旦服务端拒绝连接(比如鉴权失败),就会陷入“打开→失败→关闭→重试”死循环,且每次重试都新建 WebSocket 实例,内存持续增长。
- 必须加重连计数和退避策略:用
reconnectAttempts: number计数,失败后按Math.min(1000 * 2 ** attempt, 30000)增量延时 - 关键判断点:仅在
event.code === 1006(异常关闭)或event.wasClean === false时才触发重连;如果是code === 4001(自定义鉴权失败),应停止重连并通知上层 - 务必在
open()开头加守卫:if (this.status !== WebSocketStatus.CLOSED) return,防止多次调用open()导致多个连接并存
心跳包发 "ping" 还是发结构化 JSON?
发纯字符串 "ping" 看似轻量,但在真实网关场景下往往被拦截或忽略;而结构化心跳(如 { type: "heartbeat", timestamp: Date.now() })虽多几字节,却能穿透大多数代理和负载均衡器,还能让服务端做超时判定。
- 客户端必须配对处理:
onmessage中识别type === "pong"并清除服务端心跳超时定时器,不能只依赖客户端单向计时 - 注意
heartbeatInterval和timeout的关系:若设心跳间隔 15s,服务端超时阈值应 ≥ 30s,否则网络抖动就误判断连 - 别在心跳回调里调
this.send()前再检查readyState——心跳本身就要承担“探测连接是否存活”的职责,该发就发,失败了由onerror或后续onclose处理
最易被忽略的点:所有定时器(心跳、重连、超时检测)必须在 close() 或实例销毁时显式 clearTimeout/clearInterval,TypeScript 的 private heartbeatTimer: ReturnType<typeof setinterval> | null</typeof> 类型声明只是起点,不等于自动清理。漏掉这一条,组件卸载后定时器仍在后台跑,既耗资源又可能触发已销毁对象的方法。











