Vue 工程中 WebSocket 封装:自动重连、固定 3 秒重连、心跳检测、假死检测、请求超时完整方案

一、前言

在前端项目中,WebSocket 经常用于实时数据场景,例如:

  • 实时报警
  • 实时聊天
  • 实时设备状态
  • BMS / IoT / 物联网数据推送
  • 交易行情
  • 大屏数据刷新
  • Home Assistant / 3D 数字孪生实时通信

但是如果直接在页面中写原生 WebSocket,很容易遇到这些问题:

  • 断线后不会自动重连
  • 网络假死时 onclose 不触发
  • 页面重复调用导致创建多个连接
  • 后端断开后前端不知道
  • 发送请求后没有响应超时控制
  • onerror 只打印错误,不会恢复连接
  • 页面销毁后连接和定时器没有清理
  • 消息格式不统一,后期难维护

所以真实项目中,一般都需要对 WebSocket 做一层二次封装。

本文会实现一个 Vue 工程中可直接使用的 WebSocket 封装,包含以下功能:

功能 是否支持
断开后自动重连 支持
固定 3 秒后重连 支持
防止重复连接 支持
主动 ping / pong 心跳 支持
检测假死连接 支持
onerror 立即重连 支持
请求超时 支持
Vue 页面开箱即用 支持
页面销毁自动释放 支持

二、关于浏览器 WebSocket 的 ping / pong 说明

这里要先说明一个关键点:

浏览器中的 WebSocket API 不能像 Node.js 的 ws 库一样直接发送协议层的 Ping 帧,也不能直接监听协议层 Pong 帧。

所以前端项目中常说的 ping / pong 心跳,通常是业务层心跳

例如前端发送:

{
  "type": "ping",
  "timestamp": 1710000000000
}

后端返回:

{
  "type": "pong",
  "timestamp": 1710000000000
}

也就是说,前端主动发一个普通消息 ping,后端收到后返回一个普通消息 pong

如果前端在指定时间内没有收到 pong,就认为连接可能已经假死,然后主动关闭连接并重连。


三、最终项目目录结构

建议按下面结构组织代码:

src
├── utils
│   └── websocket-client.js       # WebSocket 核心封装
├── hooks
│   └── useWebSocket.js           # Vue 组合式 Hook
├── views
│   └── WebSocketDemo.vue         # 页面使用示例
└── main.js

如果你需要本地模拟 WebSocket 服务端,可以额外添加:

mock-ws-server.js

四、配置 WebSocket 地址

在 Vite 项目根目录新建 .env.development

VITE_WS_URL=ws://localhost:8080/ws

生产环境 .env.production

VITE_WS_URL=wss://your-domain.com/ws

说明:

协议 说明
ws:// 普通 WebSocket,类似 HTTP
wss:// 加密 WebSocket,类似 HTTPS

如果你的页面是 HTTPS,WebSocket 通常也需要使用 wss://,否则浏览器可能会因为安全策略拦截。


五、核心封装:src/utils/websocket-client.js

下面是完整封装代码,直接复制即可使用。

/**
 * WebSocketClient
 *
 * 功能:
 * 1. 断开后自动重连
 * 2. 固定 3 秒后重连
 * 3. 防止重复连接
 * 4. 主动 ping / pong 心跳
 * 5. 检测假死连接
 * 6. onerror 立即重连
 * 7. 支持请求超时
 * 8. 支持消息队列
 * 9. 支持手动关闭
 */

