WebSocket-不同平台封装-Hooks和使用案例

一: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
  • _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-apponShow/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 --- 固定 reconnectDelay ms
  • exponential --- 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-apponShow/onHide 纯 Vue 3,无平台依赖
自动连接时机 onShow(页面每次展示) onMounted(组件挂载一次)
自动断开时机 onHide(页面隐藏)+ onUnmounted visibilitychange(Tab 切换)+ onUnmounted
切换 Tab 行为 onHideonShow 自动断连重连 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>

实验案例,仅供参考

相关推荐
andxe12 小时前
安科士 AndXe 技术博客:400G QSFP112 SR4 光模块|AI 算力与超算短距互联最优方案
网络·人工智能·光模块·光通信
shiyi.十一13 小时前
第2章:应用层 — 知识要点与架构
网络·计算机网络·架构
DFT计算杂谈14 小时前
无 Root 权限在 Tesla K80 零门槛部署 DeepSeek 大模型
linux·服务器·网络·数据库·机器学习
水境传感 李兆栋14 小时前
GNSS 位移监测站 :毫米级感知,筑牢安全监测防线
网络
中微极客15 小时前
2026主流AI Agent框架技术选型与性能对比
运维·网络·人工智能
猿的天空19 小时前
机器人双手迎来全栈训练系统:灵初智能EgoSteer让灵巧手无所不能
网络·人工智能·计算机·ai·程序员·机器人·编程
Xzaveir_77719 小时前
OPPO、vivo、荣耀号码认证:多终端拨测矩阵与异常复现
大数据·网络·科技·矩阵·产品经理·ai-native
wuqingshun31415919 小时前
从网络角度来看,用户从输入网址到网页显示,期间发生了什么?
网络
牛马工作号20 小时前
Zigbee 专业详解:从协议到排障
网络·物联网·安全