
一:web通用版本WebSocket封装
整体结构
一个 class ,内部维护一个原生 WebSocket 实例,在它外面包了一层:心跳、重连、消息解析、状态管理。多个实例之间互不干扰。
构造参数一览
const ws = new WebSocketClient({
url: 'wss://example.com/ws', // 必填
heartbeatMsg: { cmd: 'ping' }, // 心跳内容,默认 'ping'
heartbeatInterval: 20000, // 心跳间隔 ms,0=禁用
reconnectMax: 8, // 最大重连次数,0=不重连
reconnectDelay: 10000, // 重连基础延迟 ms
reconnectStrategy: 'exponential', // 'fixed' | 'exponential'
closeCode: 1000, // 主动关闭状态码
closeReason: '主动关闭', // 主动关闭原因
onOpen: fn, // 连接成功回调
onMessage: fn, // 收到消息回调(已解析)
onClose: fn, // 关闭回调 (code, reason)
onError: fn, // 错误回调
messageParser: fn, // 自定义消息解析
})
公开方法
| 方法 | 作用 |
|---|---|
connect() |
建立连接。已连接或正在连接时自动跳过 |
disconnect(code?, reason?) |
主动关闭,不再触发重连 |
send(data) |
发送消息。Object 自动 JSON.stringify |
getReadyState() |
返回当前状态值 0~3 |
setCallbacks({ onOpen, onMessage, ... }) |
运行时更新回调 |
destroy() |
销毁实例,清理所有定时器和引用 |
核心机制解读
1. 状态机
自己维护 _readyState,和原生 WebSocket.readyState 的 0~3 一致:
0 CONNECTING → 1 OPEN → 2 CLOSING → 3 CLOSED
connect() 只有在 CLOSED 状态才真正创建连接,防止重复调用。
2. 心跳
- 用 递归 setTimeout(不是 setInterval),避免回调堆积。
- 只发送不主动检测超时------服务端返回
ping/pong/{ ping: true }/{ pong: true }会被自动过滤,不上报业务层。 - 连接关闭或断开时调用
_stopHeartbeat()清理定时器。
3. 重连
- 只有非手动关闭才会触发重连(
_manualClose标志位控制)。 - 两种策略:
- fixed --- 固定延迟
reconnectDelay - exponential --- 指数退避
delay * 2^(n-1),上限 30s
- fixed --- 固定延迟
- 有
_reconnecting防重复触发锁,不会在onclose+onerror双重回调下发起两次重连。 - 达到
reconnectMax后彻底放弃,打印警告。
4. _bindEvents + 防泄漏检查
每个事件回调第一行都判断 this._ws !== ws:
if (this._ws !== ws) return;
场景:快速重连时,旧 WebSocket 实例的回调可能延迟触发。如果 _ws 已经指向了新实例,旧回调直接丢弃,避免状态错乱。
5. 消息解析
_parseMessage(data)
先试 JSON.parse,失败就原样返回字符串。如果有自定义 messageParser,则完全交给你处理。
6. 异常兜底
new WebSocket(url)抛异常 → catch 后走错误回调 + 触发重连ws.send()抛异常 → catch 打印日志,不崩onerror不主动触发重连(等onclose来处理),但保留了注释说明极端情况
典型使用流程
// 1. 创建实例
const ws = new WebSocketClient({
url: 'wss://api.xxx.com/ws',
heartbeatMsg: { cmd: 'ping' },
heartbeatInterval: 15000,
onOpen: () => {
// 连接成功后可立即发订阅
ws.send({ type: 'subscribe', channel: 'btc_usdt' })
},
onMessage: (data) => {
// data 已经是解析好的对象
updateOrderBook(data)
},
onClose: (code, reason) => {
showDisconnectToast()
},
onError: (err) => {
reportError(err)
},
})
// 2. 连接
ws.connect()
// 3. 发消息(随时)
ws.send({ type: 'unsubscribe', channel: 'btc_usdt' })
// 4. 更新回调(运行时换函数)
ws.setCallbacks({
onMessage: (data) => newHandler(data),
})
// 5. 主动断开
ws.disconnect()
// 6. 彻底销毁(释放引用,防内存泄漏)
ws.destroy()
和 uni-app 版的差异点
| 项 | uni-app | Web 版 |
|---|---|---|
| 创建连接 | uni.connectSocket({ url }) |
new WebSocket(url) |
| 事件绑定 | .onOpen(cb) 方法 |
ws.onopen = cb 赋值 |
| 发送消息 | task.send({ data, fail }) |
ws.send(data) + try/catch |
| URL 非法 | fail 回调 | 构造函数抛异常,catch 后触发重连 |
| 防泄漏 | 无(uni-app 机制不同) | this._ws !== ws 检查 |
API 和使用方式保持一致,直接替换文件路径就能切过去。
代码
javascript
/**
* WebSocket 统一封装(Web 端标准版,基于原生 WebSocket API)
* 20260706 Jackie → Web 适配版 --- 优化版
*
* 功能支持:
* - 心跳检测(Ping/Pong)
* - 断线自动重连(可配置重连次数、间隔、策略)
* - 连接超时兜底
* - 多连接实例,互不干扰(通过 WebSocket 实例隔离)
* - 事件回调(onOpen / onMessage / onClose / onError)
* - 浏览器原生 WebSocket,无框架依赖
*
* 使用示例:
*
* import WebSocketClient from '@/utils/websocket.js'
*
* const ws = new WebSocketClient({
* url: 'wss://example.com/ws',
* heartbeatMsg: { cmd: 'ping' }, // 心跳消息内容
* heartbeatInterval: 20000, // 心跳间隔 ms,0 禁用
* heartbeatImmediate: true, // 连接成功后立即发送首次心跳
* connectTimeout: 15000, // 连接超时 ms,超时自动触发重连
* reconnectMax: 8, // 最大重连次数,0 表示不重连
* reconnectDelay: 10000, // 重连基础延迟 ms
* reconnectStrategy: 'exponential', // 重连策略: 'fixed' | 'exponential'
* closeCode: 1000, // 主动关闭时的状态码
* closeReason: '主动关闭', // 主动关闭时的原因
* onOpen: () => console.log('已连接'),
* onMessage: (data) => console.log('收到消息', data),
* onClose: (code, reason) => console.log('已断开'),
* onError: (err) => console.error('错误', err),
* messageParser: (raw) => JSON.parse(raw), // 自定义消息解析函数
* })
*
* ws.connect()
* ws.send({ type: 'subscribe', channel: 'btc_usdt' })
* ws.disconnect()
*/
// 连接状态常量(与 WebSocket.readyState 保持一致)
const CONNECTING = 0;
const OPEN = 1;
const CLOSING = 2;
const CLOSED = 3;
export class WebSocketClient {
/**
* @param {Object} options
* @param {string} options.url - WebSocket 地址
* @param {string|Object} [options.heartbeatMsg='ping'] - 心跳消息内容
* @param {number} [options.heartbeatInterval=20000] - 心跳间隔 ms,0 表示禁用
* @param {boolean} [options.heartbeatImmediate=false] - 连接成功后立即发送首次心跳
* @param {number} [options.connectTimeout=0] - 连接超时 ms,0 禁用
* @param {number} [options.reconnectMax=8] - 最大重连次数,0 表示不重连
* @param {number} [options.reconnectDelay=10000] - 重连基础延迟 ms
* @param {'fixed'|'exponential'} [options.reconnectStrategy='exponential'] - 重连策略
* @param {number} [options.closeCode=1000] - 主动关闭时的状态码
* @param {string} [options.closeReason='主动关闭'] - 主动关闭时的原因
* @param {Function} [options.onOpen] - 连接成功回调
* @param {Function} [options.onMessage] - 收到消息回调,参数为解析后的 data
* @param {Function} [options.onClose] - 连接关闭回调,参数 (code, reason)
* @param {Function} [options.onError] - 连接错误回调,参数为 ErrorEvent
* @param {Function} [options.messageParser] - 自定义消息解析函数,接收原始数据字符串,返回解析后的值
*/
constructor(options = {}) {
const {
url,
heartbeatMsg = "ping",
heartbeatInterval = 20000,
heartbeatImmediate = false,
connectTimeout = 0,
reconnectMax = 8,
reconnectDelay = 10000,
reconnectStrategy = "exponential",
closeCode = 1000,
closeReason = "主动关闭",
onOpen,
onMessage,
onClose,
onError,
messageParser,
} = options;
if (!url) throw new Error("[WebSocketClient] url 为必填项");
// 配置项
this._url = url;
this._heartbeatMsg = heartbeatMsg;
this._heartbeatInterval = heartbeatInterval;
this._heartbeatImmediate = heartbeatImmediate;
this._connectTimeout = connectTimeout;
this._reconnectMax = reconnectMax;
this._reconnectDelay = reconnectDelay;
this._reconnectStrategy = reconnectStrategy;
this._closeCode = closeCode;
this._closeReason = closeReason;
this._onOpen = onOpen || null;
this._onMessage = onMessage || null;
this._onClose = onClose || null;
this._onError = onError || null;
this._messageParser = messageParser || null;
// 内部状态
this._ws = null; // 原生 WebSocket 实例
this._readyState = CLOSED; // 自行维护连接状态
this._reconnectCount = 0; // 当前已重连次数
this._heartbeatTimer = null; // 心跳定时器句柄
this._reconnectTimer = null; // 重连延迟定时器句柄
this._connectTimer = null; // 连接超时定时器句柄
this._manualClose = false; // 是否由用户主动关闭(决定是否触发重连)
this._reconnecting = false; // 防重复重连标识
}
// ==================== 公开方法 ====================
/**
* 建立 WebSocket 连接
* 如果已连接或正在连接中,不会重复创建
*/
connect() {
if (this._readyState === CONNECTING || this._readyState === OPEN) {
console.warn("[WebSocketClient] 已连接或正在连接中,跳过重复 connect");
return;
}
// 清理残留的旧连接
if (this._ws) {
try { this._ws.close(); } catch (_) { /* ignore */ }
this._ws = null;
}
// 重置标志位
this._manualClose = false;
this._reconnecting = false;
this._readyState = CONNECTING;
// 启动连接超时定时器
this._startConnectTimer();
// 创建原生 WebSocket
let ws;
try {
ws = new WebSocket(this._url);
} catch (err) {
console.error("[WebSocketClient] 创建 WebSocket 失败:", err);
this._readyState = CLOSED;
this._clearConnectTimer();
this._onError && this._onError(err);
if (!this._manualClose) this._tryReconnect();
return;
}
this._ws = ws;
this._bindEvents(ws);
}
/**
* 绑定原生 WebSocket 事件
* @param {WebSocket} ws
*/
_bindEvents(ws) {
ws.onopen = () => {
if (this._ws !== ws) return;
this._clearConnectTimer();
this._readyState = OPEN;
this._reconnectCount = 0;
this._reconnecting = false;
this._startHeartbeat();
console.log(`[WebSocketClient] 连接成功: ${this._url}`);
this._onOpen && this._onOpen();
};
ws.onmessage = (event) => {
if (this._ws !== ws) return;
const parsed = this._parseMessage(event.data);
// 过滤心跳响应
const isStringHeartbeat = parsed === "ping" || parsed === "pong";
const isObjectHeartbeat =
parsed &&
typeof parsed === "object" &&
(parsed.ping === true || parsed.pong === true);
if (isStringHeartbeat || isObjectHeartbeat) {
return;
}
this._onMessage && this._onMessage(parsed);
};
ws.onclose = (event) => {
if (this._ws !== ws) return;
this._clearConnectTimer();
this._readyState = CLOSED;
this._reconnecting = false;
this._stopHeartbeat();
this._onClose && this._onClose(event.code, event.reason);
if (!this._manualClose) {
this._tryReconnect();
}
};
ws.onerror = (event) => {
if (this._ws !== ws) return;
this._onError && this._onError(event);
// 不在这里触发重连,等待随后的 onclose 处理
};
}
/**
* 主动断开连接(不再自动重连)
* @param {number} [code] 自定义关闭状态码,默认使用构造时传入的 closeCode
* @param {string} [reason] 自定义关闭原因,默认使用构造时传入的 closeReason
*/
disconnect(code, reason) {
this._manualClose = true;
this._readyState = CLOSING;
this._reconnecting = false;
this._stopHeartbeat();
this._clearReconnectTimer();
this._clearConnectTimer();
this._reconnectCount = 0;
if (this._ws) {
this._ws.close(code || this._closeCode, reason || this._closeReason);
this._ws = null;
}
this._readyState = CLOSED;
}
/**
* 发送消息,自动处理 JSON 序列化
* @param {string|Object} data 要发送的数据
*/
send(data) {
if (!this._ws || this._readyState !== OPEN) {
console.warn("[WebSocketClient] 连接未就绪,无法发送消息");
return;
}
try {
const payload = typeof data === "string" ? data : JSON.stringify(data);
this._ws.send(payload);
} catch (err) {
console.error("[WebSocketClient] 发送消息失败:", err);
}
}
/**
* 获取当前连接状态
* @returns {number} 状态常量: CONNECTING(0) | OPEN(1) | CLOSING(2) | CLOSED(3)
*/
getReadyState() {
return this._readyState;
}
/**
* 动态设置/更新回调函数
* @param {Object} callbacks
* @param {Function} [callbacks.onOpen]
* @param {Function} [callbacks.onMessage]
* @param {Function} [callbacks.onClose]
* @param {Function} [callbacks.onError]
*/
setCallbacks(callbacks = {}) {
if (callbacks.onOpen !== undefined) this._onOpen = callbacks.onOpen;
if (callbacks.onMessage !== undefined) this._onMessage = callbacks.onMessage;
if (callbacks.onClose !== undefined) this._onClose = callbacks.onClose;
if (callbacks.onError !== undefined) this._onError = callbacks.onError;
}
/**
* 销毁实例,彻底清理所有资源(定时器、连接、回调)
*/
destroy() {
this.disconnect();
this._onOpen = null;
this._onMessage = null;
this._onClose = null;
this._onError = null;
this._messageParser = null;
}
// ==================== 内部方法 ====================
/**
* 解析消息数据
* @param {string} data 原始消息字符串
* @returns {*} 解析后的数据
*/
_parseMessage(data) {
if (this._messageParser) {
return this._messageParser(data);
}
try {
return JSON.parse(data);
} catch {
return data;
}
}
/**
* 启动连接超时定时器
* 超过 connectTimeout 毫秒仍未收到 onopen,视为连接超时,触发重连
*/
_startConnectTimer() {
this._clearConnectTimer();
if (this._connectTimeout <= 0) return;
this._connectTimer = setTimeout(() => {
if (this._readyState !== CONNECTING) return;
console.warn("[WebSocketClient] 连接超时,准备重连");
this._readyState = CLOSED;
if (this._ws) {
try { this._ws.close(); } catch (_) { /* ignore */ }
this._ws = null;
}
if (!this._manualClose) {
this._tryReconnect();
}
}, this._connectTimeout);
}
/**
* 清除连接超时定时器
*/
_clearConnectTimer() {
if (this._connectTimer) {
clearTimeout(this._connectTimer);
this._connectTimer = null;
}
}
/**
* 启动心跳定时器(递归 setTimeout,避免回调堆积)
*/
_startHeartbeat() {
this._stopHeartbeat();
if (this._heartbeatInterval <= 0) return;
const run = () => {
if (this._readyState === OPEN && this._ws) {
let payload;
try {
payload = typeof this._heartbeatMsg === "string"
? this._heartbeatMsg
: JSON.stringify(this._heartbeatMsg);
} catch (err) {
console.error("[WebSocketClient] 心跳序列化失败:", err);
return;
}
try {
this._ws.send(payload);
} catch (err) {
console.error("[WebSocketClient] 心跳发送失败:", err);
}
}
if (this._readyState === OPEN) {
this._heartbeatTimer = setTimeout(run, this._heartbeatInterval);
}
};
if (this._heartbeatImmediate) {
run();
} else {
this._heartbeatTimer = setTimeout(run, this._heartbeatInterval);
}
}
/**
* 停止心跳定时器
*/
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearTimeout(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
/**
* 尝试重连(带防重复触发保护)
*/
_tryReconnect() {
if (this._reconnecting) return;
if (this._reconnectMax <= 0) return;
if (this._reconnectCount >= this._reconnectMax) {
console.warn("[WebSocketClient] 已达最大重连次数,停止重连");
return;
}
this._reconnecting = true;
this._reconnectCount++;
const delay = this._calcReconnectDelay();
console.log(
`[WebSocketClient] 将在 ${delay}ms 后第 ${this._reconnectCount}/${this._reconnectMax} 次重连...`
);
this._clearReconnectTimer();
this._reconnectTimer = setTimeout(() => {
this._reconnecting = false;
this.connect();
}, delay);
}
/**
* 计算重连延迟时间
* @returns {number} 延迟毫秒数
*/
_calcReconnectDelay() {
if (this._reconnectStrategy === "fixed") {
return this._reconnectDelay;
}
const delay = this._reconnectDelay * Math.pow(2, this._reconnectCount - 1);
return Math.min(delay, 30000);
}
/**
* 清除重连定时器
*/
_clearReconnectTimer() {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
}
export default WebSocketClient;
二:useWebSocket.js-Vue3版本
定位
这是一个 Vue 3 composable ,站在 WebSocketClient 之上再做一层封装,解决的痛点是:
页面/组件里只要传一个 url + 回调,连接管理全自动,不需要手动管 connect/disconnect。
整体架构
┌──────────────────────────────┐
│ 页面 / 组件 │
│ │
│ const { ws, isConnected, │
│ send, onReceive } │
│ = useWebSocket(url) │
└──────────┬───────────────────┘
│
▼
┌──────────────────────────────┐
│ useWebSocket (composable) │ ← 生命周期 + 消息队列
│ │
│ - onMounted → connect() │
│ - visibilitychange → 切 Tab │
│ - onUnmounted → disconnect()│
│ - messageQueue 暂存 │
└──────────┬───────────────────┘
│
▼
┌──────────────────────────────┐
│ WebSocketClient (class) │ ← 心跳 + 重连 + 消息收发
│ │
│ - new WebSocket(url) │
│ - 心跳 Ping/Pong │
│ - 指数退避重连 │
│ - 状态管理 │
└──────────────────────────────┘
两层各司其职,不重叠。
构造参数
useWebSocket(url, options)
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
url |
string | 必填 | WebSocket 地址 |
manual |
boolean | false |
true 时禁用自动连接,自己调 connect() |
onOpen |
Function | - | 连接成功回调 |
onMessage |
Function | - | 收到消息回调 (data) |
onClose |
Function | - | 关闭回调 (code, reason) |
onError |
Function | - | 错误回调 |
heartbeatMsg |
any | 'ping' |
透传 |
heartbeatInterval |
number | 30000 |
透传 |
reconnectMax |
number | 5 |
透传 |
reconnectDelay |
number | 3000 |
透传 |
reconnectStrategy |
string | 'exponential' |
透传 |
返回值
const { ws, isConnected, connect, disconnect, send, onReceive } = useWebSocket(url)
| 返回值 | 类型 | 说明 |
|---|---|---|
ws |
WebSocketClient | 底层实例,直接调 ws.send() 也行 |
isConnected |
Ref<boolean> |
响应式连接状态,模板里直接用 |
connect() |
Function | 手动连接 |
disconnect() |
Function | 手动断开 |
send(...messages) |
Function | 发消息,未连接时自动入队 |
onReceive(handler) |
Function | 动态注册消息回调 |
核心机制
1. 生命周期自动管理
非 manual 模式:
onMounted ──→ connect() // 组件挂载就连接
visibilitychange (hidden) ──→ disconnect() // 切 Tab 断开
visibilitychange (visible) ──→ connect() // 切回来重连
onUnmounted ──→ disconnect() // 组件卸载断开
manual: true 时这些全跳过,你全权控制。
2. 消息队列
function send(...messages) {
if (isConnected.value) {
doSend(messages) // 已连接 → 直接发
} else {
messageQueue.push(...messages) // 未连接 → 暂存
}
}
等 onOpen 触发时自动调用 flushQueue() 一次性发出:
connect() → onOpen → flushQueue() → doSend(queued messages)
典型场景:页面加载时立即发订阅消息,但 WebSocket 还没连上。消息不会丢,入队等到连接建立再发。
3. 双通道消息接收
// 方式一:构造时传
useWebSocket(url, {
onMessage: (data) => updateOrderBook(data),
})
// 方式二:动态注册(适合在 setup 后面条件式订阅)
const { onReceive } = useWebSocket(url)
onReceive((data) => updateUI(data))
两者可以同时用,互不覆盖。内部实现:
onMessage: (data) => {
onMessage?.(data) // 构造参数的回调
onReceiveHandler?.(data) // onReceive 注册的回调
}
4. 消息格式约定
框架假设消息是 { type, content } 结构。content 自动做一层 JSON.parse 展开:
// 服务端返回 JSON 字符串:
{ type: "trade", content: '{"price": 50000, "qty": 0.1}' }
// composable 自动解析后回调收到:
{ type: "trade", content: { price: 50000, qty: 0.1 } }
如果 content 不是合法 JSON,原样返回。
典型用法
基础 --- 连接即订阅
<script setup>
import { useWebSocket } from "@/composables/useWebSocket"
const { isConnected, send } = useWebSocket("wss://api.xxx.com/ws", {
heartbeatInterval: 20000,
onMessage: (data) => {
if (data.type === "trade") updateChart(data.content)
},
onOpen: () => {
send({ type: "subscribe", content: { channel: "btc_usdt" } })
},
})
</script>
<template>
<div :class="{ online: isConnected }">
{{ isConnected ? "已连接" : "未连接" }}
</div>
</template>
手动模式 --- 登录后才连
<script setup>
const ws = useWebSocket("wss://...", { manual: true })
function afterLogin() {
ws.connect()
ws.send({ type: "auth", content: token })
}
function logout() {
ws.disconnect()
}
</script>
动态订阅消息
<script setup>
const { onReceive } = useWebSocket(url, { onOpen: () => /* 订阅 */ })
// 按需切换处理函数
watch(currentTab, (tab) => {
onReceive((data) => {
if (data.type === tab) renderPanel(data.content)
})
})
</script>
与 uni-app 版的差异
| 项 | uni-app 版 | Web 版 |
|---|---|---|
| 导入 | @dcloudio/uni-app 的 onShow/onHide |
纯 Vue 3,无平台依赖 |
| 自动连接时机 | onShow(页面展示) |
onMounted(组件挂载) |
| 自动断开时机 | onHide(页面隐藏)+ onUnmounted |
visibilitychange(Tab 切换)+ onUnmounted |
| 底层 | @/utils/websocket uni-app 版 |
@/utils/websocket Web 版 |
| API | 完全一致 | 完全一致 |
用法、返回值、行为逻辑都没变,直接替换 import 路径就能切。
代码
javascript
/**
* WebSocket 组合式函数(Vue 3 Web 版)
* Jackie 2026-07-07 → Web 适配版 --- 优化版
*
* 封装了 WebSocket 连接的完整生命周期:
* - onMounted / 页面可见时自动连接
* - onUnmounted / 页面隐藏时自动断开
* - 支持心跳、自动重连(通过 WebSocketClient)
*
* 任何页面/组件只需传入 url + 回调即可使用,无需关心连接管理。
*
* @param {string} url - WebSocket 地址
* @param {Object} [options] - 透传给 WebSocketClient 的配置
* @param {boolean} [options.manual=false] - 是否手动管理连接
* @param {Function} [options.onOpen] - 连接成功回调
* @param {Function} [options.onMessage] - 收到消息回调 (data)
* @param {Function} [options.onClose] - 连接关闭回调 (code, reason)
* @param {Function} [options.onError] - 连接错误回调 (err)
* @param {*} [options.heartbeatMsg] - 心跳消息内容,默认 "ping"
* @param {number} [options.heartbeatInterval=30000] - 心跳间隔 ms,0 禁用
* @param {number} [options.reconnectMax=5] - 最大重连次数
* @param {number} [options.reconnectDelay=3000] - 重连基础延迟 ms
* @param {'fixed'|'exponential'} [options.reconnectStrategy='exponential']
*
* @returns {Object}
* @returns {WebSocketClient} ws - WebSocketClient 实例,用于 send()
* @returns {Ref<boolean>} isConnected - 连接状态
* @returns {Function} connect - 手动连接
* @returns {Function} disconnect - 手动断开
* @returns {Function} send - 发送消息快捷方法 (...messages)
* @returns {Function} onReceive - 注册消息接收回调,返回取消函数
*/
import { ref, onMounted, onUnmounted } from "vue";
import { WebSocketClient } from "@/utils/websocket";
/** 消息队列最大长度,防止断网时无限膨胀 */
const MAX_QUEUE_SIZE = 100;
export function useWebSocket(url, options = {}) {
const isConnected = ref(false);
const {
manual = false,
onOpen,
onMessage,
onClose,
onError,
...restOptions
} = options;
let disposed = false;
const ws = new WebSocketClient({
url,
...restOptions,
onOpen: () => {
isConnected.value = true;
onOpen?.();
flushQueue();
},
onMessage: (data) => {
// 安全解构:兼容非对象消息(纯字符串、数字等)
let type, content;
if (data && typeof data === "object") {
type = data.type;
content = data.content;
} else {
onMessage?.(data);
onReceiveHandlers.forEach((fn) => fn(data));
return;
}
// content 自动 JSON 解包
let parsedContent = content;
if (typeof content === "string") {
try {
parsedContent = JSON.parse(content);
} catch {
parsedContent = content;
}
}
const parsed = { type, content: parsedContent };
onMessage?.(parsed);
onReceiveHandlers.forEach((fn) => fn(parsed));
},
onClose: (code, reason) => {
isConnected.value = false;
onClose?.(code, reason);
},
onError,
});
/** 多订阅者消息回调列表 */
const onReceiveHandlers = [];
/**
* 注册消息接收回调(动态订阅,与构造参数的 onMessage 独立)
* @param {(data: any) => void} handler
* @returns {Function} 取消订阅函数
*/
function onReceive(handler) {
onReceiveHandlers.push(handler);
return () => {
const idx = onReceiveHandlers.indexOf(handler);
if (idx > -1) onReceiveHandlers.splice(idx, 1);
};
}
/** 消息队列:连接未就绪时暂存消息 */
const messageQueue = [];
function doSend(messages) {
messages.forEach(({ type, content }) => {
ws.send({
type,
content:
typeof content === "string" ? content : JSON.stringify(content),
});
});
}
function flushQueue() {
if (messageQueue.length > 0) {
const queue = messageQueue.splice(0);
doSend(queue);
}
}
function connect() {
if (disposed) return;
ws.connect();
}
function disconnect() {
ws.disconnect();
}
/**
* 发送一条或多条消息。
* 连接未就绪时自动入队,等 onOpen 后发送。
* 消息队列有上限(MAX_QUEUE_SIZE),超出时丢弃最早的消息。
* @param {...Object} messages - 每条消息为 { type, content }
*/
function send(...messages) {
if (isConnected.value) {
doSend(messages);
} else {
if (messageQueue.length + messages.length > MAX_QUEUE_SIZE) {
const overflow =
messageQueue.length + messages.length - MAX_QUEUE_SIZE;
console.warn(
`[useWebSocket] 消息队列已满,丢弃最旧的 ${overflow} 条消息`,
);
messageQueue.splice(0, overflow);
}
messageQueue.push(...messages);
}
}
/**
* 页面可见性变化处理
* 切到后台断开、回到前台重连(类似 App 的 onShow/onHide)
*/
function onVisibilityChange() {
if (disposed) return;
if (document.hidden) {
ws.disconnect();
} else {
ws.connect();
}
}
// ========== 生命周期 ==========
if (!manual) {
onMounted(() => {
ws.connect();
});
document.addEventListener("visibilitychange", onVisibilityChange);
}
onUnmounted(() => {
disposed = true;
document.removeEventListener("visibilitychange", onVisibilityChange);
ws.disconnect();
});
return { ws, isConnected, connect, disconnect, send, onReceive };
}
使用
javascript
import { onShow, onHide } from "@dcloudio/uni-app";
import { WebSocketClient } from "@/utils";
// WebSocket 测试
let connected = false;
const ws = new WebSocketClient({
url: "wss://echo.websocket.org",
heartbeatMsg: "ping",
heartbeatInterval: 10000,
reconnectMax: 3,
reconnectDelay: 2000,
onOpen: () => {
console.log("[WebSocket] 连接成功");
ws.send("Hello WebSocket!");
},
onMessage: (data) => {
console.log("[WebSocket] 收到消息:", data);
},
onClose: (code, reason) => {
console.log("[WebSocket] 连接关闭:", code, reason);
},
onError: (err) => {
console.error("[WebSocket] 错误:", err);
},
});
onShow(() => {
if (!connected) {
ws.connect();
connected = true;
}
console.log("打开");
});
onHide(() => {
ws.disconnect();
console.log("关闭");
connected = false;
});
三:WebSocket-uniapp封装版本
整体定位
这是一个基于 uni.connectSocket / SocketTask 的 WebSocket 封装类,目标是一套代码跑 H5 / 小程序 / App 三端。核心能力:心跳保活 + 断线自动重连 + 事件回调 + 多实例隔离。
构造参数
new WebSocketClient({
url, // 必填:wss://...
heartbeatMsg: 'ping', // 心跳内容,默认 'ping'
heartbeatInterval: 20000, // 心跳间隔 ms,0=禁用
reconnectMax: 8, // 最大重连次数,0=不重连
reconnectDelay: 10000, // 重连基础延迟 ms
reconnectStrategy: 'exponential', // fixed | exponential
closeCode: 1000, // 主动关闭状态码
closeReason: '主动关闭', // 主动关闭原因
onOpen, // 连接成功回调
onMessage, // 收到消息回调(已解析)
onClose, // 关闭回调 (code, reason)
onError, // 错误回调
messageParser, // 自定义消息解析函数
})
公开方法
| 方法 | 作用 |
|---|---|
connect() |
建立连接,已连接跳过 |
disconnect(code?, reason?) |
主动关闭,不再重连 |
send(data) |
发消息,自动 JSON.stringify |
getReadyState() |
返回当前状态 0~3 |
setCallbacks({...}) |
运行时更新回调 |
destroy() |
销毁,清所有资源 |
内部状态机
CONNECTING(0) → OPEN(1) → CLOSING(2) → CLOSED(3)
uni-app 的 SocketTask 不暴露 readyState,所以自己维护了一套:
const CONNECTING = 0;
const OPEN = 1;
const CLOSING = 2;
const CLOSED = 3;
核心机制
1. 心跳保活
用 递归 setTimeout 而非 setInterval,防止回调堆积:
connect() → onOpen → _startHeartbeat()
↓
setTimeout(run, heartbeatInterval)
↓
发送 heartbeatMsg → setTimeout(run, heartbeatInterval) // 递归
↓
断开 → _stopHeartbeat() // 清定时器
发送内容兼容字符串和对象(自动 JSON.stringify)。
2. 心跳响应自动过滤
收到消息后先判断是不是心跳响应:
// 字符串心跳
parsed === "ping" || parsed === "pong"
// 对象心跳
parsed.ping === true || parsed.pong === true
匹配的直接 return,不上报给业务层,也不打日志刷屏。
3. 断线自动重连
非手动关闭才触发,有 _reconnecting 防并发锁:
onClose / onError
↓
_tryReconnect()
↓
if (_reconnecting) return; // 防并发
if (_reconnectCount >= max) return; // 达上限放弃
↓
_reconnectCount++
_calcReconnectDelay()
↓
setTimeout → connect()
两种策略:
fixed--- 固定reconnectDelaymsexponential---delay * 2^(count-1),上限 30s
4. 消息解析
_parseMessage(data)
// 有自定义 messageParser → 用它
// 否则 try JSON.parse → 失败原样返回
5. 与原生 WebSocket 的关键区别
- new WebSocket(url) // 原生
+ uni.connectSocket({ url }) // uni-app:异步创建,回调通知
- ws.onopen = cb // 原生:属性赋值
+ task.onOpen(cb) // uni-app:方法注册
- ws.send(data) // 原生:直接发
+ task.send({ data, fail }) // uni-app:传配置对象
- ws.close(code, reason) // 原生
+ task.close({ code, reason }) // uni-app:传配置对象
uni-app 的 SocketTask 是异步的,connectSocket 返回后不等同于连接已建立,要等 onOpen 回调才算真正连上。
完整生命周期流程
new WebSocketClient(options)
↓
ws.connect()
↓
uni.connectSocket({ url }) → SocketTask
↓
task.onOpen → _readyState = OPEN → _startHeartbeat() → onOpen 回调
↓
(中间收发消息...)
↓
ws.disconnect() 或 网络断开 → onClose
↓ ↓
_manualClose = true _manualClose = false → _tryReconnect()
↓
task.close({ code, reason })
↓
_readyState = CLOSED
典型使用
import WebSocketClient from '@/utils/websocket'
const ws = new WebSocketClient({
url: 'wss://api.xxx.com/ws',
heartbeatMsg: { cmd: 'ping' },
heartbeatInterval: 15000,
reconnectMax: 5,
reconnectStrategy: 'exponential',
onOpen: () => {
console.log('连上了')
ws.send({ type: 'subscribe', channel: 'btc_usdt' })
},
onMessage: (data) => {
updateChart(data)
},
onClose: (code, reason) => {
showToast('连接断开')
},
onError: (err) => {
reportError(err)
},
})
ws.connect()
// 后续
ws.send({ type: 'ping' })
ws.setCallbacks({ onMessage: newHandler })
ws.disconnect()
ws.destroy()
对比原生和 uni-app 版
| 特性 | 原生 WebSocket | uni-app 版 |
|---|---|---|
| 创建 | new WebSocket(url) |
uni.connectSocket({ url }) |
| 事件 | ws.onopen = fn |
task.onOpen(fn) |
| 发送 | ws.send(data) |
task.send({ data, fail }) |
| 关闭 | ws.close(code, reason) |
task.close({ code, reason }) |
| readyState | 原生自带 | 自己维护 |
| 跨平台 | 仅 H5 | H5 / 小程序 / App |
| 心跳 / 重连 | 无,自己写 | 内置 |
uni-app 版的核心价值就是一套代码跑三端,同时内置了心跳和重连这些通用能力,业务方不用重复造轮子。
代码
javascript
/**
* WebSocket-uniapp 统一封装(基于 uni.connectSocket / SocketTask)
* Jackie 20260706 --- 优化版
*
* 支持功能:
* - 心跳检测(Ping/Pong)
* - 断线自动重连(可配置重连次数、间隔、策略)
* - 连接超时兜底
* - 多连接实例,互不干扰(通过 SocketTask 隔离)
* - 事件回调(onOpen / onMessage / onClose / onError)
* - 跨平台兼容(H5 / 小程序 / App)
*
* 使用示例:
*
* import WebSocketClient from '@/utils/websocket.js'
*
* const ws = new WebSocketClient({
* url: 'wss://example.com/ws',
* heartbeatMsg: { cmd: 'ping' }, // 心跳消息内容
* heartbeatInterval: 20000, // 心跳间隔 ms,0 禁用
* heartbeatImmediate: true, // 连接成功后立即发送首次心跳
* connectTimeout: 15000, // 连接超时 ms,超时自动触发重连
* reconnectMax: 8, // 最大重连次数,0 表示不重连
* reconnectDelay: 10000, // 重连基础延迟 ms
* reconnectStrategy: 'exponential', // 重连策略: 'fixed' | 'exponential'
* closeCode: 1000, // 主动关闭时的状态码
* closeReason: '主动关闭', // 主动关闭时的原因
* onOpen: () => console.log('已连接'),
* onMessage: (data) => console.log('收到消息', data),
* onClose: (code, reason) => console.log('已断开'),
* onError: (err) => console.error('错误', err),
* messageParser: (raw) => JSON.parse(raw), // 自定义消息解析函数
* })
*
* ws.connect()
* ws.send({ type: 'subscribe', channel: 'btc_usdt' })
* ws.disconnect()
*/
// 连接状态常量(自行维护,SocketTask 不暴露 readyState)
const CONNECTING = 0;
const OPEN = 1;
const CLOSING = 2;
const CLOSED = 3;
export class WebSocketClient {
/**
* @param {Object} options
* @param {string} options.url - WebSocket 地址
* @param {string|Object} [options.heartbeatMsg='ping'] - 心跳消息内容
* @param {number} [options.heartbeatInterval=20000] - 心跳间隔 ms,0 表示禁用
* @param {boolean} [options.heartbeatImmediate=false] - 连接成功后立即发送首次心跳
* @param {number} [options.connectTimeout=0] - 连接超时 ms,0 禁用
* @param {number} [options.reconnectMax=8] - 最大重连次数,0 表示不重连
* @param {number} [options.reconnectDelay=10000] - 重连基础延迟 ms
* @param {'fixed'|'exponential'} [options.reconnectStrategy='exponential'] - 重连策略
* @param {number} [options.closeCode=1000] - 主动关闭时的状态码
* @param {string} [options.closeReason='主动关闭'] - 主动关闭时的原因
* @param {Function} [options.onOpen] - 连接成功回调
* @param {Function} [options.onMessage] - 收到消息回调,参数为解析后的 data
* @param {Function} [options.onClose] - 连接关闭回调,参数 (code, reason)
* @param {Function} [options.onError] - 连接错误回调,参数为错误对象
* @param {Function} [options.messageParser] - 自定义消息解析函数,接收原始数据字符串,返回解析后的值
*/
constructor(options = {}) {
const {
url,
heartbeatMsg = "ping",
heartbeatInterval = 20000,
heartbeatImmediate = false,
connectTimeout = 0,
reconnectMax = 8,
reconnectDelay = 10000,
reconnectStrategy = "exponential",
closeCode = 1000,
closeReason = "主动关闭",
onOpen,
onMessage,
onClose,
onError,
messageParser,
} = options;
if (!url) throw new Error("[WebSocketClient] url 为必填项");
// 配置项
this._url = url;
this._heartbeatMsg = heartbeatMsg;
this._heartbeatInterval = heartbeatInterval;
this._heartbeatImmediate = heartbeatImmediate;
this._connectTimeout = connectTimeout;
this._reconnectMax = reconnectMax;
this._reconnectDelay = reconnectDelay;
this._reconnectStrategy = reconnectStrategy;
this._closeCode = closeCode;
this._closeReason = closeReason;
this._onOpen = onOpen || null;
this._onMessage = onMessage || null;
this._onClose = onClose || null;
this._onError = onError || null;
this._messageParser = messageParser || null;
// 内部状态
this._socketTask = null; // uni.connectSocket 返回的 SocketTask 实例
this._readyState = CLOSED; // 自行维护连接状态
this._reconnectCount = 0; // 当前已重连次数
this._heartbeatTimer = null; // 心跳定时器句柄
this._reconnectTimer = null; // 重连延迟定时器句柄
this._connectTimer = null; // 连接超时定时器句柄
this._manualClose = false; // 是否由用户主动关闭(决定是否触发重连)
this._reconnecting = false; // 防重复重连标识
}
// ==================== 公开方法 ====================
/**
* 建立 WebSocket 连接
* 如果已连接或正在连接中,不会重复创建
*/
connect() {
// 如果状态已经是连接中或已连接,跳过重复操作
if (this._readyState === CONNECTING || this._readyState === OPEN) {
console.warn("[WebSocketClient] 已连接或正在连接中,跳过重复 connect");
return;
}
// 清理残留的旧连接引用
if (this._socketTask) {
try { this._socketTask.close(); } catch (e) { /* ignore */ }
this._socketTask = null;
}
// 重置标志位
this._manualClose = false;
this._reconnecting = false;
this._readyState = CONNECTING;
// 启动连接超时定时器
this._startConnectTimer();
// 创建 SocketTask
this._socketTask = uni.connectSocket({
url: this._url,
success: () => {
// connectSocket 创建成功,等待 onOpen 回调
},
fail: (err) => {
console.error("[WebSocketClient] 创建连接失败:", err);
this._readyState = CLOSED;
this._clearConnectTimer();
this._socketTask = null; // ← 修复:创建失败清理 task 引用
this._onError && this._onError(err);
if (!this._manualClose) {
this._tryReconnect();
}
},
});
// ========== 绑定 SocketTask 事件 ==========
// 连接打开
this._socketTask.onOpen(() => {
this._clearConnectTimer();
this._readyState = OPEN;
this._reconnectCount = 0;
this._reconnecting = false;
this._startHeartbeat();
console.log(`[WebSocketClient] 连接成功: ${this._url}`);
this._onOpen && this._onOpen();
});
// 接收消息
this._socketTask.onMessage((res) => {
const parsed = this._parseMessage(res.data);
// 过滤心跳响应
const isStringHeartbeat = parsed === "ping" || parsed === "pong";
const isObjectHeartbeat =
parsed &&
typeof parsed === "object" &&
(parsed.ping === true || parsed.pong === true);
if (isStringHeartbeat || isObjectHeartbeat) {
return;
}
this._onMessage && this._onMessage(parsed);
});
// 连接关闭
this._socketTask.onClose((res) => {
this._clearConnectTimer();
this._readyState = CLOSED;
this._reconnecting = false;
this._stopHeartbeat();
this._onClose && this._onClose(res.code, res.reason);
if (!this._manualClose) {
this._tryReconnect();
}
});
// 连接错误
this._socketTask.onError((res) => {
this._onError && this._onError(res);
// 不在这里触发重连,等待随后的 onClose 处理
// 这样可以避免 onError + onClose 两路并发重连
});
}
/**
* 主动断开连接(不再自动重连)
* @param {number} [code] 自定义关闭状态码,默认使用构造时传入的 closeCode
* @param {string} [reason] 自定义关闭原因,默认使用构造时传入的 closeReason
*/
disconnect(code, reason) {
this._manualClose = true;
this._readyState = CLOSING;
this._reconnecting = false;
this._stopHeartbeat();
this._clearReconnectTimer();
this._clearConnectTimer();
this._reconnectCount = 0;
if (this._socketTask) {
this._socketTask.close({
code: code || this._closeCode,
reason: reason || this._closeReason,
});
this._socketTask = null;
}
this._readyState = CLOSED;
}
/**
* 发送消息,自动处理 JSON 序列化
* @param {string|Object} data 要发送的数据
*/
send(data) {
if (!this._socketTask || this._readyState !== OPEN) {
console.warn("[WebSocketClient] 连接未就绪,无法发送消息");
return;
}
let payload;
try {
payload = typeof data === "string" ? data : JSON.stringify(data);
} catch (err) {
console.error("[WebSocketClient] 消息序列化失败:", err);
return;
}
this._socketTask.send({
data: payload,
fail: (err) => {
console.error("[WebSocketClient] 发送消息失败:", err);
},
});
}
/**
* 获取当前连接状态
* @returns {number} 状态常量: CONNECTING(0) | OPEN(1) | CLOSING(2) | CLOSED(3)
*/
getReadyState() {
return this._readyState;
}
/**
* 动态设置/更新回调函数
* @param {Object} callbacks
* @param {Function} [callbacks.onOpen]
* @param {Function} [callbacks.onMessage]
* @param {Function} [callbacks.onClose]
* @param {Function} [callbacks.onError]
*/
setCallbacks(callbacks = {}) {
if (callbacks.onOpen !== undefined) this._onOpen = callbacks.onOpen;
if (callbacks.onMessage !== undefined) this._onMessage = callbacks.onMessage;
if (callbacks.onClose !== undefined) this._onClose = callbacks.onClose;
if (callbacks.onError !== undefined) this._onError = callbacks.onError;
}
/**
* 销毁实例,彻底清理所有资源(定时器、连接、回调)
*/
destroy() {
this.disconnect();
this._onOpen = null;
this._onMessage = null;
this._onClose = null;
this._onError = null;
this._messageParser = null;
}
// ==================== 内部方法 ====================
/**
* 解析消息数据
* @param {string} data 原始消息字符串
* @returns {*} 解析后的数据
*/
_parseMessage(data) {
if (this._messageParser) {
return this._messageParser(data);
}
try {
return JSON.parse(data);
} catch {
return data;
}
}
/**
* 启动连接超时定时器
* 超过 connectTimeout 毫秒仍未收到 onOpen,视为连接超时,触发重连
*/
_startConnectTimer() {
this._clearConnectTimer();
if (this._connectTimeout <= 0) return;
this._connectTimer = setTimeout(() => {
if (this._readyState !== CONNECTING) return;
console.warn("[WebSocketClient] 连接超时,准备重连");
this._readyState = CLOSED;
if (this._socketTask) {
try { this._socketTask.close(); } catch (e) { /* ignore */ }
this._socketTask = null;
}
if (!this._manualClose) {
this._tryReconnect();
}
}, this._connectTimeout);
}
/**
* 清除连接超时定时器
*/
_clearConnectTimer() {
if (this._connectTimer) {
clearTimeout(this._connectTimer);
this._connectTimer = null;
}
}
/**
* 启动心跳定时器(递归 setTimeout,避免回调堆积)
*/
_startHeartbeat() {
this._stopHeartbeat();
if (this._heartbeatInterval <= 0) return;
const run = () => {
if (this._readyState === OPEN && this._socketTask) {
let payload;
try {
payload = typeof this._heartbeatMsg === "string"
? this._heartbeatMsg
: JSON.stringify(this._heartbeatMsg);
} catch (err) {
console.error("[WebSocketClient] 心跳序列化失败:", err);
return;
}
this._socketTask.send({
data: payload,
fail: (err) => {
console.error("[WebSocketClient] 心跳发送失败:", err);
},
});
}
if (this._readyState === OPEN) {
this._heartbeatTimer = setTimeout(run, this._heartbeatInterval);
}
};
if (this._heartbeatImmediate) {
// 立即发送首次心跳,再进入定时循环
run();
} else {
this._heartbeatTimer = setTimeout(run, this._heartbeatInterval);
}
}
/**
* 停止心跳定时器
*/
_stopHeartbeat() {
if (this._heartbeatTimer) {
clearTimeout(this._heartbeatTimer);
this._heartbeatTimer = null;
}
}
/**
* 尝试重连(带防重复触发保护)
*/
_tryReconnect() {
if (this._reconnecting) return;
if (this._reconnectMax <= 0) return;
if (this._reconnectCount >= this._reconnectMax) {
console.warn("[WebSocketClient] 已达最大重连次数,停止重连");
return;
}
this._reconnecting = true;
this._reconnectCount++;
const delay = this._calcReconnectDelay();
console.log(
`[WebSocketClient] 将在 ${delay}ms 后第 ${this._reconnectCount}/${this._reconnectMax} 次重连...`,
);
this._clearReconnectTimer();
this._reconnectTimer = setTimeout(() => {
this._reconnecting = false;
this.connect();
}, delay);
}
/**
* 计算重连延迟时间
* @returns {number} 延迟毫秒数
*/
_calcReconnectDelay() {
if (this._reconnectStrategy === "fixed") {
return this._reconnectDelay;
}
const delay = this._reconnectDelay * Math.pow(2, this._reconnectCount - 1);
return Math.min(delay, 30000);
}
/**
* 清除重连定时器
*/
_clearReconnectTimer() {
if (this._reconnectTimer) {
clearTimeout(this._reconnectTimer);
this._reconnectTimer = null;
}
}
}
export default WebSocketClient;
四:useWebSocket.js-uniapp版本
整体定位
Vue 3 composable ,站在 WebSocketClient 之上再加一层生命周期管理,让页面/组件用 WebSocket 时只需要关心业务,不用管连接、重连、断开的时机。
构造参数
useWebSocket(url, options)
| 参数 | 类型 | 默认 | 说明 |
|---|---|---|---|
url |
string | 必填 | WebSocket 地址 |
manual |
boolean | false |
是否手动管理连接 |
onOpen |
Function | - | 连接成功回调 |
onMessage |
Function | - | 收到消息回调 (data) |
onClose |
Function | - | 关闭回调 (code, reason) |
onError |
Function | - | 错误回调 |
heartbeatMsg |
any | 'ping' |
透传 |
heartbeatInterval |
number | 30000 |
透传 |
reconnectMax |
number | 5 |
透传 |
reconnectDelay |
number | 3000 |
透传 |
reconnectStrategy |
string | 'exponential' |
透传 |
返回值也差不多,ws / isConnected / connect / disconnect / send / onReceive。
核心机制
1. 生命周期自动管理(uni-app 特有的)
if (!manual) {
onShow(() => ws.connect()) // 页面显示 → 连接
}
onHide(() => ws.disconnect()) // 页面隐藏 → 断开
onUnmounted(() => ws.disconnect()) // 组件卸载 → 断开
关键区别(和 Web 版对比):
uni-app 版: Web 版:
onShow → connect() onMounted → connect()
onHide → disconnect() visibilitychange → disconnect/connect
onUnmounted → disconnect() onUnmounted → disconnect()
uni-app 的 onShow / onHide 是框架级生命周期 ,页面从后台切回前台、App 从后台恢复都会触发,比浏览器的 visibilitychange 更准确。
2. 消息队列
连接断开时的消息不会丢:
function send(...messages) {
if (isConnected.value) {
doSend(messages) // 已连接 → 直接发
} else {
messageQueue.push(...messages) // 暂存
}
}
等 onOpen 时自动 flushQueue() 发出:
onOpen → isConnected = true → flushQueue()
↓
messageQueue.splice(0) → doSend(queue)
3. 双通道消息接收
// 方式一:构造时传
useWebSocket(url, { onMessage: (data) => ... })
// 方式二:动态注册
const { onReceive } = useWebSocket(url)
onReceive((data) => ...)
两者共存,互不覆盖。内部实现:
onMessage: (data) => {
onMessage?.(data) // 构造参数的回调
onReceiveHandler?.(data) // onReceive 注册的回调
}
4. 消息格式约定
这里硬编码 了 { type, content } 的消息结构,并对 content 做了自动解包:
// 服务端返回:
{ type: "trade", content: '{"price":50000,"qty":0.1}' }
// 回调收到:
{ type: "trade", content: { price: 50000, qty: 0.1 } }
完整生命周期流程
页面打开 → onShow → ws.connect()
↓
uni.connectSocket → task.onOpen
↓
isConnected = true → flushQueue() → 发出暂存消息 → onOpen?.()
↓
(收发消息...)
↓
页面切后台 → onHide → ws.disconnect()
↓
页面切回来 → onShow → ws.connect()
↓
...
页面关闭 → onUnmounted → ws.disconnect()
典型用法
<script setup>
import { useWebSocket } from "@/composables/useWebSocket"
const { isConnected, send, onReceive } = useWebSocket(
"wss://api.xxx.com/ws",
{
heartbeatInterval: 20000,
onOpen: () => {
send({ type: "subscribe", content: { channel: "btc_usdt" } })
},
onMessage: (data) => {
if (data.type === "trade") updateChart(data.content)
},
}
)
// 动态订阅
onReceive((data) => {
console.log("收到:", data)
})
</script>
<template>
<view>
<text>{{ isConnected ? "在线" : "离线" }}</text>
</view>
</template>
和 Web 版的核心差异
| 项 | uni-app 版 | Web 版 |
|---|---|---|
| 导入 | @dcloudio/uni-app 的 onShow/onHide |
纯 Vue 3,无平台依赖 |
| 自动连接时机 | onShow(页面每次展示) |
onMounted(组件挂载一次) |
| 自动断开时机 | onHide(页面隐藏)+ onUnmounted |
visibilitychange(Tab 切换)+ onUnmounted |
| 切换 Tab 行为 | onHide → onShow 自动断连重连 |
visibilitychange 监听 |
| 底层 Client | uni-app WebSocketClient | 原生 WebSocket WebSocketClient |
| 跨平台 | H5 / 小程序 / App | 仅浏览器 |
uni-app 版的核心优势是 onShow/onHide 这三端都生效,不需要手动监听 visibilitychange,而且在小程序和 App 上也表现一致。
代码
javascript
/**
* 通用 WebSocket 组合式函数(uni-app 版)
* Jackie 2026-07-07 --- 优化版
*
* 封装了 WebSocket 连接的完整生命周期:
* - onShow 自动连接
* - onHide / onUnmounted 自动断开
* - 支持心跳、自动重连(通过 WebSocketClient)
*
* 任何页面/组件只需传入 url + 回调即可使用,无需关心连接管理。
*
* @param {string} url - WebSocket 地址
* @param {Object} [options] - 透传给 WebSocketClient 的配置
* @param {boolean} [options.manual=false] - 是否手动管理连接
* @param {Function} [options.onOpen] - 连接成功回调
* @param {Function} [options.onMessage] - 收到消息回调 (data)
* @param {Function} [options.onClose] - 连接关闭回调 (code, reason)
* @param {Function} [options.onError] - 连接错误回调 (err)
* @param {*} [options.heartbeatMsg] - 心跳消息内容,默认 "ping"
* @param {number} [options.heartbeatInterval=30000] - 心跳间隔 ms,0 禁用
* @param {number} [options.reconnectMax=5] - 最大重连次数
* @param {number} [options.reconnectDelay=3000] - 重连基础延迟 ms
* @param {'fixed'|'exponential'} [options.reconnectStrategy='exponential']
*
* @returns {Object}
* @returns {WebSocketClient} ws - WebSocketClient 实例,用于 send()
* @returns {Ref<boolean>} isConnected - 连接状态
* @returns {Function} connect - 手动连接
* @returns {Function} disconnect - 手动断开
* @returns {Function} send - 发送消息快捷方法 (...messages)
* @returns {Function} onReceive - 注册消息接收回调,返回取消函数
*/
import { ref, onUnmounted } from "vue";
import { onShow, onHide } from "@dcloudio/uni-app";
import { WebSocketClient } from "@/utils/websocket";
/** 消息队列最大长度,防止断网时无限膨胀 */
const MAX_QUEUE_SIZE = 100;
export function useWebSocket(url, options = {}) {
const isConnected = ref(false);
const {
manual = false,
onOpen,
onMessage,
onClose,
onError,
...restOptions
} = options;
// 是否已被销毁,避免销毁后 onShow 又意外触发 connect
let disposed = false;
const ws = new WebSocketClient({
url,
...restOptions,
onOpen: () => {
isConnected.value = true;
onOpen?.();
flushQueue();
},
onMessage: (data) => {
// 安全解构:兼容非对象消息(纯字符串、数字等)
let type, content;
if (data && typeof data === "object") {
type = data.type;
content = data.content;
} else {
// 非对象消息直接透传给业务层
onMessage?.(data);
onReceiveHandlers.forEach((fn) => fn(data));
return;
}
// content 自动 JSON 解包
let parsedContent = content;
if (typeof content === "string") {
try {
parsedContent = JSON.parse(content);
} catch {
parsedContent = content;
}
}
const parsed = { type, content: parsedContent };
onMessage?.(parsed);
onReceiveHandlers.forEach((fn) => fn(parsed));
},
onClose: (code, reason) => {
isConnected.value = false;
onClose?.(code, reason);
},
onError,
});
/** 多订阅者消息回调列表 */
const onReceiveHandlers = [];
/**
* 注册消息接收回调(动态订阅,与构造参数的 onMessage 独立)
* @param {(data: any) => void} handler
* @returns {Function} 取消订阅函数
*/
function onReceive(handler) {
onReceiveHandlers.push(handler);
return () => {
const idx = onReceiveHandlers.indexOf(handler);
if (idx > -1) onReceiveHandlers.splice(idx, 1);
};
}
/** 消息队列:连接未就绪时暂存消息 */
const messageQueue = [];
function doSend(messages) {
messages.forEach(({ type, content }) => {
ws.send({
type,
content:
typeof content === "string" ? content : JSON.stringify(content),
});
});
}
function flushQueue() {
if (messageQueue.length > 0) {
const queue = messageQueue.splice(0);
doSend(queue);
}
}
function connect() {
if (disposed) return;
ws.connect();
}
function disconnect() {
ws.disconnect();
}
/**
* 发送一条或多条消息。
* 连接未就绪时自动入队,等 onOpen 后发送。
* 消息队列有上限(MAX_QUEUE_SIZE),超出时丢弃最早的消息。
* @param {...Object} messages - 每条消息为 { type, content }
*/
function send(...messages) {
if (isConnected.value) {
doSend(messages);
} else {
// 队列上限保护
if (messageQueue.length + messages.length > MAX_QUEUE_SIZE) {
const overflow =
messageQueue.length + messages.length - MAX_QUEUE_SIZE;
console.warn(
`[useWebSocket] 消息队列已满,丢弃最旧的 ${overflow} 条消息`,
);
messageQueue.splice(0, overflow);
}
messageQueue.push(...messages);
}
}
if (!manual) {
onShow(() => {
if (disposed) return;
ws.connect();
});
}
onHide(() => {
if (!disposed) ws.disconnect();
});
onUnmounted(() => {
disposed = true;
ws.disconnect();
});
return { ws, isConnected, connect, disconnect, send, onReceive };
}
book使用ws
javascript
<template>
<view class="book">
<view class="book-item-name">
<view class="flex flex-col">
<text>价格</text>
<text>(USDT)</text>
</view>
<view class="flex flex-col">
<text>数量</text>
<text>(BTC)</text>
</view>
</view>
<view class="book-item">
<view
v-for="(item, index) in orderBook.asks"
:key="'ask-' + index"
class="row ask-row"
:class="flashMap[item.price]"
>
<text class="price green-color">{{
formatMoney(item.price.toFixed(2))
}}</text>
<text class="size">{{ item.size.toFixed(6) }}</text>
<view
class="depth-bar ask-bar"
:style="{ width: (item.size / maxAskSize) * 100 + '%' }"
></view>
</view>
<view class="divider">
<text class="dv1" :class="priceFlash">
{{ formatMoney(lastPriceFormatted) }}
</text>
<text class="dv2">{{ lastUsdPrice }}</text>
</view>
<view
v-for="(item, index) in orderBook.bids"
:key="'bid-' + index"
class="row bid-row"
:class="flashMap[item.price]"
>
<text class="price red-color">{{
formatMoney(item.price.toFixed(2))
}}</text>
<text class="size">{{ item.size.toFixed(6) }}</text>
<view
class="depth-bar bid-bar"
:style="{ width: (item.size / maxBidSize) * 100 + '%' }"
></view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, reactive, onUnmounted } from "vue";
import { formatMoney } from "@/utils";
import { useWebSocket } from "@/composables/useWebSocket";
/** 订单簿深度 */
const orderBook = ref({
asks: [
{ price: 63313, size: 1.31898806 },
{ price: 63314.3, size: 0.01458098 },
{ price: 63315, size: 0.00053399 },
{ price: 63315.1, size: 0.18507448 },
{ price: 63315.2, size: 0.62001501 },
{ price: 63315.3, size: 0.34825279 },
],
bids: [
{ price: 63312.9, size: 3.11297792 },
{ price: 63311.7, size: 0.08778257 },
{ price: 63311, size: 0.05691691 },
{ price: 63309.7, size: 0.23716206 },
{ price: 63308.1, size: 0.35574309 },
],
});
/** 最新成交价 */
const lastTradePrice = ref(63273.8);
/** 最新成交方向(用于确定闪烁颜色) */
const lastTradeSide = ref("");
/** 最新成交时间戳,用于触发闪烁动画结束后复位 */
const lastTradeTs = ref(0);
/** 价格档位闪动状态映射 { [price]: 'flash-green' | 'flash-red' | undefined } */
const flashMap = reactive({});
let flashTimers = {};
/** 当前价格闪动状态 */
const priceFlash = ref("");
const maxAskSize = computed(() => {
if (!orderBook.value.asks.length) return 0;
return Math.max(...orderBook.value.asks.map((item) => item.size));
});
const maxBidSize = computed(() => {
if (!orderBook.value.bids.length) return 0;
return Math.max(...orderBook.value.bids.map((item) => item.size));
});
/** 格式化最新价格 */
const lastPriceFormatted = computed(() => {
if (lastTradePrice.value === null) return "--";
return lastTradePrice.value.toFixed(2);
});
/** 估算美元价值(粗略按最新价换算) */
const lastUsdPrice = computed(() => {
if (lastTradePrice.value === null) return "--";
return `≈ $${lastTradePrice.value.toFixed(2)}`;
});
/**
* 触发价格档位闪烁
* @param {number} price
* @param {'ask'|'bid'} side
* @param {'up'|'down'} direction - size 增大或减少
*/
function triggerRowFlash(price, side, direction) {
const key = price.toString();
// 卖盘(ask)用绿色闪烁,买盘(bid)用红色闪烁
const color = side === "ask" ? "flash-green" : "flash-red";
flashMap[key] = color;
if (flashTimers[key]) clearTimeout(flashTimers[key]);
// 短暂保持颜色后移除 class
flashTimers[key] = setTimeout(() => {
delete flashMap[key];
delete flashTimers[key];
}, 200);
}
/** 触发中间价格 */
function triggerPriceFlash(side) {
priceFlash.value = side === "buy" ? "red-color" : "green-color";
}
/**
* 处理 books 频道数据(快照 + 增量更新)
*/
function processBookData(payload) {
const { action, data } = payload;
if (!data || !data.length) return;
const book = data[0];
const { asks, bids } = book;
if (action === "snapshot") {
// 全量快照,截取前 N 档与增量更新保持一致,避免数据过多撑开页面
orderBook.value.asks = asks
.map((item) => ({
price: parseFloat(item[0]),
size: parseFloat(item[1]),
}))
.sort((a, b) => a.price - b.price)
.slice(0, 6);
orderBook.value.bids = bids
.map((item) => ({
price: parseFloat(item[0]),
size: parseFloat(item[1]),
}))
.sort((a, b) => b.price - a.price)
.slice(0, 5);
} else if (action === "update") {
// 增量更新:合并到已有数据中
if (asks && asks.length) {
const askMap = new Map(
orderBook.value.asks.map((a) => [a.price, a.size]),
);
asks.forEach((item) => {
const price = parseFloat(item[0]);
const size = parseFloat(item[1]);
const prevSize = askMap.get(price);
if (size === 0) {
// 移除该价格档位
askMap.delete(price);
} else {
askMap.set(price, size);
}
// 触发闪烁(排除首次出现的 snapshot 场景)
if (prevSize !== undefined) {
triggerRowFlash(price, "ask", size > prevSize ? "up" : "down");
} else if (size > 0) {
// 全新出现的档位
triggerRowFlash(price, "ask", "up");
}
});
// 排序:卖盘价格升序
const sortedAsks = [...askMap.entries()]
.sort((a, b) => a[0] - b[0])
.slice(0, 6)
.map(([price, size]) => ({ price, size }));
orderBook.value.asks = sortedAsks;
}
if (bids && bids.length) {
const bidMap = new Map(
orderBook.value.bids.map((b) => [b.price, b.size]),
);
bids.forEach((item) => {
const price = parseFloat(item[0]);
const size = parseFloat(item[1]);
const prevSize = bidMap.get(price);
if (size === 0) {
bidMap.delete(price);
} else {
bidMap.set(price, size);
}
if (prevSize !== undefined) {
triggerRowFlash(price, "bid", size > prevSize ? "up" : "down");
} else if (size > 0) {
triggerRowFlash(price, "bid", "up");
}
});
// 排序:买盘价格降序
const sortedBids = [...bidMap.entries()]
.sort((a, b) => b[0] - a[0])
.slice(0, 5)
.map(([price, size]) => ({ price, size }));
orderBook.value.bids = sortedBids;
}
}
}
/**
* 处理 trades 频道数据(逐笔成交)
*/
function processTradeData(payload) {
if (!payload.data || !payload.data.length) return;
const trade = payload.data[0];
const price = parseFloat(trade.px);
const side = trade.side; // 'buy' or 'sell'
const ts = trade.ts;
lastTradePrice.value = price;
lastTradeSide.value = side;
lastTradeTs.value = ts;
// 触发中间价格闪烁
triggerPriceFlash(side);
}
// const { ws } = useWebSocket("wss://ws.okx.com:8443/ws/v5/public", {
// onOpen: () => {
// // 订阅订单簿深度(books 频道提供实时快照 + 增量更新)
// ws.send({
// op: "subscribe",
// args: [{ channel: "books", instId: "BTC-USDT" }],
// });
// // 订阅逐笔成交(用于更新最新成交价)
// ws.send({
// op: "subscribe",
// args: [{ channel: "trades", instId: "BTC-USDT" }],
// });
// },
// onMessage: (data) => {
// if (data.arg?.channel === "books") {
// processBookData(data);
// } else if (data.arg?.channel === "trades") {
// processTradeData(data);
// }
// },
// });
onUnmounted(() => {
// 清理所有闪烁定时器
Object.values(flashTimers).forEach(clearTimeout);
});
</script>
<style lang="scss" scoped>
.book {
display: flex;
flex-direction: column;
// gap: 28rpx;
.book-item-name {
color: var(---06, #8a919f);
font-family: "PingFang SC";
font-size: 24rpx;
font-weight: 400;
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 10rpx;
}
.book-item {
// display: flex;
// flex-direction: column;
// gap: 20rpx;
color: #1d1e21;
font-family: "PingFang SC";
font-size: 20rpx;
font-weight: 400;
.divider {
display: flex;
flex-direction: column;
color: #8a919f;
font-family: "Hanken Grotesk";
font-size: 20rpx;
font-weight: 400;
padding: 20rpx 0;
border-radius: 8rpx;
transition: background-color 0.6s ease-out;
.dv1 {
// color: #f5465d;
font-family: "Hanken Grotesk";
font-size: 36rpx;
font-weight: 700;
}
}
.row {
display: flex;
justify-content: space-between;
align-items: center;
// height: 28rpx;
padding: 4rpx 0;
position: relative;
overflow: hidden;
transition: background-color 0.6s ease-out;
.depth-bar {
position: absolute;
top: 0;
height: 100%;
}
.ask-bar {
right: 0;
background: rgba(41, 202, 139, 0.12);
}
.bid-bar {
right: 0;
background: rgba(245, 70, 93, 0.12);
}
&.flash-green {
background-color: rgba(41, 202, 139, 0.2);
}
&.flash-red {
background-color: rgba(245, 70, 93, 0.2);
}
}
}
}
</style>
实验案例,仅供参考