export default class WebSocketClient {
  constructor(options = {}) {
    this.url = options.url;
    this.protocols = options.protocols;

    /**
     * 固定 3 秒重连
     */
    this.reconnectInterval = options.reconnectInterval ?? 3000;

    /**
     * onerror 立即重连
     * 这里设置为 0,表示不等待,直接重连
     */
    this.errorReconnectInterval = options.errorReconnectInterval ?? 0;

    /**
     * 心跳发送间隔
     */
    this.heartbeatInterval = options.heartbeatInterval ?? 10000;

    /**
     * pong 响应超时时间
     */
    this.heartbeatTimeout = options.heartbeatTimeout ?? 5000;

    /**
     * sendRequest 默认超时时间
     */
    this.requestTimeout = options.requestTimeout ?? 8000;

    /**
     * 是否开启调试日志
     */
    this.debug = options.debug ?? false;

    /**
     * 是否允许断线期间消息进入队列
     */
    this.enableQueue = options.enableQueue ?? true;

    /**
     * 连接实例
     */
    this.ws = null;

    /**
     * 当前连接状态
     */
    this.status = "closed";

    /**
     * 是否正在连接中,防止重复连接
     */
    this.isConnecting = false;

    /**
     * 是否手动关闭
     * 手动关闭后,不再自动重连
     */
    this.manualClose = false;

    /**
     * 重连定时器
     */
    this.reconnectTimer = null;

    /**
     * 心跳定时器
     */
    this.heartbeatTimer = null;

    /**
     * pong 超时检测定时器
     */
    this.pongTimer = null;

    /**
     * 是否正在等待 pong
     */
    this.waitingPong = false;

    /**
     * 消息发送队列
     */
    this.messageQueue = [];

    /**
     * 请求池
     * requestId -> { resolve, reject, timer }
     */
    this.pendingRequests = new Map();

    /**
     * 回调函数
     */
    this.onOpen = options.onOpen || null;
    this.onClose = options.onClose || null;
    this.onError = options.onError || null;
    this.onMessage = options.onMessage || null;
    this.onStatusChange = options.onStatusChange || null;
    this.onReconnect = options.onReconnect || null;
  }

  /**
   * 打印日志
   */
  log(...args) {
    if (this.debug) {
      console.log("[WebSocketClient]", ...args);
    }
  }

  /**
   * 修改状态
   */
  setStatus(status) {
    this.status = status;

    if (typeof this.onStatusChange === "function") {
      this.onStatusChange(status);
    }
  }

  /**
   * 获取当前连接是否打开
   */
  isOpen() {
    return this.ws && this.ws.readyState === WebSocket.OPEN;
  }

  /**
   * 获取当前是否正在连接
   */
  isConnectingState() {
    return this.ws && this.ws.readyState === WebSocket.CONNECTING;
  }

  /**
   * 连接 WebSocket
   */
  connect() {
    if (!this.url) {
      throw new Error("WebSocket url 不能为空");
    }

    /**
     * 防止重复连接:
     * 如果已经打开或者正在连接,直接 return
     */
    if (this.isOpen() || this.isConnecting || this.isConnectingState()) {
      this.log("连接已存在或正在连接中,跳过重复连接");
      return;
    }

    this.manualClose = false;
    this.isConnecting = true;
    this.setStatus("connecting");

    this.log("开始连接:", this.url);

    try {
      this.ws = new WebSocket(this.url, this.protocols);

      this.ws.onopen = this.handleOpen.bind(this);
      this.ws.onmessage = this.handleMessage.bind(this);
      this.ws.onerror = this.handleError.bind(this);
      this.ws.onclose = this.handleClose.bind(this);
    } catch (error) {
      this.isConnecting = false;
      this.setStatus("closed");
      this.log("创建 WebSocket 失败:", error);

      this.scheduleReconnect(this.reconnectInterval);
    }
  }

  /**
   * 连接成功
   */
  handleOpen(event) {
    this.log("连接成功");

    this.isConnecting = false;
    this.setStatus("open");

    this.clearReconnectTimer();
    this.startHeartbeat();
    this.flushMessageQueue();

    if (typeof this.onOpen === "function") {
      this.onOpen(event);
    }
  }

