如何实现多 Tab 复用 WebSocket?一文搞定高效实时通信技术!

973 阅读4分钟

如何实现多 Tab 复用 WebSocket?一文搞定高效实时通信技术!

你好,各位读者,我是梦兽,一名热衷于 WEB 全栈开发及 Rust 编程的爱好者。若你也对 Rust 情有独钟,欢迎关注我的公众号 “梦兽编程”,加入我们的技术交流群,一同探讨前沿科技。


在现代 Web 应用中,WebSocket 是实现实时通信的关键技术。然而,当用户在多个标签页(Tab)中打开同一个应用时,每个 Tab 都会独立创建一个 WebSocket 连接,这会导致以下问题:

  • 资源浪费:每个 WebSocket 连接都占用服务器和客户端的资源,增加了性能开销。
  • 连接限制:浏览器对同一域名的 WebSocket 连接数有限制,多个连接可能导致服务不可用。

为了解决这些问题,我们可以通过共享 WebSocket 实现多 Tab 的高效通信。本文将介绍如何通过 localStorage 和 BroadcastChannel 等技术实现这一目标,并提供完整的代码示例和图表说明。

同一个浏览器多个Tab

在多 Tab 场景下,每个 Tab 都需要接收 WebSocket 消息,但独立创建 WebSocket 连接会导致资源浪费和重复消息接收的问题。

根据梦兽的web开发经验我们可以使用以下方式进行实现。

  1. 主从模型:通过 localStorage 或其他机制,选定一个 Tab 作为“主标签页”(Master Tab),由它负责创建 WebSocket 连接并处理消息。
  2. 消息广播:主标签页通过 BroadcastChannel 或 localStorage 将接收到的消息广播给其他 Tab。
  3. 动态切换主标签页:当主标签页关闭时,其他 Tab 自动接管 WebSocket 连接。

截屏2025-01-21 16.42.28.png

竞选 主标签页

在竞选标签中。梦兽这里使用localStorage实现这个功能。这个是核心代码。

'use client';

import { useEffect, useRef } from 'react';
import { LocalStorage } from '@/hooks';

function useWebSocket() {
  const TAB_ID = useRef(Date.now()).current;
  console.log('Client ID', TAB_ID);

  const wsRef = useRef<WebSocket | null>(null);
  // 主标签页的标识键
  const MASTER_KEY = 'websocket_master';

  // 尝试成为主标签页
  function tryBecomeMaster() {
    // 先获取当前的主标签页值
    const currentMaster = LocalStorage.getInstance().get(MASTER_KEY);

    if (!currentMaster) {
      LocalStorage.getInstance().set(MASTER_KEY, TAB_ID);
      // 双重检查,确保真的成为了主标签页
      if (LocalStorage.getInstance().get(MASTER_KEY) === TAB_ID.toString()) {
        LocalStorage.getInstance().set(MASTER_KEY, TAB_ID);
        startWebSocket();
      }
    }
  }

  // 启动 WebSocket 连接
  function startWebSocket() {
    console.log('xxxxx');

    // 创建 WebSocket 实例
    wsRef.current = new WebSocket('wss://your-websocket-url');

    wsRef.current.onopen = function () {
      console.log('WebSocket 已连接');
    };

    wsRef.current.onmessage = function (event) {
      console.log('收到消息:', event.data);
    };

    wsRef.current.onclose = function () {
      console.log('WebSocket 已关闭');
      // 如果主标签页关闭,尝试重新成为主标签页
      // localStorage.removeItem(MASTER_KEY);
    };
  }

  useEffect(() => {
    // 监听 storage 事件,检测主标签页的变化
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === MASTER_KEY) {
        if (!event.newValue) {
          tryBecomeMaster();
        }
      }
    };

    // 在页面卸载时,释放主标签页的标识
    const handleBeforeUnload = () => {
      console.log(LocalStorage.instance.get(MASTER_KEY), TAB_ID);
      if (LocalStorage.instance.get(MASTER_KEY) === TAB_ID) {
        LocalStorage.instance.remove(MASTER_KEY);
      }
    };

    // 监听 storage 事件,检测主标签页的变化
    window.addEventListener('storage', handleStorageChange);
    // 在页面卸载时,释放主标签页的标识
    window.addEventListener('beforeunload', handleBeforeUnload);

    tryBecomeMaster();

    return () => {
      window.removeEventListener('storage', handleStorageChange);
      window.removeEventListener('beforeunload', handleBeforeUnload);
    };
  }, []);
}

export default useWebSocket;

