前端 WebSocket 笔记

前端 WebSocket 是一种在客户端(浏览器)和服务器之间建立持久性连接的通信协议,它允许双方进行全双工(full-duplex)通信,即数据可以同时在两个方向上发送。与传统的 HTTP 请求-响应模型不同,WebSocket 建立连接后,服务器可以主动向客户端推送数据,而无需客户端发起请求。这使得 WebSocket 非常适合实现实时应用,如聊天室、在线游戏、实时数据更新等。

WebSocket 的基本使用

在前端使用 WebSocket 非常简单,主要通过 WebSocket 构造函数和其提供的事件监听器及方法。

  1. 创建 WebSocket 实例:

    js 复制代码
    const ws = new WebSocket('ws://localhost:8080/ws');
    // 对于加密连接,使用 wss://
    // const ws = new WebSocket('wss://your-domain.com/ws');
    • ws:// 是 WebSocket 的非加密协议。
    • wss:// 是 WebSocket 的加密协议(WebSocket Secure),基于 TLS/SSL,类似于 HTTPS。
  2. 事件监听:

    WebSocket 实例会触发以下事件,用于处理连接状态和接收消息:

    • onopen:连接成功建立时触发。
    • onmessage:从服务器接收到消息时触发。event.data 包含接收到的数据。
    • onerror:发生错误时触发。
    • onclose:连接关闭时触发。event.codeevent.reason 提供关闭原因。
  3. 发送消息:

    使用 send() 方法向服务器发送数据。

    js 复制代码
    ws.send('Hello Server!');
    ws.send(JSON.stringify({ type: 'chat', message: 'Hi there!' }));
    • send() 方法可以发送字符串、Blob、ArrayBuffer 或 ArrayBufferView 类型的数据。
  4. 关闭连接:

    使用 close() 方法关闭 WebSocket 连接。

    js 复制代码
    ws.close(1000, 'Closing normally'); // 可选地提供状态码和原因

基本使用示例:

html 复制代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>WebSocket Basic Demo</title>
</head>
<body>
    <h1>WebSocket 演示</h1>
    <input type="text" id="messageInput" placeholder="输入消息">
    <button id="sendButton">发送</button>
    <div id="messages"></div>

    <script>
        const messageInput = document.getElementById('messageInput');
        const sendButton = document.getElementById('sendButton');
        const messagesDiv = document.getElementById('messages');

        // 假设你的 WebSocket 服务器运行在 ws://localhost:8080/ws
        // 你需要一个后端服务器来运行 WebSocket 服务
        const ws = new WebSocket('ws://localhost:8080/ws');

        ws.onopen = (event) => {
            console.log('WebSocket 连接已建立!', event);
            messagesDiv.innerHTML += '<p style="color: green;">连接成功!</p>';
        };

        ws.onmessage = (event) => {
            console.log('收到消息:', event.data);
            messagesDiv.innerHTML += `<p><strong>服务器:</strong> ${event.data}</p>`;
        };

        ws.onerror = (event) => {
            console.error('WebSocket 错误:', event);
            messagesDiv.innerHTML += '<p style="color: red;">连接错误!</p>';
        };

        ws.onclose = (event) => {
            console.log('WebSocket 连接已关闭!', event.code, event.reason);
            messagesDiv.innerHTML += `<p style="color: gray;">连接已关闭 (${event.code}): ${event.reason}</p>`;
            // 在实际应用中,这里会触发重连机制
        };

        sendButton.addEventListener('click', () => {
            const message = messageInput.value;
            if (message && ws.readyState === WebSocket.OPEN) {
                ws.send(message);
                messagesDiv.innerHTML += `<p><strong>我:</strong> ${message}</p>`;
                messageInput.value = '';
            } else if (ws.readyState !== WebSocket.OPEN) {
                messagesDiv.innerHTML += '<p style="color: orange;">连接未打开,无法发送消息。</p>';
            }
        });
    </script>
</body>
</html>

WebSocket 的重连机制

WebSocket 连接可能因为多种原因中断,例如网络不稳定、服务器重启、客户端断网等。为了提供更好的用户体验,前端需要实现自动重连机制。