  /**
   * 接收消息
   */
  handleMessage(event) {
    const message = this.parseMessage(event.data);

    /**
     * 收到任何消息,说明连接至少当前是有数据返回的
     */
    this.log("收到消息:", message);

    /**
     * 处理 pong 心跳响应
     */
    if (this.isPongMessage(message)) {
      this.handlePong(message);
      return;
    }

    /**
     * 处理 request / response 模式
     */
    if (message && message.requestId && this.pendingRequests.has(message.requestId)) {
      const pending = this.pendingRequests.get(message.requestId);

      clearTimeout(pending.timer);

      this.pendingRequests.delete(message.requestId);

      pending.resolve(message);

      return;
    }

    /**
     * 普通业务消息交给外部处理
     */
    if (typeof this.onMessage === "function") {
      this.onMessage(message, event);
    }
  }

  /**
   * 连接错误
   *
   * 需求:onerror 立即重连
   */
  handleError(event) {
    this.log("连接错误,准备立即重连", event);

    if (typeof this.onError === "function") {
      this.onError(event);
    }

    if (this.manualClose) {
      return;
    }

    /**
     * onerror 后不等待 onclose,直接清理并立即重连
     * 注意:scheduleReconnect 内部有防重复定时器保护
     */
    this.forceCloseSocket();

    this.rejectAllPendingRequests(new Error("WebSocket 连接错误,请求已中断"));

    this.scheduleReconnect(this.errorReconnectInterval);
  }

  /**
   * 连接关闭
   */
  handleClose(event) {
    this.log("连接关闭:", event);

    this.isConnecting = false;
    this.stopHeartbeat();
    this.rejectAllPendingRequests(new Error("WebSocket 连接已关闭,请求已中断"));

    this.cleanupSocket();

    if (typeof this.onClose === "function") {
      this.onClose(event);
    }

    if (this.manualClose) {
      this.setStatus("closed");
      return;
    }

    /**
     * 需求:断开后固定 3 秒重连
     */
    this.scheduleReconnect(this.reconnectInterval);
  }

  /**
   * 安排重连
   */
  scheduleReconnect(delay = this.reconnectInterval) {
    if (this.manualClose) {
      return;
    }

    /**
     * 防止重复重连定时器
     */
    if (this.reconnectTimer) {
      return;
    }

    this.setStatus("reconnecting");

    this.log(`${delay}ms 后重连`);

    this.reconnectTimer = setTimeout(() => {
      this.reconnectTimer = null;

      if (typeof this.onReconnect === "function") {
        this.onReconnect();
      }

      this.connect();
    }, delay);
  }

  /**
   * 清理重连定时器
   */
  clearReconnectTimer() {
    if (this.reconnectTimer) {
      clearTimeout(this.reconnectTimer);
      this.reconnectTimer = null;
    }
  }

  /**
   * 开始心跳
   */
  startHeartbeat() {
    this.stopHeartbeat();

    this.heartbeatTimer = setInterval(() => {
      this.sendPing();
    }, this.heartbeatInterval);

    /**
     * 连接成功后立即发一次 ping
     */
    this.sendPing();
  }

