WebSocket 连接、error / close 事件和 Vue3 重连组件示例
这篇文章解决什么问题
WebSocket 的 API 很少,真正容易写错的不是“怎么连上”,而是下面几个细节:
error和close都可能触发,应该在哪个事件里提示用户?- 连接失败时,为什么拿不到 HTTP 状态码和详细报错?
- 服务端主动断开、网络断开、浏览器主动关闭,前端怎么区分?
- 失败后要不要自动重连?如果提示用户重连,状态怎么收口?
- Vue3 组件里怎么封装连接、发送、断开、重连?
本文用原生 WebSocket 写一个基础 JS 示例,再封装一个 Vue3 组件,重点说明 error / close 的触发时机和连接失败后的重连提示逻辑。
先说结论
WebSocket 事件处理建议记住一句话:
error只记录“出错了”,最终状态统一交给close收口。
原因是:
error事件信息很少,浏览器出于安全原因不会把握手失败的 HTTP 状态码、响应体、底层网络错误完整暴露给 JS。- 很多失败场景会先触发
error,随后再触发close。 close事件里有code、reason、wasClean,更适合作为“连接已结束”的统一入口。- 用户主动关闭、服务端正常关闭通常只触发
close,不一定触发error。
所以更稳的写法是:
open:标记连接成功,清掉失败提示。message:处理服务端消息。error:记录错误状态,但不要在这里重复重连。close:判断是不是主动关闭;不是主动关闭时,再提示用户重连或进入自动重连。
基础 JS 连接示例
先看最小版本:
js
let socket = null;
let manualClose = false;
function connectWebSocket() {
manualClose = false;
socket = new WebSocket("wss://example.com/ws");
socket.addEventListener("open", () => {
console.log("WebSocket 已连接");
socket.send(JSON.stringify({ type: "hello" }));
});
socket.addEventListener("message", (event) => {
console.log("收到消息:", event.data);
});
socket.addEventListener("error", (event) => {
// error 通常没有可用细节,不要只靠它判断最终断开原因。
console.warn("WebSocket 发生错误:", event);
});
socket.addEventListener("close", (event) => {
console.log("WebSocket 已关闭:", {
code: event.code,
reason: event.reason,
wasClean: event.wasClean,
});
if (!manualClose) {
console.log("非主动关闭,可以提示用户重连");
}
});
}
function sendMessage(data) {
if (!socket || socket.readyState !== WebSocket.OPEN) {
console.warn("WebSocket 未连接,消息未发送");
return;
}
socket.send(JSON.stringify(data));
}
function closeWebSocket() {
manualClose = true;
if (socket) {
socket.close(1000, "用户主动关闭");
}
}这个版本能跑,但还不够业务化。真实项目里还要处理:
- 正在连接时禁止重复连接;
- 连接失败后提示用户重连;
- 用户主动关闭时不弹失败提示;
- 自动重连不要无限刷;
- 组件卸载时清理连接和定时器。
error 事件什么时候触发
error 表示 WebSocket 连接或通信过程中发生错误,常见场景包括:
- 地址写错,DNS 解析失败;
- 服务不可达,端口没开;
- TLS / 证书异常;
- HTTP Upgrade 握手失败;
- 服务端拒绝连接;
- 网络突然中断;
- 浏览器检测到底层连接异常。
但 error 有一个关键限制:
前端通常拿不到具体失败原因。
例如服务端返回 401、403、500,或者握手没有返回 101 Switching Protocols,浏览器控制台可能能看到更多信息,但 JS 里的 error 事件通常不会暴露响应体、状态码和底层错误细节。
所以不要写成这样:
js
socket.onerror = () => {
reconnect();
};问题在于:很多失败会紧接着触发 close。如果 error 里重连,close 里也重连,就会出现重复连接、状态跳动、多个 socket 同时存在。
更推荐:
js
socket.onerror = () => {
latestError = "连接发生错误,请等待关闭事件确认状态";
};
socket.onclose = () => {
showReconnectTip();
};close 事件什么时候触发
close 表示连接已经关闭。它可能由很多原因触发:
- 前端调用
socket.close(); - 服务端主动关闭;
- 网络中断后浏览器确认连接不可用;
- 握手失败,连接没有真正建立;
- 页面刷新或组件卸载时关闭连接;
- 服务端异常退出。
close 事件里最重要的是这几个字段:
| 字段 | 含义 | 常见用途 |
|---|---|---|
code | 关闭码 | 判断正常关闭还是异常关闭 |
reason | 关闭原因 | 服务端主动传递的说明 |
wasClean | 是否干净关闭 | 粗略判断是否正常完成关闭流程 |
常见关闭码:
| code | 含义 | 前端处理建议 |
|---|---|---|
1000 | 正常关闭 | 如果是用户主动关闭,不提示重连 |
1001 | 端点离开,比如页面跳转或服务重启 | 可提示重连 |
1006 | 异常关闭,不能由代码主动发送 | 常见于网络断开、握手失败 |
1008 | 策略违规,比如鉴权失败 | 不要盲目自动重连,优先提示登录或权限问题 |
1011 | 服务端内部错误 | 可提示稍后重试 |
注意:1006 是浏览器报告出来的异常关闭状态,不能通过 socket.close(1006) 主动发送。
连接失败时,提示重连的核心逻辑
推荐把连接结束统一分成两类:
| 类型 | 判断方式 | UI 行为 |
|---|---|---|
| 主动关闭 | 用户点断开、组件卸载、业务主动调用 close() | 不提示失败,不自动重连 |
| 非主动关闭 | manualClose 为 false 的 close | 提示连接断开,可点击重连 |
伪代码:
js
socket.onerror = () => {
status = "error";
};
socket.onclose = (event) => {
socket = null;
if (manualClose) {
status = "closed";
showReconnect = false;
return;
}
status = "disconnected";
showReconnect = true;
closeInfo = event;
};这里有两个关键点:
error不直接弹多次提示,只记录“可能失败”。close是最终收口点,只要不是主动关闭,就展示重连提示。
Vue3 组件封装示例
下面是一个可直接放进项目里的 Vue3 组件示例。它做了这些事:
- 组件挂载后自动连接;
- 连接成功后清空失败提示;
- 发送消息前检查
readyState; error只记录错误,不直接重连;close统一判断是否提示重连;- 非主动断开后,最多自动重连 3 次;
- 页面始终保留“立即重连”按钮,用户不用等倒计时;
- 组件卸载时主动关闭连接,避免内存泄漏。
vue
<template>
<section class="ws-panel">
<header class="ws-panel__header">
<div>
<p class="ws-panel__label">WebSocket 状态</p>
<strong>{{ statusText }}</strong>
</div>
<button type="button" @click="closeSocket" :disabled="!socket">
断开
</button>
</header>
<div v-if="showReconnect" class="ws-panel__tip">
<p>{{ reconnectTip }}</p>
<button type="button" @click="reconnectNow">立即重连</button>
</div>
<form class="ws-panel__form" @submit.prevent="sendMessage">
<input v-model="inputText" placeholder="输入要发送的消息" />
<button type="submit" :disabled="status !== 'open'">发送</button>
</form>
<ul class="ws-panel__messages">
<li v-for="item in messages" :key="item.id">
{{ item.text }}
</li>
</ul>
</section>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
const props = defineProps({
url: {
type: String,
required: true,
},
});
const emit = defineEmits(["open", "message", "close", "error"]);
const socket = ref(null);
const status = ref("idle");
const inputText = ref("");
const messages = ref([]);
const showReconnect = ref(false);
const reconnectCount = ref(0);
const reconnectTimer = ref(null);
const reconnectDelay = ref(0);
const latestCloseEvent = ref(null);
const latestError = ref("");
const maxReconnectCount = 3;
let manualClose = false;
let messageId = 0;
const statusText = computed(() => {
const textMap = {
idle: "未连接",
connecting: "连接中",
open: "已连接",
error: "连接异常",
disconnected: "已断开",
closed: "已关闭",
};
return textMap[status.value] || "未知状态";
});
const reconnectTip = computed(() => {
if (reconnectCount.value >= maxReconnectCount) {
return "连接失败,自动重连已停止,请检查网络后手动重连。";
}
if (reconnectDelay.value > 0) {
return `连接已断开,将在 ${reconnectDelay.value} 秒后尝试第 ${reconnectCount.value + 1} 次重连。`;
}
return "连接已断开,可以手动重连。";
});
/**
* 清理旧 socket 和重连定时器,避免重复连接叠在一起。
*/
function cleanupSocket() {
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
reconnectTimer.value = null;
}
if (socket.value) {
socket.value.onopen = null;
socket.value.onmessage = null;
socket.value.onerror = null;
socket.value.onclose = null;
socket.value = null;
}
}
/**
* 创建 WebSocket 连接。连接失败不在 error 里重连,统一等 close 收口。
*/
function connect() {
cleanupSocket();
manualClose = false;
status.value = "connecting";
showReconnect.value = false;
latestError.value = "";
latestCloseEvent.value = null;
const ws = new WebSocket(props.url);
socket.value = ws;
ws.onopen = (event) => {
status.value = "open";
reconnectCount.value = 0;
reconnectDelay.value = 0;
showReconnect.value = false;
emit("open", event);
};
ws.onmessage = (event) => {
messages.value.unshift({
id: ++messageId,
text: event.data,
});
emit("message", event.data);
};
ws.onerror = (event) => {
// error 经常缺少细节,这里只记录状态;真正是否重连交给 close 判断。
latestError.value = "WebSocket 连接发生错误";
status.value = "error";
emit("error", event);
};
ws.onclose = (event) => {
socket.value = null;
latestCloseEvent.value = event;
emit("close", event);
if (manualClose) {
status.value = "closed";
showReconnect.value = false;
return;
}
status.value = "disconnected";
showReconnect.value = true;
scheduleReconnect();
};
}
/**
* 非主动断开后进入有限重连;失败提示一直展示,用户可直接点“立即重连”。
*/
function scheduleReconnect() {
if (reconnectCount.value >= maxReconnectCount) {
reconnectDelay.value = 0;
return;
}
reconnectDelay.value = Math.min(2 ** reconnectCount.value, 8);
reconnectTimer.value = setTimeout(() => {
reconnectCount.value += 1;
connect();
}, reconnectDelay.value * 1000);
}
/**
* 用户手动重连时重置次数,避免被上一次自动重连上限卡住。
*/
function reconnectNow() {
reconnectCount.value = 0;
reconnectDelay.value = 0;
connect();
}
/**
* 发送前必须检查 readyState,避免连接中或已关闭时直接 send 抛错。
*/
function sendMessage() {
const text = inputText.value.trim();
if (!text) {
return;
}
if (!socket.value || socket.value.readyState !== WebSocket.OPEN) {
showReconnect.value = true;
latestError.value = "当前未连接,无法发送消息";
return;
}
socket.value.send(text);
inputText.value = "";
}
/**
* 用户主动断开不属于失败,不展示重连提示,也不触发自动重连。
*/
function closeSocket() {
manualClose = true;
if (reconnectTimer.value) {
clearTimeout(reconnectTimer.value);
reconnectTimer.value = null;
}
if (socket.value) {
socket.value.close(1000, "用户主动断开");
}
}
onMounted(() => {
connect();
});
onBeforeUnmount(() => {
manualClose = true;
closeSocket();
cleanupSocket();
});
</script>
<style scoped>
.ws-panel {
display: grid;
gap: 16px;
max-width: 680px;
padding: 20px;
border: 1px solid #d8dee4;
border-radius: 16px;
background: #ffffff;
}
.ws-panel__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
}
.ws-panel__label {
margin: 0 0 4px;
color: #667085;
font-size: 14px;
}
.ws-panel__tip {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
border-radius: 12px;
background: #fff7ed;
color: #9a3412;
}
.ws-panel__tip p {
margin: 0;
}
.ws-panel__form {
display: flex;
gap: 10px;
}
.ws-panel__form input {
flex: 1;
min-width: 0;
padding: 8px 10px;
border: 1px solid #d8dee4;
border-radius: 8px;
}
.ws-panel button {
padding: 8px 12px;
border: 0;
border-radius: 8px;
background: #111827;
color: #ffffff;
cursor: pointer;
}
.ws-panel button:disabled {
cursor: not-allowed;
opacity: 0.5;
}
.ws-panel__messages {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
list-style: none;
}
.ws-panel__messages li {
padding: 10px 12px;
border-radius: 10px;
background: #f6f8fa;
}
</style>使用方式:
vue
<template>
<WebSocketPanel
url="wss://example.com/ws"
@message="handleMessage"
@close="handleClose"
/>
</template>
<script setup>
import WebSocketPanel from "./WebSocketPanel.vue";
function handleMessage(data) {
console.log("业务收到消息:", data);
}
function handleClose(event) {
console.log("连接关闭:", event.code, event.reason);
}
</script>为什么重连放在 close,不放在 error
连接失败时,浏览器常见事件顺序是:
text
new WebSocket(url)
-> error
-> close如果你在 error 里已经执行重连,随后 close 又执行一次重连,就会产生两个问题:
- 重连次数被错误累计;
- 页面可能同时存在多个正在连接的 socket。
所以本文组件采用这个策略:
js
ws.onerror = () => {
status.value = "error";
};
ws.onclose = () => {
if (!manualClose) {
showReconnect.value = true;
scheduleReconnect();
}
};这能保证所有失败最终都走同一个出口。
连接失败时怎么提示用户
提示逻辑建议分三层:
- 立即告诉用户“连接断开”。
- 如果还有自动重连次数,展示倒计时和第几次重连。
- 自动重连次数耗尽后,停止自动动作,保留“立即重连”按钮。
原因是 WebSocket 失败不一定是短暂网络抖动,也可能是鉴权失效、服务端拒绝、地址配置错误。无限自动重连只会制造更多请求和更差体验。
更细的业务处理可以按关闭码拆:
js
function getCloseTip(event) {
if (event.code === 1008) {
return "连接被拒绝,请检查登录状态或权限。";
}
if (event.code === 1011) {
return "服务端异常,请稍后重试。";
}
if (event.code === 1006) {
return "连接异常断开,请检查网络后重连。";
}
return "连接已断开,可以重新连接。";
}如果服务端会通过 reason 返回业务原因,也可以一起展示。但不要完全信任它,因为不同浏览器、代理和服务端实现可能导致 reason 为空。
实战注意点
1. 发送前检查 readyState
send() 只能在 OPEN 状态调用。
js
if (socket.readyState === WebSocket.OPEN) {
socket.send("hello");
}状态值:
| 状态 | 值 | 含义 |
|---|---|---|
CONNECTING | 0 | 正在连接 |
OPEN | 1 | 已连接,可以发送 |
CLOSING | 2 | 正在关闭 |
CLOSED | 3 | 已关闭 |
2. 鉴权失败不要无限重连
如果 token 过期,服务端可能拒绝握手,也可能握手成功后立刻关闭。前端不一定能从 error 里拿到 401。
更稳的做法是:服务端在关闭时使用明确的业务关闭码或 reason,前端在 close 里识别后跳登录或刷新 token。
3. 页面卸载要主动关闭
组件卸载时不关闭 socket,会留下连接、回调和重连定时器。
js
onBeforeUnmount(() => {
manualClose = true;
closeSocket();
cleanupSocket();
});4. 重连要有上限和退避
不要每隔 100ms 无限重连。推荐指数退避:
js
const delay = Math.min(2 ** retryCount, 30);这样第 1、2、3、4 次大约是 1s、2s、4s、8s,后面封顶,避免服务端雪崩。
5. 心跳不等于重连
心跳负责发现“看起来没断,但实际上不可用”的连接;重连负责连接断开后的恢复。两者可以配合,但不要混为一谈。
典型策略:
- 定时发送
ping; - 一段时间没收到
pong,主动close(); - 让
close事件进入统一重连流程。
最后给一个判断清单
写 WebSocket 连接时,可以按这个清单检查:
- 是否只在
open后发送消息? - 是否在
send前判断readyState === WebSocket.OPEN? - 是否区分用户主动关闭和异常断开?
- 是否避免
error和close同时触发导致重复重连? - 是否在
close里统一提示重连? - 是否给自动重连设置次数上限?
- 是否在组件卸载时清理 socket 和 timer?
- 是否对鉴权失败、服务端异常、异常断开给出不同提示?
如果这些都处理了,WebSocket 组件基本就能应付大多数业务场景。