前端 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 等。它们提供了更完善的重连、心跳、多路复用、房间管理等功能,并且有良好的跨浏览器兼容性。
相关推荐
Dragon Wu几秒前
React state在setInterval里未获取最新值的问题
前端·javascript·react.js·前端框架
Jinuss1 分钟前
Vue3源码reactivity响应式篇之watch实现
前端·vue3
YU大宗师4 分钟前
React面试题
前端·javascript·react.js
木兮xg5 分钟前
react基础篇
前端·react.js·前端框架
ssshooter29 分钟前
你知道怎么用 pnpm 临时给某个库打补丁吗?
前端·面试·npm
IT利刃出鞘1 小时前
HTML--最简的二级菜单页面
前端·html
yume_sibai1 小时前
HTML HTML基础(4)
前端·html
给月亮点灯|2 小时前
Vue基础知识-Vue集成 Element UI全量引入与按需引入
前端·javascript·vue.js
知识分享小能手2 小时前
React学习教程,从入门到精通,React 组件生命周期详解(适用于 React 16.3+,推荐函数组件 + Hooks)(17)
前端·javascript·vue.js·学习·react.js·前端框架·vue3
面向星辰2 小时前
html音视频和超链接标签,颜色标签
前端·html·音视频