vue3引入websocket,websocket连接同一浏览器多页签打开优化

820 阅读4分钟

背景

最近vue3项目做一个消息通知模块,一开始想要用简单的轮询做通知,考虑后面很多地方会用到消息通知,于是决定采用websocket通讯方式。 项目是基于vue3和pina库下引入websocket。

思路

1.客户端登录系统websocket会进行连接,客户端每15秒会向服务端发送“ping”判断websocket是否正常连接,如果正常连接服务端会返回“pong”
2.当意外连接断开(可能是服务端或者客户端网络问题)时,服务端不能正常返回“pong”,客户端会关闭当前连接,重新发起连接,新的连接继续向服务端发送“ping”
3.当意外连接断开重连5次之后,客户端就不再发起连接了

具体代码

在src/store目录新建use-websocket.ts

import { defineStore } from 'pinia'
import { ref } from 'vue'
import { STORAGE_ACCESS_TOKEN_KEY } from '~/constant'

const VITE_API_HTTPS_HOST_BASE_PATH = import.meta.env.VITE_API_HTTPS_HOST_BASE_PATH as string

interface UseWebSocket {
  socket: any
  readyState: number
  socketMessage: Record<string, any>
  isReconnect: boolean
  lockReconnect: boolean
}

export const useWebsocketStore = defineStore('websocket', {
  state: (): UseWebSocket => {
    return {
      socket: null,
      readyState: 0,
      socketMessage: {},
      isReconnect: true, // 是否重连(主动断开是不重连)
      lockReconnect: false // 防止重复连接
    }
  },
  actions: {
    SET_SOCKET (socket: object) {
      this.socket = socket
    },
    SET_SOCKET_MESSAGE (socketMessage: any) {
      this.socketMessage = socketMessage
    },
    connectWebSocket () {
      if (this.lockReconnect) return
      this.lockReconnect = true
      this.isReconnect = true
      const CONNECT_TIME = 20000 // 重新连接间隔时间
      const PING_INTERVAL = 15000 // 心跳间隔,单位为毫秒
      const heartbeatMessage = 'ping' // 心跳消息
      const token = window.localStorage.getItem(STORAGE_ACCESS_TOKEN_KEY) ?? ''
      // 'wss:///dev-reseller.xmhaini.com/dev-api/message/ws'
      const HOST_ADDRESS =
        'wss:///' +
        ((import.meta.env.VITE_API_HOST as string) || window.location.host) +
        VITE_API_HTTPS_HOST_BASE_PATH +
        '/ws?serverName=hjs&token=' +
        token
      const socket = ref(new WebSocket(HOST_ADDRESS))
      this.SET_SOCKET(socket)
      setTimeout(() => {
        this.lockReconnect = false
      }, 4000)

      const heartNum = 5 // 重新连接次数
      let intervalObj: any
      const heartCheckTask = () => {
        let countDown = heartNum
        intervalObj && clearInterval(intervalObj)
        intervalObj = setInterval(() => {
          this.socket.send(heartbeatMessage)
          countDown--
          if (countDown === 0) {
            clearInterval(intervalObj)
            this.socket.close()
          }
        }, PING_INTERVAL)
      }

      // 监听连接事件
      socket.value.onopen = () => {
        heartCheckTask()
      }

      // 监听消息事件
      socket.value.onmessage = (event) => {
        try {
          if (event.data === 'pong') {
            heartCheckTask()
            return
          }
          const message = JSON.parse(event.data)
          this.SET_SOCKET_MESSAGE(message)
        } catch (error) {}
      }

      // 监听关闭事件 断线重连
      socket.value.onclose = () => {
        // 断线重连(主动断开不重连)
        if (this.isReconnect) {
          setTimeout(() => {
            this.connectWebSocket()
          }, CONNECT_TIME)
        }
      }

      // 连接错误
      socket.value.onerror = () => {
        setTimeout(() => {
          this.connectWebSocket()
        }, CONNECT_TIME)
      }
    },

    // 发送消息方法
    sendMessage (message: string) {
      this.socket.send(message)
    },

    // 主动断开连接
    handleSocketClose () {
      if (this.socket) {
        this.isReconnect = false
        this.socket.close()
      }
    }
  }
})

