在现代 Web 应用中,实时通信已成为不可或缺的功能之一。无论是即时通讯、在线游戏还是协同办公,都需要一种低延迟、高吞吐的双向通信机制。WebSocket 正是为此而生的技术标准,它弥补了传统 HTTP 请求-响应模式的不足,实现了全双工通信。

然而,在实际开发中,如何构建一个高可用、稳定可靠的 WebSocket 长连接却并非易事。本文将深入探讨 WebSocket 的工作原理,并结合实战案例分享一套完整的优化方案。


一、WebSocket 基础回顾

1.1 什么是 WebSocket?

WebSocket 是 HTML5 提供的一种协议,允许客户端与服务器之间建立持久化的双向通信通道。相比传统的轮询或长轮询方式,WebSocket 具有以下优势:

  • 低延迟:建立连接后无需频繁握手,数据可直接传输。
  • 低开销:头部信息更小,减少了不必要的网络负载。
  • 全双工:支持同时收发数据,无需等待对方响应。

1.2 WebSocket 生命周期

一个典型的 WebSocket 生命周期包括以下几个阶段:

  1. 握手阶段:客户端发起 HTTP Upgrade 请求,升级为 WebSocket 协议。
  2. 连接建立:服务器返回 101 Switching Protocols 状态码,表示协议切换成功。
  3. 数据交互:双方通过 onopenonmessageonerroronclose 四个事件进行通信。
  4. 连接关闭:任一方调用 [close()](file://d:\alyunProject\health-advistor-care\src\utils\websocket.ts#L27-L34) 方法或因异常断开连接。

二、WebSocket 长连接面临的挑战

尽管 WebSocket 功能强大,但在实际应用中仍存在诸多挑战:

2.1 网络不稳定导致连接中断

移动网络、防火墙、代理服务器等因素可能导致连接意外断开。如果没有完善的重连机制,用户体验将大打折扣。

2.2 中间设备切断空闲连接

许多 NAT 设备或负载均衡器会在长时间无通信后主动关闭连接。此时即使两端都认为连接正常,也无法继续通信。

2.3 消息丢失风险

在网络抖动或连接重建过程中,尚未发送的数据可能丢失,影响业务完整性。

2.4 错误处理粒度不足

通用错误回调难以满足复杂场景的需求,例如区分认证失败、网络异常等不同情况。


三、优化方案详解

为了解决上述问题,我们可以从以下几个维度入手,构建一个健壮的 WebSocket 长连接系统。

3.1 自动重连机制

自动重连是最基础也是最重要的功能。其核心思想是在连接断开后尝试重新建立连接,直至达到最大重试次数。

实现要点:
  • 设置最大重连次数(如 3 次),避免无限重试造成资源浪费。
  • 使用指数退避策略逐步延长重连间隔,减轻服务器压力。
  • 区分主动关闭和被动关闭,仅在被动关闭时触发重连。
private reconnect(): void {
    this.clearReconnectTimer();
    if (this.reconnectAttempts < this.maxAttempts) {
        this.reconnectAttempts++;
        this.reconnectTimeoutId = setTimeout(() => {
            console.log(`${this.reconnectAttempts} 次重连...`);
            this.open();
        }, this.reconnectTimeoutMs * this.reconnectAttempts);
    } else {
        console.error('已达最大重连次数,停止重连');
    }
}

3.2 心跳保活机制

为了应对中间设备切断空闲连接的问题,我们需要引入心跳机制,定期向服务器发送探测包并等待响应。

实现要点:
  • 定时发送 ping 消息,频率可根据业务需求调整(如每 30 秒一次)。
  • 设置心跳超时时间(如 5 秒),若未收到 pong 响应则主动断开连接并触发重连。
private startHeartbeat(): void {
    this.heartbeatIntervalId = setInterval(() => {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            this.send({ type: 'ping' });
            this.heartbeatTimeoutId = setTimeout(() => {
                console.warn('心跳超时,断开连接');
                this.close();
            }, 5000);
        }
    }, 30000);
}

3.3 消息队列缓冲机制

为了避免网络波动期间的消息丢失,我们可以在本地维护一个消息队列,暂存未发送的数据并在连接恢复后补发。

实现要点:
  • 使用数组作为队列容器,支持先进先出(FIFO)顺序。
  • 在连接断开时将消息入队,在连接恢复后批量发送。
流程图示意:
用户调用 send(data)
       ↓
判断 WebSocket 状态是否为 OPEN?
       ↓ 是
   直接发送消息
       ↓
       否
   将消息加入 messageQueue 队列
       ↓
