一、为什么要封装webSocket?
原生 WebSocket API 提供了基础的连接、收发消息能力,但在实际项目中直接使用会遇到以下问题:
-
连接断开后无法自动重连:网络抖动或服务器重启都会导致连接丢失,需要手动处理。
-
缺乏心跳保活:部分网络设备(如防火墙、负载均衡器)会断开长时间无数据传输的连接,导致"假死"。
-
消息顺序与队列:服务器推送可能很频繁,如果回调处理耗时,容易造成消息乱序或丢失。
-
重复连接:应用中可能多处需要使用长连接,如果每个地方都新建实例,会浪费资源并导致状态不一致。
-
错误处理不统一:缺少统一的错误日志、重试策略等。
二、整体架构
我们的封装主要由三部分组成:
-
配置接口
LongLinkOptions:定义连接所需的参数(服务器地址、用户信息、订阅类型等)。 -
核心类
LongLink:管理 WebSocket 生命周期、心跳、重连、消息队列。 -
单例工厂
initLongLink:保证全局只有一个长连接实例。
代码结构清晰,职责分离,易于维护和扩展
javascript
class LongLink {
// 私有属性:ws 实例、定时器、状态标记、队列等
// 私有方法:连接、处理事件、心跳、重连等
// 公开方法:onMessage、close
}
3.1 配置与默认值
通过 options 对象传入所有配置,并提供合理的默认值,减少调用者的心智负担。
javascript
interface LongLinkOptions {
url: string; // 必须:WebSocket 地址
uid: string | number; // 必须:用户标识
clientInfo: string; // 必须:客户端信息
opTypes: string | string[]; // 必须:订阅类型
conversationTypes?: string | string[]; // 可选:会话类型,默认 '9'
heartbeat?: number; // 可选:心跳间隔(秒),默认 60
maxRetries?: number; // 可选:最大重试次数,默认 5
retryDelay?: number; // 可选:重连初始延迟(ms),默认 500
extraSendOptions?: Record<string, any>; // 可选:额外扩展参数
}
3.2 连接建立与初始化
在构造函数中调用 connect() 立即创建 WebSocket 实例,并绑定四个核心事件:
-
onopen:连接成功 → 重置重试计数、启动心跳、发送订阅消息 -
onmessage:收到消息 → 解析、入队、消费 -
onclose:连接关闭 → 清理资源、触发重连(除非被标记为 kill) -
onerror:发生错误 → 主动关闭连接(会触发 onclose)
初始化消息 :连接成功后,根据配置的 opTypes 和 conversationTypes 发送一个或多个 type: 1 消息,告知服务器该连接要接收哪些业务数据。
3.3 心跳保活
心跳的作用是定期向服务器发送一个很小的数据包(type: 2),保持连接活跃,同时也能检测网络是否通畅。
-
在
onopen中启动定时器,每隔heartbeat秒发送一次。 -
在
onclose中停止定时器,避免内存泄漏。 -
发送前检查
readyState,确保连接是打开的。
javascript
private startHeartbeat(): void {
this.stopHeartbeat();
const interval = (this.options.heartbeat ?? 60) * 1000;
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 2, data: 1 });
}
}, interval);
}
3.4 自动重连策略
网络波动或服务器重启会导致连接关闭,我们需要自动恢复连接。
-
重连条件 :不是被主动关闭(
kill标记为 false)且未达到最大重试次数。 -
重连延迟 :采用线性退避,即
retryDelay * (retryCount + 1)。第一次重连延迟 500ms,第二次 1000ms,依此类推。 -
最大重试 :设置
maxRetries避免无限重连,达到上限后停止并打印错误。 -
重试计数:连接成功后重置为 0,失败时递增。
javascript
private handleClose(): void {
// ... 清理心跳
if (this.kill) return;
if (this.retryCount < maxRetries) {
const delay = retryDelay * (this.retryCount + 1);
this.reconnectTimer = setTimeout(() => {
this.retryCount++;
this.connect();
}, delay);
}
}
3.5 消息队列与顺序处理
服务器推送的消息可能同时到达,如果直接调用外部回调,可能因回调的异步性导致消息乱序。使用队列可以保证消息按接收顺序逐个处理。
-
入队 :收到业务消息(
type: 消息)后,解析并push到数组末尾。 -
消费 :调用
consume()方法,从数组头部shift取出一个消息,并通过messageCallback抛出。 -
触发时机 :每次入队后立即消费;当外部通过
onMessage注册回调后,也会立即消费已有消息(避免积压)。
javascript
private handleMessage(evt: MessageEvent): void {
// 解析消息...
if (msg.type === '消息') {
const data = JSON.parse(msg.data);
this.messageQueue.push(data);
this.consume();
}
}
private consume(): void {
if (!this.messageQueue.length) return;
if (typeof this.messageCallback !== 'function') return;
const msg = this.messageQueue.shift();
this.messageCallback(msg);
}
3.6 错误处理与资源清理
-
错误事件 :
onerror触发时,调用this.ws.close()主动关闭连接,这会触发onclose中的重连逻辑,避免在错误状态下继续尝试发送消息。 -
定时器清理 :在
connect()开始前调用clearTimers()停止所有定时器(心跳和重连),防止旧的定时器干扰新连接。 -
连接状态检查 :发送消息前必须判断
readyState === WebSocket.OPEN,避免向已关闭的连接发送数据导致报错。
3.7 单例模式
长连接通常是一个全局资源,应该全局唯一。通过 initLongLink 工厂函数实现单例:
javascript
let instance: LongLink | null = null;
export function initLongLink(options: LongLinkOptions) {
if (!instance) {
instance = new LongLink(options);
}
return {
onMessage: instance.onMessage.bind(instance),
close: instance.close.bind(instance),
};
}
优点:
-
避免重复连接浪费资源。
-
保证连接状态一致,不会出现多个实例同时操作。
-
对外只暴露必要的方法(
onMessage、close),隐藏内部实现细节。
实现代码:
javascript
// ---------- 类型定义 ----------
interface LongLinkOptions {
url: string; // WebSocket 服务地址
uid: string | number; // 用户标识
clientInfo: string; // 客户端信息
opTypes: string | string[]; // 订阅的操作类型
conversationTypes?: string | string[]; // 会话类型,默认 '9'
heartbeat?: number; // 心跳间隔(秒),默认 60
maxRetries?: number; // 最大重试次数,默认 5
retryDelay?: number; // 重连初始延迟(ms),默认 500
extraSendOptions?: Record<string, any>; // 额外发送参数
}
interface SocketMessage {
type: number;
data: any;
}
// 默认配置
const defaultOptions: Partial<LongLinkOptions> = {
conversationTypes: "9",
heartbeat: 60,
maxRetries: 5,
retryDelay: 500,
};
class WsLink {
private readonly options: LongLinkOptions;
private ws: WebSocket | null = null;
private heartbeatTimer: ReturnType<typeof setInterval> | null = null;
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private kill = false;
private retryCount = 0;
private messageQueue: any[] = [];
private messageCallback: (msg: any) => void = () => {};
constructor(options: LongLinkOptions) {
this.options = { ...defaultOptions, ...options };
this.connect();
}
/* 创建webSocket链接 */
private connect(): void {
/* 清理之前的定时器和链接 */
this.clearTimers();
if (this.ws) {
this.ws.close();
this.ws = null;
}
this.ws = new WebSocket(this.options.url, "协议名称");
// 绑定连接成功事件
this.ws.onopen = this.handleOpen.bind(this);
// 绑定收到消息事件
this.ws.onmessage = this.handleMessage.bind(this);
// 绑定连接关闭事件
this.ws.onclose = this.handleClose.bind(this);
// 绑定连接错误事件
this.ws.onerror = this.handleError.bind(this);
}
/* 链接成功 */
private handleOpen(): void {
console.warn("[LongLink] 已连接");
this.retryCount = 0;
this.startHeartbeat();
const opList = this.normalize(this.options.opTypes);
const convList = this.normalize(this.options.conversationTypes!);
opList.forEach((op, i) => {
const conv = convList[i] || convList[0];
this.send({
type: 1,
data: {
uid: this.options.uid,
"client-info": this.options.clientInfo,
conversationType: conv,
opType: op,
heartbeatExpire: this.options.heartbeat,
...this.options.extraSendOptions,
},
});
});
}
// 接收消息
private handleMessage(evt: MessageEvent): void {
let msg: any;
try {
msg = JSON.parse(evt.data);
} catch (e) {
console.error("[LongLink] 消息解析失败", e);
return;
}
if (msg.type === 'error' && msg.data && typeof msg.data === "string") {
try {
const data = JSON.parse(msg.data);
this.messageQueue.push(data);
this.consume();
} catch (e) {
console.error("[LongLink] 业务数据解析失败", e);
}
}
if (msg.type === 0 && msg.data === 401) {
this.close(true);
}
}
// 连接关闭
private handleClose(): void {
console.warn("[LongLink] 连接关闭");
this.stopHeartbeat();
this.ws = null;
if (this.kill) return;
const max = this.options.maxRetries ?? 5;
if (this.retryCount < max) {
const delay = (this.options.retryDelay ?? 500) * (this.retryCount + 1);
console.warn(
`[LongLink] ${delay}ms 后重试 (${this.retryCount + 1}/${max})`,
);
this.reconnectTimer = setTimeout(() => {
this.retryCount++;
this.connect();
}, delay);
} else {
console.error("[LongLink] 已达最大重试次数,停止重连");
}
}
// 连接错误
private handleError(err: Event): void {
console.error("[LongLink] 错误", err);
this.ws?.close(); // 触发重连
}
// 心跳
private startHeartbeat(): void {
this.stopHeartbeat();
const interval = (this.options.heartbeat ?? 60) * 1000;
this.heartbeatTimer = setInterval(() => {
if (this.ws?.readyState === WebSocket.OPEN) {
this.send({ type: 2, data: 1 });
}
}, interval);
}
/**
* 停止心跳
*/
private stopHeartbeat(): void {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* 清除所有定时器(心跳、重连)
*/
private clearTimers(): void {
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
}
// 发送消息(仅连接打开时)
private send(msg: SocketMessage): void {
if (this.ws?.readyState === WebSocket.OPEN) {
this.ws.send(JSON.stringify(msg));
}
}
// 数组化处理
private normalize<T>(val: T | T[]): T[] {
return Array.isArray(val) ? val : [val];
}
// 消费队列
private consume(): void {
if (!this.messageQueue.length) return;
if (typeof this.messageCallback !== "function") return;
const msg = this.messageQueue.shift();
this.messageCallback(msg);
}
// ---------- 公开 API ----------
public onMessage(cb: (msg: any) => void): void {
this.messageCallback = cb;
this.consume(); // 立即消费已有消息
}
public close(kill: boolean = false): void {
this.kill = kill;
this.clearTimers();
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
// ---------- 单例 ----------
let instance: WsLink | null = null;
export function initLongLink(options: LongLinkOptions): Pick<WsLink, 'onMessage' | 'close'> {
if (!instance) {
instance = new WsLink(options);
}
return {
onMessage: instance.onMessage.bind(instance),
close: instance.close.bind(instance),
};
}