登录入口引用:

import { useWebsocketStore } from '~/stores/use-websocket'

const websocketStore = useWebsocketStore()
watch(
  () => authStore.user,
  (val) => {
    if (val && props.type === LayoutHeaderTypes.DEFAULT) {
      messageStore.getSystemNoticeInfo()
      websocketStore.connectWebSocket()
    } else {
      websocketStore.handleSocketClose()
    }
  },
  {
    immediate: true
  }
)

性能问题:

当用户在同一个浏览器中打开多个同源网页标签页(Tab)(例如:后台管理系统开多个页面),如果每个页签都独立建立 WebSocket 连接,会导致:
❌ 重复连接:一个用户占用多个后端连接;
❌ 资源浪费:服务器内存、带宽、连接数压力倍增(会占用大量服务器资源);
❌ 状态不一致:消息在多个页签重复推送,已读/未读状态混乱;
❌ 体验割裂:在一个页签操作(如删除通知),其他页签无法实时同步。
目标:只用一个 WebSocket 连接,服务所有同源页签。

方案一:利用 Page Visibility API 与页签协同协议,动态切换连接持有者

利用visibilitychange监听浏览器窗口变化事件优化websocket连接数。
实现基于页签活跃状态的 WebSocket 连接独占机制,确保单用户单连接,降低服务器负载 60%+;
利用 Page Visibility API 与页签协同协议,动态切换连接持有者,保障实时通信连续性与状态一致性。

改造app.vue根组件
当浏览器离开(切换/最小化/隐藏)当前页签,客户端会断开连接,当浏览器又切换回来该页签,客户端会重新连接。保证同一个浏览器打开多个页签时,只有一个页签连接websocket

import { useWebsocketStore } from '~/stores/use-websocket'

const websocketStore = useWebsocketStore()
document.addEventListener('visibilitychange', documentVisibilityChange)
function documentVisibilityChange () {
  if (document.visibilityState === 'hidden') {
    websocketStore.handleSocketClose()
  }
  if (document.visibilityState === 'visible') {
    websocketStore.connectWebSocket()
  }
}

方案二:BroadcastChannel + 主页签代理模式

核心思想:主页签代理模式(Master-Slave Architecture)

选举一个“主页签”(Master):负责与后端建立并维护唯一的 WebSocket 连接; 其余页签为“从页签”(Slave):不直接连后端,而是通过浏览器内部通信与主页签交互; 主页签代理收发:接收后端消息后广播给所有从页签;从页签的用户操作也由主页签代为转发。 💡 本质:前端实现“连接复用 + 状态同步”。

关键技术:BroadcastChannel

什么是 BroadcastChannel? 浏览器原生 API(无需第三方库); 允许同源的不同页面、iframe、Web Worker 之间广播消息; 类似“本地消息总线”,通信不经过网络,低延迟、高效率。

// 创建频道
const channel = new BroadcastChannel('my-app-ws');

// 发送消息
channel.postMessage({ type: 'NEW_MESSAGE', data: 'Hello' });

// 接收消息
channel.onmessage = (event) => {
console.log('收到:', event.data);
};

✅ 支持现代浏览器(Chrome 54+、Firefox 38+、Safari 15.4+、Edge 79+)

核心优势

✅ 连接数最小化 N 个页签 → 1 个 WebSocket 连接
✅ 状态强一致 所有页签看到完全相同的实时数据
✅ 资源高效利用 降低服务器负载,提升系统并发能力
✅ 无感切换 用户在不同页签间切换,通信不间断
✅ 前端自治 不依赖后端改造,纯前端架构优化

注意事项
  1. 兼容性处理: 旧版 Safari/iOS 可能不支持 BroadcastChannel,需降级方案(如使用 localStorage + storage 事件模拟);
  2. 安全性: 仅限同源页面通信,天然安全;但避免传输敏感明文数据;
  3. 错误边界: 需处理 Master 崩溃、网络中断、页面刷新等异常场景;
  4. 性能: 消息广播是轻量级的,对性能影响极小。