  /**
   * 停止心跳
   */
  stopHeartbeat() {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer);
      this.heartbeatTimer = null;
    }

    if (this.pongTimer) {
      clearTimeout(this.pongTimer);
      this.pongTimer = null;
    }

    this.waitingPong = false;
  }

  /**
   * 发送 ping
   */
  sendPing() {
    if (!this.isOpen()) {
      return;
    }

    /**
     * 如果上一次 ping 还没有收到 pong,说明可能假死
     */
    if (this.waitingPong) {
      this.log("上一次 pong 未返回,判定为假死连接,准备重连");

      this.handleHeartbeatTimeout();

      return;
    }

    this.waitingPong = true;

    const pingMessage = {
      type: "ping",
      timestamp: Date.now(),
    };

    this.sendRaw(pingMessage);

    /**
     * 指定时间内没有收到 pong,也判定为假死
     */
    this.pongTimer = setTimeout(() => {
      if (this.waitingPong) {
        this.log("pong 响应超时,判定为假死连接");
        this.handleHeartbeatTimeout();
      }
    }, this.heartbeatTimeout);
  }

  /**
   * 判断是否是 pong 消息
   */
  isPongMessage(message) {
    if (message === "pong") return true;

    if (message && message.type === "pong") return true;

    return false;
  }

  /**
   * 处理 pong
   */
  handlePong(message) {
    this.log("收到 pong:", message);

    this.waitingPong = false;

    if (this.pongTimer) {
      clearTimeout(this.pongTimer);
      this.pongTimer = null;
    }
  }

  /**
   * 心跳超时
   */
  handleHeartbeatTimeout() {
    this.stopHeartbeat();

    this.rejectAllPendingRequests(new Error("WebSocket 心跳超时,请求已中断"));

    this.forceCloseSocket();

    this.scheduleReconnect(this.reconnectInterval);
  }

  /**
   * 普通发送消息
   */
  send(data) {
    if (!this.isOpen()) {
      this.log("连接未打开,消息进入队列:", data);

      if (this.enableQueue) {
        this.messageQueue.push(data);
      }

      return false;
    }

    return this.sendRaw(data);
  }

  /**
   * 原始发送
   */
  sendRaw(data) {
    if (!this.isOpen()) {
      return false;
    }

    const message = typeof data === "string" ? data : JSON.stringify(data);

    this.ws.send(message);

    return true;
  }

  /**
   * 发送请求,并等待服务端根据 requestId 返回响应
   *
   * 服务端需要返回:
   * {
   *   "requestId": "xxx",
   *   "type": "response",
   *   "data": {}
   * }
   */
  sendRequest(data, timeout = this.requestTimeout) {
    if (!this.isOpen()) {
      return Promise.reject(new Error("WebSocket 未连接,无法发送请求"));
    }

    const requestId = this.createRequestId();

    const payload = {
      ...this.normalizeRequestData(data),
      requestId,
    };

    return new Promise((resolve, reject) => {
      const timer = setTimeout(() => {
        this.pendingRequests.delete(requestId);
        reject(new Error(`WebSocket 请求超时:${requestId}`));
      }, timeout);

      this.pendingRequests.set(requestId, {
        resolve,
        reject,
        timer,
      });

      const success = this.sendRaw(payload);

      if (!success) {
        clearTimeout(timer);
        this.pendingRequests.delete(requestId);
        reject(new Error("WebSocket 发送失败"));
      }
    });
  }

  /**
   * 清空消息队列
   */
  flushMessageQueue() {
    if (!this.messageQueue.length) {
      return;
    }

    const queue = [...this.messageQueue];

    this.messageQueue = [];

    queue.forEach((message) => {
      this.send(message);
    });
  }

  /**
   * 解析消息
   */
  parseMessage(data) {
    try {
      return JSON.parse(data);
    } catch {
      return data;
    }
  }

  /**
   * 标准化请求数据
   */
  normalizeRequestData(data) {
    if (data && typeof data === "object" && !Array.isArray(data)) {
      return data;
    }

    return {
      data,
    };
  }

  /**
   * 生成 requestId
   */
  createRequestId() {
    return `req_${Date.now()}_${Math.random().toString(16).slice(2)}`;
  }

  /**
   * 拒绝所有等待响应的请求
   */
  rejectAllPendingRequests(error) {
    this.pendingRequests.forEach((pending) => {
      clearTimeout(pending.timer);
      pending.reject(error);
    });

    this.pendingRequests.clear();
  }

  /**
   * 强制关闭当前 socket
   */
  forceCloseSocket() {
    if (!this.ws) {
      return;
    }

    try {
      this.cleanupSocketEvents();

      if (
        this.ws.readyState === WebSocket.OPEN ||
        this.ws.readyState === WebSocket.CONNECTING
      ) {
        this.ws.close();
      }
    } catch (error) {
      this.log("强制关闭 socket 异常:", error);
    } finally {
      this.cleanupSocket();
    }
  }

  /**
   * 清理 socket 事件,防止重复触发
   */
  cleanupSocketEvents() {
    if (!this.ws) {
      return;
    }

    this.ws.onopen = null;
    this.ws.onmessage = null;
    this.ws.onerror = null;
    this.ws.onclose = null;
  }

  /**
   * 清理 socket 引用
   */
  cleanupSocket() {
    this.cleanupSocketEvents();
    this.ws = null;
    this.isConnecting = false;
  }

  /**
   * 手动关闭连接
   */
  close() {
    this.log("手动关闭连接");

    this.manualClose = true;

    this.clearReconnectTimer();
    this.stopHeartbeat();
    this.rejectAllPendingRequests(new Error("WebSocket 已手动关闭"));

    if (this.ws) {
      try {
        this.cleanupSocketEvents();

        if (
          this.ws.readyState === WebSocket.OPEN ||
          this.ws.readyState === WebSocket.CONNECTING
        ) {
          this.ws.close();
        }
      } catch (error) {
        this.log("关闭 WebSocket 异常:", error);
      }
    }

    this.cleanupSocket();
    this.setStatus("closed");
  }
}

