文章目录
-
- 前言
- [一、WebSocket Hook 封装](#一、WebSocket Hook 封装)
- 二、消息分发方法实现
- [三、实现通过 websocket 推送、接收消息](#三、实现通过 websocket 推送、接收消息)
-
-
- [3.1 App.vue 中创建 websocket 连接、断开链接](#3.1 App.vue 中创建 websocket 连接、断开链接)
- [3.2 页面推送消息](#3.2 页面推送消息)
-
-
- [3.2.1 安装 emitter](#3.2.1 安装 emitter)
- [3.2.2 App.vue 文件中暴露方法](#3.2.2 App.vue 文件中暴露方法)
- [3.2.3 wsDispatcher.ts 中接收](#3.2.3 wsDispatcher.ts 中接收)
- [3.2.4 .vue 文件中使用](#3.2.4 .vue 文件中使用)
-
- [3.3 页面接收消息(通过注册 handler 实现)](#3.3 页面接收消息(通过注册 handler 实现))
- [3.4 页面接收消息Pro版本写法,实现批量注册,自动卸载](#3.4 页面接收消息Pro版本写法,实现批量注册,自动卸载)
-
-
- [3.4.1 封装 useWebSocketHandler.ts hooks](#3.4.1 封装 useWebSocketHandler.ts hooks)
- [3.4.2 页面使用](#3.4.2 页面使用)
-
-
前言
业务场景:前端项目与 C++ 后端通过 WebSocket 进行数据通信
数据格式采用 JSON,通过 apiName 字段区分接口。 需要实现单 WebSocket 连接,所有页面都能通过该连接向 C++ 发送消息,并获取指定接口的返回数据
请求参数示例:
javascript
const requestQuery = {
apiName: 'accountLogin',
data: {
username: 'admin',
password: '123456'
}
}
返回参数示例:
javascript
const requestQuery = {
apiName: 'accountLoginResponse',
data: {
code: '200',
msg: '登录成功'
}
}
一、WebSocket Hook 封装
常规 WebSocket 封装实现,熟悉 WebSocket 的读者可直接查看完整代码:
js
// /src/hooks/useWebsocket.ts
import { ref, watch, type Ref } from 'vue-demi'
import { type Fn, type MaybeRefOrGetter, isClient, isWorker, toRef, tryOnScopeDispose, useIntervalFn } from '@vueuse/shared'
import { useEventListener } from '@vueuse/core'
import { isNullAndUnDef } from '@/utils/is'
export type WebSocketStatus = 'OPEN' | 'CONNECTING' | 'CLOSED'
const DEFAULT_PING_MESSAGE = 'ping'
/**
*
* @see https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
*/
export interface UseWebSocketOptions {
onConnected?: (ws: WebSocket) => void
onDisconnected?: (ws: WebSocket, event: CloseEvent) => void
onError?: (ws: WebSocket, event: Event) => void
onMessage?: (ws: WebSocket, event: MessageEvent) => void
/**
* Send heartbeat for every x milliseconds passed
* 每隔 x ms 发送一次心跳
*
* @default false
*/
heartbeat?:
| boolean
| {
/**
* The message to send
* 发送的消息
*
* @default 'ping'
*/
message?: string | ArrayBuffer | Blob
/**
* Interval, in milliseconds
* 每隔多少毫秒发送一次心跳
*
* @default 1000
*/
interval?: number
/**
* Heartbeat response timeout, in milliseconds
* 心跳响应超时时间,单位毫秒
*
* @default 1000
*/
pongTimeout?: number
}
/**
* Enabled auto reconnect
* 是否开启自动重连
*
* @default false
*/
autoReconnect?:
| boolean
| {
/**
* Maximum retry times.
* Or you can pass predicate function (which returns true if you want to retry).
* 最大重试次数
* 或者传入一个断言函数(布尔表达式),当函数返回 true 时重连
*
* @default -1
*/
retries?: number | (() => boolean)
/**
* Delay for reconnect, in milliseconds
* 重连延迟,单位毫秒
*
* @default 1000
*/
delay?: number
/**
* On maximum retry times reached
* 达到最大重试次数时触发
*/
onFailed?: Fn
}
/**
* Automatically open a connection
* 是否自动打开连接
*
* @default true
*/
immediate?: boolean
/**
* Automatically close a connection
* 是否自动关闭连接
*
* @default true
*/
autoClose?: true
/**
* List of one or more sub-protocol string
* 一个或多个子协议字符串
*
* @default []
*/
protocols?: string[]
}
export interface UseWebSocketReturn<T> {
/**
* 通过 websocket 接收到的最新数据的引用,
* 可以监听它以响应传入的消息
*/
data: Ref<T | null>
/**
* 当前 websocket 状态可能的值:
* 'OPEN', 'CONNECTING', 'CLOSED'
*/
status: Ref<WebSocketStatus>
/**
* 关闭 websocket 连接
*/
close: WebSocket['close']
/**
* 重新打开 websocket 连接。
* 如果当前连接处于活动状态,则在打开新连接之前将其关闭。
*/
open: Fn
/** Sends data through the websocket connection.
* 通过 websocket 连接发送数据。
*
* @param data
* @param useBuffer 当 socket 连接未打开时,将数据存储到缓冲区并在连接时发送。默认为 true。
*/
send: (data: string | ArrayBuffer | Blob, useBuffer?: boolean) => boolean
/**
* WebSocket 实例的引用。
*/
ws: Ref<WebSocket | undefined>
}
/**
* 处理嵌套的选项
* @param options
*/
function resolveNestedOptions<T>(options: T | true): T {
if (options === true) {
return {} as T
}
return options
}
/**
* 响应式的 WebSocket 客户端。
* @useage
* @see hooks/docs/useWebsocket.md || https://vueuse.org/useWebSocket
* @param url
* @param options
*/
export function useWebSocket<Data = any>(
url: MaybeRefOrGetter<string | URL | undefined>,
options: UseWebSocketOptions = {}
): UseWebSocketReturn<Data> {
const { onConnected, onDisconnected, onError, onMessage, immediate = true, autoClose = true, protocols = [] } = options
const data: Ref<Data | null> = ref(null)
const status = ref<WebSocketStatus>('CLOSED')
const wsRef = ref<WebSocket | undefined>()
const urlRef = toRef(url)
// 心跳暂停
let heartbeatPause: Fn | undefined
// 心跳恢复
let heartbeatResume: Fn | undefined
// 连接是否已完全关闭
let explicitlyClosed = false
// 重连次数
let retried = 0
// 缓冲 data
let bufferedData: (string | ArrayBuffer | Blob)[] = []
// ping pong timeout 时长
let pongTimeoutWait: ReturnType<typeof setTimeout> | undefined
/**
* 发送缓冲数据
*/
const _sendBuffer = () => {
if (bufferedData.length && wsRef.value && status.value === 'OPEN') {
for (const buffer of bufferedData) {
wsRef.value.send(buffer)
}
// 清空缓冲
bufferedData = []
}
}
/**
* 重置心跳
*/
const resetHeartbeat = () => {
clearTimeout(pongTimeoutWait)
pongTimeoutWait = undefined
}
// Status code 1000 -> Normal closure https://developer.mozilla.org/en-US/docs/Web/API/CloseEvent/code
/**
* 正常关闭
* @param code
* @param reason
*/
const close: WebSocket['close'] = (code = 1000, reason) => {
if (!isClient || !wsRef.value) return
explicitlyClosed = true
resetHeartbeat()
heartbeatPause?.()
wsRef.value?.close(code, reason)
}
/**
* 发送数据
* @param data
* @param useBuffer
*/
const send = (data: string | ArrayBuffer | Blob, useBuffer = true) => {
if (!wsRef.value || status.value !== 'OPEN') {
useBuffer && bufferedData.push(data)
return false
}
// 先把断开连接时未发送的缓存数据先发送出去
_sendBuffer()
// 再发送当前数据
wsRef.value.send(data)
return true
}
/**
* 初始化 websocket
*/
const _init = () => {
// 正常关闭时不重连
if (explicitlyClosed || typeof urlRef.value === 'undefined') return
const ws = new WebSocket(urlRef.value, protocols)
wsRef.value = ws
status.value = 'CONNECTING'
/**
* 连接成功
*/
ws.onopen = () => {
status.value = 'OPEN'
onConnected?.(ws)
heartbeatResume?.()
// 将缓存的数据发送出去
_sendBuffer()
}
/**
* 连接断开
* @param ev
*/
ws.onclose = ev => {
status.value = 'CLOSED'
wsRef.value = undefined
onDisconnected?.(ws, ev)
// 不是正常关闭时,重连
if (!explicitlyClosed && options.autoReconnect) {
const { retries = -1, delay = 1000, onFailed } = resolveNestedOptions(options.autoReconnect)
retried += 1
if (typeof retries === 'number' && (retries < 0 || retried < retries)) {
setTimeout(_init, delay)
} else if (typeof retries === 'function' && retries()) {
setTimeout(_init, delay)
} else {
onFailed?.()
}
}
}
/**
* 连接错误
* @param e
*/
ws.onerror = e => {
onError?.(ws!, e)
}
/**
* 接收消息
* @param e
*/
ws.onmessage = (e: MessageEvent) => {
if (options.heartbeat) {
// 重置心跳计时
// 以保持心跳的频率和稳定性
resetHeartbeat()
const { message = DEFAULT_PING_MESSAGE } = resolveNestedOptions(options.heartbeat)
// 服务器发送的是心跳消息,return
// 避免将心跳消息当作普通消息处理
if (e.data === message) {
return
}
}
data.value = e.data
onMessage?.(ws!, e)
}
}
// 心跳
if (options.heartbeat) {
const { message = DEFAULT_PING_MESSAGE, interval = 1000, pongTimeout = 1000 } = resolveNestedOptions(options.heartbeat)
const { pause, resume } = useIntervalFn(
() => {
// 发送心跳
send(message, false)
if (!isNullAndUnDef(pongTimeoutWait)) return
// 设置心跳超时
pongTimeoutWait = setTimeout(() => {
// 超时后关闭连接
// auto-reconnect will be trigger with ws.onclose()
close()
explicitlyClosed = false
}, pongTimeout)
},
interval,
{ immediate: false }
)
heartbeatPause = pause
heartbeatResume = resume
}
if (autoClose) {
if (isClient) {
useEventListener('beforeunload', () => close())
}
tryOnScopeDispose(() => close())
}
/**
* 开启连接
*/
const open = () => {
if (!isClient && !isWorker) return
close()
explicitlyClosed = false
retried = 0
_init()
}
if (immediate) {
watch(urlRef, open, { immediate: true })
}
return {
data,
status,
close,
send,
open,
ws: wsRef
}
}
二、消息分发方法实现
javascript
// /src/utils/wsDispatcher.ts
// 定义处理函数类型
type WsHandler = (data: any) => void
// 存储处理函数的映射
const handlers = new Map<string, WsHandler>()
/**
* 注册响应函数
* @param apiName 接口名称
* @param handler 处理函数
* @returns 取消注册函数
*/
export function registerHandler(apiName: string, handler: WsHandler) {
handlers.set(apiName, handler)
return () => handlers.delete(apiName)
}
/**
* 处理WebSocket消息
* @param message 消息内容
* @returns void
*/
export function dispatchMessage(message: string) {
try {
const { apiName, data } = JSON.parse(message)
const handler = handlers.get(apiName)
if (handler) {
handler(data)
} else {
console.warn(`未找到处理函数:${apiName}`)
}
} catch (e) {
console.error('WebSocket消息解析失败', e)
}
}
三、实现通过 websocket 推送、接收消息
3.1 App.vue 中创建 websocket 连接、断开链接
在 App.vue 中创建、销毁 websocket,通过 dispatchMessage 将消息分发到每个页面
javascript
<template>
<el-config-provider :locale="locale">
<router-view />
</el-config-provider>
</template>
<script lang="ts" setup>
import { onBeforeUnmount } from 'vue'
import { useWebSocket } from '@/hooks/web/useWebsocket'
import { dispatchMessage } from '@/utils/wsDispatcher'
defineOptions({
name: 'App'
})
// 初始化WebSocket
const { close, send } = useWebSocket('ws://172.16.16.129:12345/', {
onConnected: ws => {
console.log('WebSocket connected', ws)
},
onDisconnected: (ws, event) => {
console.log('WebSocket disconnected', event)
},
onError: (ws, event) => {
console.error('WebSocket error', event)
},
onMessage: (ws, event) => {
dispatchMessage(event.data) // 使用分发中心处理消息
},
// 自动重连配置
autoReconnect: {
retries: 3,
delay: 1000
},
heartbeat: {
message: 'ping',
interval: 30000,
pongTimeout: 10000
}
})
/**
* 发送请求
* @param apiName C++接口名
* @param data C++接口参数
* @return void
*/
const sendMessage = (apiName: string, data: any) => {
const requestBody = {
apiName,
data
}
send(JSON.stringify(requestBody))
}
// 页面卸载时关闭WebSocket连接
onBeforeUnmount(() => {
close()
})
</script>
3.2 页面推送消息
- 按照 3.1 所写,已经可以有一个 sendMessage 方法去推送消息,但是这个方法怎么被其他的 .vue 文件所使用呢?
- 首先想到的是 vue3 的 Provide 和 Inject ,但是实际写下来每个页面里面冗余代码过多,不采用
- 所以,使用 emitter 帮我实现 类似 vue 的 eventBus 的功能
3.2.1 安装 emitter
bash
npm i emitter
3.2.2 App.vue 文件中暴露方法
javascript
<script lang="ts" setup>
// ... App.vue 文件中的其他代码,如 import { useWebSocket } from '@/hooks/web/useWebsocket'
import { emitter } from '@/utils/eventBus'
// ... App.vue 文件中的其他代码,如 const { close, send } = useWebSocket('ws://172.16.16.129:12345/', {}
/**
* 发送请求
* @param apiName C++接口名
* @param data C++接口参数
* @return void
*/
const sendMessage = (apiName: string, data: any) => {
const requestBody = {
apiName,
data
}
send(JSON.stringify(requestBody))
}
/**
* 发送请求
* @param apiName C++接口名
* @param data C++接口参数
* @return void
*/
// @ts-ignore
emitter.on('sendMessage', (payload: any) => {
sendMessage(payload.apiName, payload.data)
})
</script>
3.2.3 wsDispatcher.ts 中接收
javascript
// /src/utils/wsDispatcher.ts
import { emitter } from '@/utils/eventBus'
// ... wsDispatcher.ts 的其他的代码
/**
* 请求Qt端接口
* @param apiName 接口名称
* @param data 请求数据
* @returns void
*/
export function sendMessageToQt<T = any>(apiName: string, data: T = {} as T) {
emitter.emit('sendMessage', {
apiName,
data
})
}
// ... wsDispatcher.ts 的其他的代码
3.2.4 .vue 文件中使用
至此,就可以在 任意的 .vue 文件中去通过 sendMessageToQt 方法,在 websocket 中推送消息
javascript
<script setup lang="ts">
import { onMounted} from 'vue'
import { sendMessageToQt } from '@/utils/wsDispatcher'
/**
* 获取列表
* @description querySingleDeviceUsageRecord-查询单个设备借用及归还记录
* @returns void
*/
const getList = () => {
sendMessageToQt('querySingleDeviceUsageRecord', showQuery.value)
}
onMounted(() => {
getList()
})
</script>
3.3 页面接收消息(通过注册 handler 实现)
- 现在我们已经可以在不同的 .vue 文件中,注册时间处理器,然后拿到分发过来的对应接口的返回值
- 但是目前这样,我们当前页如果有 5个请求,我们就需要 注册5个Handler,销毁5个Handler,并不友好
- 所以不推荐这种写法,后续有封装
javascript
<script lang="ts" setup>
import { onUnmounted} from 'vue'
import { registerHandler} from '@/utils/wsDispatcher'
// 处理接口名:apiName1 对应的返回值
const handleTaskOverviewInfo = (res: any) => {
// TODO: 拿到 桌面端(C++) 传递的 res,进行后续处理,例如 vue 页面数据渲染
}
// 注册事件处理器
const unregisterApiName1Response = registerHandler('apiName1', handleTaskOverviewInfo)
onUnmounted(() => {
// 组件卸载时自动取消注册
unregisterTaskOverviewInfo()
})
</script>
3.4 页面接收消息Pro版本写法,实现批量注册,自动卸载
3.4.1 封装 useWebSocketHandler.ts hooks
javascript
import { onUnmounted } from 'vue'
import { registerHandler } from '@/utils/wsDispatcher'
/**
* 注册事件处理函数
* @param eventName 事件名称
* @param handler 事件处理函数
*/
export const useWebSocketHandler = () => {
// 存储事件名称和处理函数的映射关系
const eventNames: Record<string, Function | undefined> = {}
/**
* 注册事件处理函数
* @param arr 事件名称和处理函数的数组
* @example
* registerHandlers([
* { eventName: 'getLoginRecords', handler: handlerGetLoginRecordsResponse },
* { eventName: 'accountLoginResponse', handler: handleAccountLoginResponse },
* ])
*/
const registerHandlers = (arr: Array<{ eventName: string; handler: (...args: any[]) => void }>) => {
arr.forEach(({ eventName, handler }) => {
console.log(`注册--${eventName}`)
const handlerName = registerHandler(eventName, handler)
eventNames[eventName] = handlerName
})
}
/**
* 注销所有事件处理函数
*/
const unregisterAllHandlers = () => {
Object.keys(eventNames).forEach(key => {
const handler = eventNames[key]
if (typeof handler === 'function') {
console.log(`注销--${key}`)
handler()
}
})
}
onUnmounted(() => {
// 组件卸载时注销所有事件处理函数
unregisterAllHandlers()
})
return {
registerHandlers
}
}
3.4.2 页面使用
javascript
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { sendMessageToQt } from '@/utils/wsDispatcher'
import { useWebSocketHandler } from '@/hooks/web/useWebSocketHandler'
defineOptions({
name: 'AdminMangeDevice'
})
/**
* 请求服务器数据
* @param pageSize 每页显示条数
* @returns void
*/
function getList() {
sendMessageToQt('apiName1', {
offset: 1,
counts: 10
})
}
/**
* 处理响应数据
*/
function handleApiName1Response(res: any) {
// 处理响应数据
console.log('apiName1 response:', res)
}
/**
* 处理响应数据
*/
function handleApiName2Response(res: any) {
// 处理响应数据
console.log('apiName2 response:', res)
}
const { registerHandlers } = useWebSocketHandler()
/**
* 注册消息处理器
*/
registerHandlers([
{
// 设备管理页-获取所有设备的状态
eventName: 'apiName1',
handler: handleApiName1Response
},
{
// 设备管理页-标记设备不可用/恢复
eventName: 'apiName2',
handler: handleApiName2Response
}
])
onMounted(() => {
// 获取列表数据
getList()
})
</script>