等待连接恢复(handleOpen 触发)
       ↓
调用 flushMessageQueue()
       ↓
循环发送队列中的所有消息
       ↓
清空队列,完成补发
public send(data: any): void {
    if (this.ws && this.ws.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify(data));
    } else {
        this.messageQueue.push(data);
    }
}

private flushMessageQueue(): void {
    while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
        const message = this.messageQueue.shift();
        this.ws.send(JSON.stringify(message));
    }
}

3.4 细粒度错误处理

针对不同的错误类型采取差异化的处理策略,提升系统的稳健性。

实现要点:
  • 解析错误信息,识别具体原因(如认证失败、网络异常等)。
  • 对关键错误(如认证失败)直接终止重连流程,避免无效尝试。
private handleError = (error: Event): void => {
    const errorMessage = (error as any).message || '未知错误';
    console.error('WebSocket错误:', errorMessage);

    if (errorMessage.includes('认证失败')) {
        console.warn('认证失败,停止重连');
        return;
    }

    this.reconnect();
};

四、Vue + TypeScript 实战示例

下面是一个基于 Vue 3 和 TypeScript 的完整实现示例,展示了如何封装一个高可用的 WebSocket 工具库。

4.1 核心代码

// src/utils/websocket.ts
import { ref, onUnmounted } from 'vue';

interface WebSocketOptions {
    url: string;
    protocols?: string | string[];
    reconnectTimeout?: number; // 重连间隔(毫秒)
    maxReconnectAttempts?: number; // 最大重连次数
    heartbeatInterval?: number; // 心跳间隔(毫秒)
}

class WebSocketService {
    private ws: WebSocket | null = null;
    private callbacks: { [key: string]: Function[] } = {};
    private reconnectTimeoutMs: number = 2000; // 默认重连间隔
    private reconnectAttempts = 0; // 已重连次数
    private maxAttempts = 3; // 默认最大重连次数
    public hasMessageListener = false; // 是否已注册消息监听器
    private reconnectTimeoutId: NodeJS.Timeout | null = null; // 重连定时器ID
    private heartbeatIntervalId: NodeJS.Timeout | null = null; // 心跳定时器ID
    private heartbeatTimeoutId: NodeJS.Timeout | null = null; // 心跳超时定时器ID
    private messageQueue: any[] = []; // 消息队列

    constructor(private options: WebSocketOptions) {
        this.reconnectTimeoutMs = options.reconnectTimeout ?? this.reconnectTimeoutMs;
        this.maxAttempts = options.maxReconnectAttempts ?? this.maxAttempts;
        this.heartbeatInterval = options.heartbeatInterval ?? 30000;
    }

    public open(): void {
        this.ws = new WebSocket(this.options.url, this.options.protocols);
        this.ws.addEventListener('open', this.handleOpen);
        this.ws.addEventListener('message', this.handleMessage);
        this.ws.addEventListener('error', this.handleError);
        this.ws.addEventListener('close', this.handleClose);
    }

    public close(isActiveClose = false): void {
        if (this.ws) {
            this.ws.close();
            if (!isActiveClose) {
                this.reconnect();
            }
        }
    }

    public reconnect(): void {
        this.clearReconnectTimer();
        if (this.reconnectAttempts < this.maxAttempts) {
            this.reconnectAttempts++;
            this.reconnectTimeoutId = setTimeout(() => {
                console.log(`${this.reconnectAttempts} 次重连...`);
                this.open();
            }, this.reconnectTimeoutMs * this.reconnectAttempts); // 指数退避策略
        } else {
            console.error('已达最大重连次数,停止重连');
        }
    }

    private clearReconnectTimer() {
        if (this.reconnectTimeoutId) {
            clearTimeout(this.reconnectTimeoutId);
            this.reconnectTimeoutId = null;
        }
    }

    private startHeartbeat(): void {
        this.heartbeatIntervalId = setInterval(() => {
            if (this.ws && this.ws.readyState === WebSocket.OPEN) {
                this.send({ type: 'ping' });
                this.heartbeatTimeoutId = setTimeout(() => {
                    console.warn('心跳超时,断开连接');
                    this.close(); // 主动断开连接触发重连
                }, 5000); // 心跳超时时间为5秒
            }
        }, this.heartbeatInterval);
    }

    private stopHeartbeat(): void {
        if (this.heartbeatIntervalId) {
            clearInterval(this.heartbeatIntervalId);
            this.heartbeatIntervalId = null;
        }
        if (this.heartbeatTimeoutId) {
            clearTimeout(this.heartbeatTimeoutId);
            this.heartbeatTimeoutId = null;
        }
    }