六、Vue Hook 封装:src/hooks/useWebSocket.js

上面是通用类,下面再封装一层 Vue 组合式 Hook,方便在组件中使用。

import { ref, shallowRef, onBeforeUnmount } from "vue";
import WebSocketClient from "@/utils/websocket-client";

export function useWebSocket(options = {}) {
  const status = ref("closed");
  const latestMessage = ref(null);
  const messages = ref([]);
  const error = ref(null);

  const clientRef = shallowRef(null);

  const client = new WebSocketClient({
    url: options.url,
    protocols: options.protocols,

    reconnectInterval: options.reconnectInterval ?? 3000,
    errorReconnectInterval: options.errorReconnectInterval ?? 0,

    heartbeatInterval: options.heartbeatInterval ?? 10000,
    heartbeatTimeout: options.heartbeatTimeout ?? 5000,

    requestTimeout: options.requestTimeout ?? 8000,

    enableQueue: options.enableQueue ?? true,
    debug: options.debug ?? false,

    onStatusChange(value) {
      status.value = value;

      if (typeof options.onStatusChange === "function") {
        options.onStatusChange(value);
      }
    },

    onMessage(message, event) {
      latestMessage.value = message;
      messages.value.push(message);

      if (typeof options.onMessage === "function") {
        options.onMessage(message, event);
      }
    },

    onOpen(event) {
      if (typeof options.onOpen === "function") {
        options.onOpen(event);
      }
    },

    onClose(event) {
      if (typeof options.onClose === "function") {
        options.onClose(event);
      }
    },

    onError(event) {
      error.value = event;

      if (typeof options.onError === "function") {
        options.onError(event);
      }
    },

    onReconnect() {
      if (typeof options.onReconnect === "function") {
        options.onReconnect();
      }
    },
  });

  clientRef.value = client;

  function connect() {
    client.connect();
  }

  function close() {
    client.close();
  }

  function send(data) {
    return client.send(data);
  }

  function sendRequest(data, timeout) {
    return client.sendRequest(data, timeout);
  }

  /**
   * 组件销毁时释放连接
   *
   * 如果你的 WebSocket 是全局唯一连接,不建议在页面组件中自动 close,
   * 可以把 autoClose 设置为 false。
   */
  if (options.autoClose !== false) {
    onBeforeUnmount(() => {
      client.close();
    });
  }

  return {
    status,
    latestMessage,
    messages,
    error,
    client: clientRef,

    connect,
    close,
    send,
    sendRequest,
  };
}

七、页面使用示例:src/views/WebSocketDemo.vue

<template>
  <div class="page">
    <h2>Vue WebSocket 封装示例</h2>

    <div class="status">
      当前状态:
      <span :class="['status-text', status]">
        {{ status }}
      </span>
    </div>

    <div class="actions">
      <button @click="connect">连接</button>
      <button @click="close">关闭</button>
      <button @click="sendNormalMessage">发送普通消息</button>
      <button @click="sendRequestMessage">发送请求消息</button>
    </div>

    <div class="panel">
      <h3>最新消息</h3>
      <pre>{{ latestMessage }}</pre>
    </div>

    <div class="panel">
      <h3>消息列表</h3>
      <ul>
        <li v-for="(item, index) in messages" :key="index">
          {{ item }}
        </li>
      </ul>
    </div>
  </div>