重连策略:

  1. 监听 onclose 事件: 当连接关闭时,onclose 事件会触发,这是启动重连的信号。
  2. 延迟重连: 立即重连可能会导致服务器压力过大,或在网络不稳定时陷入无限重连循环。通常采用指数退避 (Exponential Backoff) 策略,即每次重连失败后,等待时间逐渐增加。
  3. 最大重连次数/最大延迟: 设置一个重连次数上限或最大延迟时间,避免无休止的重连。
  4. 清除旧连接: 在尝试新连接之前,确保清理掉任何旧的、可能处于挂起状态的连接实例。
  5. 连接状态管理: 维护一个连接状态变量(例如 connecting, connected, disconnected),以避免重复重连或在连接未建立时发送消息。

重连机制示例:

js 复制代码
class WebSocketClient {
    constructor(url, options = {}) {
        this.url = url;
        this.options = {
            reconnectInterval: options.reconnectInterval || 1000, // 初始重连间隔
            maxReconnectInterval: options.maxReconnectInterval || 30000, // 最大重连间隔
            reconnectAttempts: options.reconnectAttempts || Infinity, // 最大重连尝试次数
            debug: options.debug || false,
            // ... 其他选项
        };

        this.ws = null;
        this.reconnectTimer = null;
        this.currentReconnectInterval = this.options.reconnectInterval;
        this.reconnectCount = 0;
        this.isConnected = false;
        this.shouldReconnect = true; // 标志是否应该尝试重连

        this.onOpenCallback = options.onOpen || (() => {});
        this.onMessageCallback = options.onMessage || (() => {});
        this.onErrorCallback = options.onError || (() => {});
        this.onCloseCallback = options.onClose || (() => {});

        this.connect();
    }

    log(...args) {
        if (this.options.debug) {
            console.log('[WebSocketClient]', ...args);
        }
    }

    connect() {
        this.log('尝试连接...');
        this.ws = new WebSocket(this.url);

        this.ws.onopen = (event) => {
            this.log('连接已建立!');
            this.isConnected = true;
            this.reconnectCount = 0; // 重置重连计数
            this.currentReconnectInterval = this.options.reconnectInterval; // 重置重连间隔
            clearTimeout(this.reconnectTimer); // 清除任何待处理的重连定时器
            this.onOpenCallback(event);
        };

        this.ws.onmessage = (event) => {
            this.onMessageCallback(event);
        };

        this.ws.onerror = (event) => {
            this.log('连接错误!', event);
            this.onErrorCallback(event);
            // 错误发生时,通常也会触发 onclose
        };

        this.ws.onclose = (event) => {
            this.log('连接已关闭!', event.code, event.reason);
            this.isConnected = false;
            this.onCloseCallback(event);

            if (this.shouldReconnect && event.code !== 1000) { // 1000 是正常关闭
                this.reconnect();
            }
        };
    }

    reconnect() {
        if (this.reconnectCount >= this.options.reconnectAttempts) {
            this.log('达到最大重连尝试次数,停止重连。');
            this.shouldReconnect = false; // 停止重连
            return;
        }

        this.reconnectCount++;
        this.currentReconnectInterval = Math.min(
            this.currentReconnectInterval * 2, // 指数退避
            this.options.maxReconnectInterval
        );

        this.log(`将在 ${this.currentReconnectInterval / 1000} 秒后尝试重连 (第 ${this.reconnectCount} 次)...`);
        this.reconnectTimer = setTimeout(() => {
            this.connect();
        }, this.currentReconnectInterval);
    }

    send(data) {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(data);
        } else {
            this.log('连接未打开,无法发送消息。');
            // 可以在这里将消息排队,待连接恢复后发送
        }
    }

    close() {
        this.shouldReconnect = false; // 用户主动关闭,不重连
        clearTimeout(this.reconnectTimer);
        if (this.ws) {
            this.ws.close(1000, 'Client initiated close');
        }
    }
}

// 如何使用
const client = new WebSocketClient('ws://localhost:8080/ws', {
    reconnectInterval: 500,
    maxReconnectInterval: 10000,
    reconnectAttempts: 10,
    debug: true,
    onOpen: () => {
        console.log('自定义:连接已打开!');
        client.send('Hello from client after reconnect!');
    },
    onMessage: (event) => {
        console.log('自定义:收到消息:', event.data);
    },
    onError: (event) => {
        console.error('自定义:发生错误!');
    },
    onClose: (event) => {
        console.log('自定义:连接已关闭!');
    }
});

// 模拟发送消息
setTimeout(() => {
    client.send('Test message 1');
}, 2000);

// 模拟用户主动关闭连接
// setTimeout(() => {
//     client.close();
// }, 10000);