    public on(event: 'message', callback: (data: any) => void): void;
    public on(event: 'open' | 'error' | 'close', callback: () => void): void;
    public on(event: string, callback: (...args: any[]) => void): void {
        if (event === 'message' && !this.hasMessageListener) {
            this.hasMessageListener = true;
        }
        if (!this.callbacks[event]) {
            this.callbacks[event] = [];
        }
        this.callbacks[event].push(callback);
    }

    public hasMessageListeners(): boolean {
        return this.hasMessageListener;
    }

    private handleOpen = (): void => {
        console.log('WebSocket连接已建立');
        this.reconnectAttempts = 0; // 成功连接后重置重连计数
        this.startHeartbeat(); // 启动心跳
        this.flushMessageQueue(); // 清空消息队列
        if (this.callbacks.open) {
            this.callbacks.open.forEach((cb) => cb());
        }
    };

    private handleMessage = (event: MessageEvent): void => {
        const data = JSON.parse(event.data);
        console.log('WebSocket接收到消息:', data);
        if (data.type === 'pong') {
            console.log('收到心跳响应');
            if (this.heartbeatTimeoutId) {
                clearTimeout(this.heartbeatTimeoutId);
                this.heartbeatTimeoutId = null;
            }
        } else if (this.callbacks.message) {
            this.callbacks.message.forEach((cb) => cb(data));
        }
    };

    private handleError = (error: Event): void => {
        const errorMessage = (error as any).message || '未知错误';
        console.error('WebSocket错误:', errorMessage);

        if (errorMessage.includes('认证失败')) {
            console.warn('认证失败,停止重连');
            return;
        }

        if (this.callbacks.error) {
            this.callbacks.error.forEach((cb) => cb(error));
        }
        this.reconnect();
    };

    private handleClose = (): void => {
        console.log('WebSocket连接已关闭');
        this.stopHeartbeat(); // 停止心跳
        if (this.callbacks.close) {
            this.callbacks.close.forEach((cb) => cb());
            if (!this.options.reconnectTimeout) {
                this.reconnect();
            }
        }
    };

    public send(data: any): void {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(JSON.stringify(data));
            console.log('WebSocket发送消息:', data);
        } else {
            console.log('WebSocket未连接,消息加入队列');
            this.messageQueue.push(data);
        }
    }

    private flushMessageQueue(): void {
        while (this.messageQueue.length > 0 && this.ws?.readyState === WebSocket.OPEN) {
            const message = this.messageQueue.shift();
            this.ws.send(JSON.stringify(message));
            console.log('发送队列中的消息:', message);
        }
    }
}

export default function useWebSocket(options: WebSocketOptions) {
    const wsService = new WebSocketService(options);

    onUnmounted(() => {
        wsService.close(true);
    });

    return {
        open: wsService.open.bind(wsService),
        close: wsService.close.bind(wsService),
        reconnect: wsService.reconnect.bind(wsService),
        on: wsService.on.bind(wsService),
        send: wsService.send.bind(wsService),
        hasMessageListeners: wsService.hasMessageListeners.bind(wsService),
    };
}

4.2 使用示例

<!-- ChatRoom.vue -->
<template>
  <div>
    <ul>
      <li v-for="(msg, index) in messages" :key="index">{{ msg }}</li>
    </ul>
    <input v-model="inputMessage" @keyup.enter="sendMessage" />
    <button @click="sendMessage">发送</button>
  </div>
</template>

<script setup lang="ts">
import { ref } from 'vue';
import useWebSocket from '@/utils/websocket';

const inputMessage = ref('');
const messages = ref<string[]>([]);

const { open, on, send } = useWebSocket({
  url: 'ws://localhost:8080/chat',
  reconnectTimeout: 3000,
  maxReconnectAttempts: 5,
  heartbeatInterval: 20000,
});

on('message', (data) => {
  messages.value.push(data.text);
});

const sendMessage = () => {
  if (inputMessage.value.trim()) {
    send({ text: inputMessage.value });
    inputMessage.value = '';
  }
};

open();
</script>

五、总结

构建高可用的 WebSocket 长连接是一项系统工程,需要综合考虑网络环境、业务需求和技术实现等多个方面。本文提出的优化方案涵盖了自动重连、心跳保活、消息队列和错误处理四大核心模块,已在多个实际项目中得到验证。

希望这篇博客能为你提供有价值的参考,助力你打造出更加稳定、高效的实时通信系统!如果你有任何疑问或建议,欢迎留言交流 😊

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