</template>

<script setup>
import { onMounted } from "vue";
import { useWebSocket } from "@/hooks/useWebSocket";

const {
  status,
  latestMessage,
  messages,
  connect,
  close,
  send,
  sendRequest,
} = useWebSocket({
  url: import.meta.env.VITE_WS_URL,

  /**
   * 断线后固定 3 秒重连
   */
  reconnectInterval: 3000,

  /**
   * onerror 立即重连
   */
  errorReconnectInterval: 0,

  /**
   * 每 10 秒发一次 ping
   */
  heartbeatInterval: 10000,

  /**
   * 5 秒内没有收到 pong,判定假死
   */
  heartbeatTimeout: 5000,

  /**
   * 请求默认 8 秒超时
   */
  requestTimeout: 8000,

  debug: true,

  onOpen() {
    console.log("页面监听:WebSocket 已连接");
  },

  onReconnect() {
    console.log("页面监听:WebSocket 正在重连");
  },

  onMessage(message) {
    console.log("页面监听:收到业务消息", message);
  },
});

onMounted(() => {
  connect();
});

function sendNormalMessage() {
  send({
    type: "message",
    data: {
      text: "这是一条普通 WebSocket 消息",
    },
  });
}

async function sendRequestMessage() {
  try {
    const res = await sendRequest(
      {
        type: "getUserInfo",
        data: {
          userId: 1001,
        },
      },
      8000
    );

    console.log("请求响应:", res);
  } catch (error) {
    console.error("请求失败:", error.message);
  }
}
</script>

<style scoped>
.page {
  padding: 24px;
  font-family: Arial, sans-serif;
}

.status {
  margin-bottom: 16px;
}

.status-text {
  font-weight: bold;
}

.status-text.open {
  color: #18a058;
}

.status-text.connecting,
.status-text.reconnecting {
  color: #f0a020;
}

.status-text.closed {
  color: #d03050;
}

.actions {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
}

button {
  padding: 8px 14px;
  border: 1px solid #ccc;
  border-radius: 6px;
  background: #fff;
  cursor: pointer;
}

button:hover {
  background: #f5f5f5;
}

.panel {
  margin-top: 16px;
  padding: 16px;
  border: 1px solid #ddd;
  border-radius: 8px;
}

pre {
  white-space: pre-wrap;
  word-break: break-all;
}
</style>

八、本地模拟 WebSocket 服务端

如果你现在没有后端,可以用 Node.js 快速模拟一个 WebSocket 服务端。

安装依赖:

npm install ws -D

在项目根目录新建 mock-ws-server.js

const { WebSocketServer } = require("ws");

const wss = new WebSocketServer({
  port: 8080,
  path: "/ws",
});

console.log("WebSocket mock server running at ws://localhost:8080/ws");

wss.on("connection", (ws) => {
  console.log("客户端已连接");

  ws.on("message", (raw) => {
    const text = raw.toString();

    console.log("收到客户端消息:", text);

    let message = null;

    try {
      message = JSON.parse(text);
    } catch {
      message = text;
    }

    /**
     * 处理业务层 ping / pong
     */
    if (message && message.type === "ping") {
      ws.send(
        JSON.stringify({
          type: "pong",
          timestamp: Date.now(),
        })
      );

      return;
    }

    /**
     * 处理 request / response 模式
     */
    if (message && message.requestId) {
      setTimeout(() => {
        ws.send(
          JSON.stringify({
            type: "response",
            requestId: message.requestId,
            data: {
              success: true,
              received: message,
              serverTime: Date.now(),
            },
          })
        );
      }, 500);

      return;
    }

    /**
     * 普通消息回显
     */
    ws.send(
      JSON.stringify({
        type: "message",
        data: {
          text: "服务端已收到消息",
          received: message,
        },
      })
    );
  });

  ws.on("close", () => {
    console.log("客户端已断开");
  });
});

启动服务端:

