Vue 工程中 WebSocket 封装:自动重连、固定 3 秒重连、心跳检测、假死检测、请求超时完整方案
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/
更多推荐

所有评论(0)