WebSocket 的心跳检测(Ping/Pong)

即使 WebSocket 连接看起来是打开的,但由于网络代理、防火墙或服务器端超时设置,连接可能在不发送数据的情况下"死掉"而不会触发 onclose 事件。心跳检测就是用来解决这个问题,确保连接的活跃性。

心跳检测策略:

  1. 客户端发送 Ping: 客户端定时向服务器发送一个"心跳"消息(通常是简单的字符串如 "ping" 或特定的 JSON 结构)。
  2. 服务器响应 Pong: 服务器收到 Ping 后,应立即回复一个"Pong"消息。
  3. 超时检测: 客户端在发送 Ping 后启动一个定时器。如果在一定时间内没有收到 Pong 响应,就认为连接已死,主动关闭连接并触发重连机制。
  4. 清除定时器: 在连接建立 (onopen) 时启动心跳,在连接关闭 (onclose) 或错误 (onerror) 时清除心跳定时器。

心跳检测示例(集成到 WebSocketClient 类中):

js 复制代码
class WebSocketClientWithHeartbeat {
    constructor(url, options = {}) {
        this.url = url;
        this.options = {
            reconnectInterval: options.reconnectInterval || 1000,
            maxReconnectInterval: options.maxReconnectInterval || 30000,
            reconnectAttempts: options.reconnectAttempts || Infinity,
            pingInterval: options.pingInterval || 30000, // 客户端发送 ping 的间隔 (30秒)
            pongTimeout: options.pongTimeout || 10000, // 接收 pong 的超时时间 (10秒)
            debug: options.debug || false,
        };

        this.ws = null;
        this.reconnectTimer = null;
        this.currentReconnectInterval = this.options.reconnectInterval;
        this.reconnectCount = 0;
        this.isConnected = false;
        this.shouldReconnect = true;

        this.pingTimer = null; // 发送 ping 的定时器
        this.pongTimeoutTimer = null; // 等待 pong 的超时定时器

        this.onOpenCallback = options.onOpen || (() => {});
        this.onMessageCallback = options.onMessage || (() => {});
        this.onErrorCallback = options.onError || (() => {});
        this.onCloseCallback = options.onClose || (() => {});

        this.connect();
    }

    log(...args) {
        if (this.options.debug) {
            console.log('[WebSocketClientWithHeartbeat]', ...args);
        }
    }

    connect() {
        this.log('尝试连接...');
        this.ws = new WebSocket(this.url);

        this.ws.onopen = (event) => {
            this.log('连接已建立!');
            this.isConnected = true;
            this.reconnectCount = 0;
            this.currentReconnectInterval = this.options.reconnectInterval;
            clearTimeout(this.reconnectTimer);
            this.startHeartbeat(); // 连接成功后启动心跳
            this.onOpenCallback(event);
        };

        this.ws.onmessage = (event) => {
            // 如果服务器回复了 pong 消息,重置 pong 超时定时器
            if (event.data === 'pong') {
                this.log('收到 pong 响应。');
                clearTimeout(this.pongTimeoutTimer);
                this.pongTimeoutTimer = null; // 清除超时定时器
                this.startPongTimeout(); // 重新启动 pong 超时检测
            } else {
                this.onMessageCallback(event);
            }
        };

        this.ws.onerror = (event) => {
            this.log('连接错误!', event);
            this.onErrorCallback(event);
            // 错误发生时,通常也会触发 onclose
        };

        this.ws.onclose = (event) => {
            this.log('连接已关闭!', event.code, event.reason);
            this.isConnected = false;
            this.stopHeartbeat(); // 连接关闭时停止心跳
            this.onCloseCallback(event);

            if (this.shouldReconnect && event.code !== 1000) {
                this.reconnect();
            }
        };
    }

    reconnect() {
        if (this.reconnectCount >= this.options.reconnectAttempts) {
            this.log('达到最大重连尝试次数,停止重连。');
            this.shouldReconnect = false;
            return;
        }

        this.reconnectCount++;
        this.currentReconnectInterval = Math.min(
            this.currentReconnectInterval * 2,
            this.options.maxReconnectInterval
        );

        this.log(`将在 ${this.currentReconnectInterval / 1000} 秒后尝试重连 (第 ${this.reconnectCount} 次)...`);
        this.reconnectTimer = setTimeout(() => {
            this.connect();
        }, this.currentReconnectInterval);
    }

