背景
最近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 连接
✅ 状态强一致 所有页签看到完全相同的实时数据
✅ 资源高效利用 降低服务器负载,提升系统并发能力
✅ 无感切换 用户在不同页签间切换,通信不间断
✅ 前端自治 不依赖后端改造,纯前端架构优化
注意事项
- 兼容性处理: 旧版 Safari/iOS 可能不支持 BroadcastChannel,需降级方案(如使用 localStorage + storage 事件模拟);
- 安全性: 仅限同源页面通信,天然安全;但避免传输敏感明文数据;
- 错误边界: 需处理 Master 崩溃、网络中断、页面刷新等异常场景;
- 性能: 消息广播是轻量级的,对性能影响极小。