node mock-ws-server.js

然后启动 Vue 项目:

npm run dev

九、每个功能的实现说明

9.1 断开后自动重连

核心代码:

if (this.manualClose) {
  this.setStatus("closed");
  return;
}

this.scheduleReconnect(this.reconnectInterval);

只要不是手动关闭,连接断开后就会进入重连逻辑。


9.2 固定 3 秒后重连

核心配置:

reconnectInterval: 3000

核心代码:

scheduleReconnect(delay = this.reconnectInterval) {
  this.reconnectTimer = setTimeout(() => {
    this.reconnectTimer = null;
    this.connect();
  }, delay);
}

如果你希望改成 5 秒重连:

reconnectInterval: 5000

9.3 防止重复连接

核心代码:

if (this.isOpen() || this.isConnecting || this.isConnectingState()) {
  return;
}

它可以避免下面这种情况:

connect();
connect();
connect();

否则页面多次点击连接按钮,可能会创建多个 WebSocket 实例。


9.4 主动 ping / pong 心跳

核心代码:

const pingMessage = {
  type: "ping",
  timestamp: Date.now(),
};

this.sendRaw(pingMessage);

后端需要返回:

{
  "type": "pong"
}

前端收到后清除等待状态:

this.waitingPong = false;
clearTimeout(this.pongTimer);

9.5 检测假死连接

有时候网络已经断了,但是浏览器端 onclose 没有立即触发,readyState 可能还保持在 OPEN

这种情况就叫假死连接。

解决方式就是心跳超时检测:

this.pongTimer = setTimeout(() => {
  if (this.waitingPong) {
    this.handleHeartbeatTimeout();
  }
}, this.heartbeatTimeout);

如果超过指定时间还没收到 pong,主动关闭并重连:

handleHeartbeatTimeout() {
  this.stopHeartbeat();
  this.forceCloseSocket();
  this.scheduleReconnect(this.reconnectInterval);
}

9.6 onerror 立即重连

核心代码:

handleError(event) {
  this.forceCloseSocket();
  this.scheduleReconnect(this.errorReconnectInterval);
}

配置:

errorReconnectInterval: 0

0 表示立即重连。

如果你担心服务端宕机时重连太频繁,可以改成:

errorReconnectInterval: 3000

9.7 请求超时

普通 WebSocket 的 send() 本身没有请求响应概念,它只是把消息发出去。

如果想像 HTTP 一样实现:

const res = await sendRequest(data);

就需要自己维护 requestId

前端发送:

{
  "type": "getUserInfo",
  "requestId": "req_1710000000000_xxx",
  "data": {
    "userId": 1001
  }
}

服务端返回:

{
  "type": "response",
  "requestId": "req_1710000000000_xxx",
  "data": {
    "name": "Tom"
  }
}

前端根据 requestId 找到对应 Promise 并 resolve。

如果超过指定时间还没返回,就 reject:

const timer = setTimeout(() => {
  this.pendingRequests.delete(requestId);
  reject(new Error(`WebSocket 请求超时:${requestId}`));
}, timeout);

十、服务端需要配合的消息格式

为了让上面的封装完整工作,后端至少需要支持两类消息。

10.1 心跳消息

前端发送:

{
  "type": "ping",
  "timestamp": 1710000000000
}

后端返回:

{
  "type": "pong",
  "timestamp": 1710000000000
}

10.2 请求响应消息

前端发送:

{
  "type": "getUserInfo",
  "requestId": "req_1710000000000_abcd",
  "data": {
    "userId": 1001
  }
}

后端返回:

{
  "type": "response",
  "requestId": "req_1710000000000_abcd",
  "data": {
    "userId": 1001,
    "name": "Tom"
  }
}

重点是:后端必须把前端传来的 requestId 原样返回


十一、全局单例连接写法

如果你的项目只需要一个全局 WebSocket 连接,不建议每个页面都创建一个连接。

可以创建一个单例文件。

新建 src/utils/ws-singleton.js

import WebSocketClient from "@/utils/websocket-client";