WebSocket消息转发

  const broadcastChannel = new BroadcastChannel('MASTER_MESSAGE');
  const followChannel = new BroadcastChannel('FOLLOW_MESSAGE');
 // ...  省略亿点代码
  wsRef.current.onmessage = function (event) {
      console.log('收到消息:', event.data);
      if(LocalStorage.getInstance().get(MASTER_KEY) === TAB_ID) {
     	 broadcastChannel.postMessage(event.data);
      }
    };
     // ...  省略亿点代码
  
  wsRef.current.onopen = function () {
        followChannel.onMessage((event)=>{
        
         if(LocalStorage.getInstance().get(MASTER_KEY) === TAB_ID) {
          	const data = event.data;
  			wsRef.send(data);
         }
  		});
    };
   // ...  省略亿点代码

Change Hooks

如果不想使用使用上下文,或者状态库进行通信的话。可以封装一个hooks。再对应的组件引入。虽然会比上下文或者状态库这种方式多一点内存,但也是有优点,就是你不用关系渲染的细腻度可以自己控制到对应的组件进行监听。按需渲染

import { useEffect, useState } from 'react';
import { BroadcastChannel } from '@/packages';

function useBroadcastChannel(channelName: string) {
  const [message, setMessage] = useState();

  useEffect(() => {
    const broadcastChannel = new BroadcastChannel(channelName);
    broadcastChannel.onMessage(event => {
      setMessage(event.data);
    });
  }, []);

  return {
    message,
  };
}

export default useBroadcastChannel;

WebSocket 消息转发的完整实现

在多 Tab 复用 WebSocket 的场景下,当主标签页接收到服务器的 WebSocket 消息时,需要将消息转发给其他标签页。通过 BroadcastChannel,我们可以高效地实现这一功能。

'use client';

import { useEffect, useRef } from 'react';
import { LocalStorage } from '@/hooks';

function useWebSocket() {
  const TAB_ID = useRef(Date.now()).current;
  const wsRef = useRef<WebSocket | null>(null);
  const MASTER_KEY = 'websocket_master';

  const broadcastChannel = new BroadcastChannel('MASTER_MESSAGE');

  // 尝试成为主标签页
  function tryBecomeMaster() {
    const currentMaster = LocalStorage.getInstance().get(MASTER_KEY);

    if (!currentMaster) {
      LocalStorage.getInstance().set(MASTER_KEY, TAB_ID);
      if (LocalStorage.getInstance().get(MASTER_KEY) === TAB_ID.toString()) {
        startWebSocket();
      }
    }
  }

  // 启动 WebSocket 连接
  function startWebSocket() {
    wsRef.current = new WebSocket('wss://your-websocket-url');

    wsRef.current.onopen = () => {
      console.log('WebSocket 已连接');
    };

    wsRef.current.onmessage = (event) => {
      console.log('收到消息:', event.data);

      // 主标签页通过 BroadcastChannel 广播消息
      if (LocalStorage.getInstance().get(MASTER_KEY) === TAB_ID.toString()) {
        broadcastChannel.postMessage(event.data);
      }
    };

    wsRef.current.onclose = () => {
      console.log('WebSocket 已关闭');
    };
  }

  useEffect(() => {
    const handleStorageChange = (event: StorageEvent) => {
      if (event.key === MASTER_KEY && !event.newValue) {
        tryBecomeMaster();
      }
    };

    const handleBeforeUnload = () => {
      if (LocalStorage.getInstance().get(MASTER_KEY) === TAB_ID.toString()) {
        LocalStorage.getInstance().remove(MASTER_KEY);
      }
    };

    // 监听主标签页消息的变化
    broadcastChannel.onmessage = (event) => {
      console.log('从主标签页接收到消息:', event.data);
      // 在从标签页中处理收到的消息
    };

    window.addEventListener('storage', handleStorageChange);
    window.addEventListener('beforeunload', handleBeforeUnload);

    tryBecomeMaster();

    return () => {
      window.removeEventListener('storage', handleStorageChange);
      window.removeEventListener('beforeunload', handleBeforeUnload);
      broadcastChannel.close();
    };
  }, []);
}

export default useWebSocket;

通过以上代码和思路,你可以轻松实现多 Tab 复用 WebSocket 的功能,从而提升 Web 应用的性能和用户体验。如果你在实现过程中遇到问题,欢迎随时留言讨论!

进一步学习

如果你对本文内容感兴趣,欢迎点赞、分享,或关注我的技术博客,获取更多 Rust 编程的精彩内容!如果有任何疑问或建议,欢迎在评论区留言,我们一起探讨!