    startHeartbeat() {
        this.log('启动心跳检测...');
        this.pingTimer = setInterval(() => {
            if (this.ws && this.ws.readyState === WebSocket.OPEN) {
                this.log('发送 ping...');
                this.ws.send('ping'); // 发送 ping 消息
                this.startPongTimeout(); // 启动等待 pong 的超时定时器
            }
        }, this.options.pingInterval);
    }

    startPongTimeout() {
        // 如果已经有等待 pong 的定时器,先清除
        if (this.pongTimeoutTimer) {
            clearTimeout(this.pongTimeoutTimer);
        }
        this.pongTimeoutTimer = setTimeout(() => {
            this.log('未收到 pong 响应,连接可能已断开,主动关闭连接。');
            if (this.ws) {
                this.ws.close(1008, 'Pong timeout'); // 1008 是协议错误码,表示无法处理的消息
            }
        }, this.options.pongTimeout);
    }

    stopHeartbeat() {
        this.log('停止心跳检测。');
        clearInterval(this.pingTimer);
        clearTimeout(this.pongTimeoutTimer);
        this.pingTimer = null;
        this.pongTimeoutTimer = null;
    }

    send(data) {
        if (this.ws && this.ws.readyState === WebSocket.OPEN) {
            this.ws.send(data);
        } else {
            this.log('连接未打开,无法发送消息。');
        }
    }

    close() {
        this.shouldReconnect = false;
        this.stopHeartbeat();
        clearTimeout(this.reconnectTimer);
        if (this.ws) {
            this.ws.close(1000, 'Client initiated close');
        }
    }
}

// 如何使用
const clientWithHeartbeat = new WebSocketClientWithHeartbeat('ws://localhost:8080/ws', {
    reconnectInterval: 500,
    maxReconnectInterval: 10000,
    reconnectAttempts: 10,
    pingInterval: 5000, // 每5秒发送一次 ping
    pongTimeout: 3000,  // 3秒内未收到 pong 则认为断开
    debug: true,
    onOpen: () => {
        console.log('自定义:连接已打开!');
    },
    onMessage: (event) => {
        console.log('自定义:收到消息:', event.data);
    },
    onError: (event) => {
        console.error('自定义:发生错误!');
    },
    onClose: (event) => {
        console.log('自定义:连接已关闭!');
    }
});

// 模拟发送消息
setTimeout(() => {
    clientWithHeartbeat.send('Test message with heartbeat');
}, 2000);

服务器端心跳的配合:

请注意,心跳检测需要服务器端的配合。服务器需要:

  • 接收到客户端的 ping 消息后,立即回复 pong 消息。
  • 服务器也可以主动向客户端发送 ping 消息,并等待客户端回复 pong,如果超时未收到,则服务器端关闭连接。

总结与最佳实践

  • 封装: 将 WebSocket 的连接、重连、心跳等逻辑封装成一个独立的类或模块,方便复用和管理。
  • 状态管理: 明确管理连接的各种状态(连接中、已连接、断开),避免在不正确的状态下执行操作。
  • 错误处理: 仔细处理 onerror 事件,并根据错误类型决定是否重连。
  • 消息队列: 在连接断开期间,可以考虑将待发送的消息存储在一个队列中,待连接恢复后再发送。
  • 安全性: 始终使用 wss:// 进行生产环境的 WebSocket 连接,以加密通信。服务器端也应进行来源(Origin)验证。
  • 选择合适的库: 对于复杂的实时应用,考虑使用成熟的 WebSocket 库,如 Socket.IOSockJS 等。它们提供了更完善的重连、心跳、多路复用、房间管理等功能,并且有良好的跨浏览器兼容性。
相关推荐
小小小小宇3 小时前
前端并发控制管理
前端
小小小小宇4 小时前
前端SSE笔记
前端
小小小小宇5 小时前
前端visibilitychange事件
前端
小小小小宇5 小时前
前端Loader笔记
前端
烛阴6 小时前
从0到1掌握盒子模型:精准控制网页布局的秘诀
前端·javascript·css
前端工作日常9 小时前
我理解的`npm pack` 和 `npm install <local-path>`
前端
李剑一10 小时前
说个多年老前端都不知道的标签正确玩法——q标签
前端
嘉小华10 小时前
大白话讲解 Android屏幕适配相关概念(dp、px 和 dpi)
前端
姑苏洛言10 小时前
在开发跑腿小程序集成地图时,遇到的坑,MapContext.includePoints(Object object)接口无效在组件中使用无效?
前端