const wsClient = new WebSocketClient({
  url: import.meta.env.VITE_WS_URL,
  reconnectInterval: 3000,
  errorReconnectInterval: 0,
  heartbeatInterval: 10000,
  heartbeatTimeout: 5000,
  requestTimeout: 8000,
  debug: true,

  onMessage(message) {
    console.log("全局收到消息:", message);
  },
});

export default wsClient;

main.js 中启动:

import { createApp } from "vue";
import App from "./App.vue";
import wsClient from "@/utils/ws-singleton";

const app = createApp(App);

wsClient.connect();

app.mount("#app");

页面中使用:

import wsClient from "@/utils/ws-singleton";

wsClient.send({
  type: "message",
  data: "hello",
});

const res = await wsClient.sendRequest({
  type: "getUserInfo",
  data: {
    userId: 1001,
  },
});

如果使用全局单例,一般不需要在页面销毁时关闭连接。


十二、常见问题

12.1 为什么明明断网了,WebSocket 没有马上触发 onclose?

WebSocket 底层基于 TCP,某些异常断网、网络切换、代理中断、电脑睡眠恢复等情况,浏览器可能不会立即感知连接已经不可用。

所以不能只依赖 onclose,还需要业务层心跳检测。


12.2 为什么我的 ping 发出去了,但是一直重连?

大概率是后端没有回复 pong

前端发:

{
  "type": "ping"
}

后端必须返回:

{
  "type": "pong"
}

否则前端会认为连接假死,然后触发重连。


12.3 为什么发送请求后一直超时?

检查后端是否返回了相同的 requestId

前端发送:

{
  "requestId": "req_xxx"
}

后端必须返回:

{
  "requestId": "req_xxx"
}

如果 requestId 对不上,前端无法知道这个响应属于哪一个请求。


12.4 onerror 立即重连会不会太频繁?

会有这个可能。

本文根据需求实现的是 onerror 立即重连:

errorReconnectInterval: 0

如果生产环境服务端可能长时间不可用,建议改成固定 3 秒:

errorReconnectInterval: 3000

或者升级为指数退避重连策略,例如:

3000 -> 5000 -> 10000 -> 30000

12.5 为什么要区分手动关闭和异常关闭?

因为手动关闭时通常表示用户退出页面、退出登录、主动断开。

这个时候不应该继续自动重连。

所以封装中使用了:

this.manualClose = true;

如果是异常关闭:

this.manualClose = false;

才会进入重连逻辑。


十三、真实项目推荐参数

useWebSocket({
  url: import.meta.env.VITE_WS_URL,

  // 断开后固定 3 秒重连
  reconnectInterval: 3000,

  // onerror 立即重连
  errorReconnectInterval: 0,

  // 10 秒一次心跳
  heartbeatInterval: 10000,

  // 5 秒没有收到 pong,判定假死
  heartbeatTimeout: 5000,

  // 请求 8 秒超时
  requestTimeout: 8000,

  // 开发阶段打开,生产环境关闭
  debug: import.meta.env.DEV,
});

十四、总结

本文完成了 Vue 工程中 WebSocket 的完整封装,重点解决了真实项目中最常见的几个问题:

  • 断开后自动重连
  • 固定 3 秒重连
  • 防止重复连接
  • 主动 ping / pong 心跳
  • 检测假死连接
  • onerror 立即重连
  • 请求超时
  • 页面销毁自动清理
  • 支持 Vue 组合式 Hook 使用
  • 支持全局单例连接

对于实际项目来说,不建议直接在页面里裸写 new WebSocket()

推荐统一封装成:

WebSocketClient 核心类
        +
useWebSocket Vue Hook
        +
业务页面调用

这样后续维护、排查问题、统一调整心跳和重连策略都会更方便。


参考资料

  • MDN WebSocket API:https://developer.mozilla.org/en-US/docs/Web/API/WebSocket
  • MDN WebSocket readyState:https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/readyState
  • WHATWG WebSockets Standard:https://websockets.spec.whatwg.org/

更多推荐