Skip to content

WebSocket 连接、error / close 事件和 Vue3 重连组件示例

这篇文章解决什么问题

WebSocket 的 API 很少,真正容易写错的不是“怎么连上”,而是下面几个细节:

  • errorclose 都可能触发,应该在哪个事件里提示用户?
  • 连接失败时,为什么拿不到 HTTP 状态码和详细报错?
  • 服务端主动断开、网络断开、浏览器主动关闭,前端怎么区分?
  • 失败后要不要自动重连?如果提示用户重连,状态怎么收口?
  • Vue3 组件里怎么封装连接、发送、断开、重连?

本文用原生 WebSocket 写一个基础 JS 示例,再封装一个 Vue3 组件,重点说明 error / close 的触发时机和连接失败后的重连提示逻辑。

先说结论

WebSocket 事件处理建议记住一句话:

error 只记录“出错了”,最终状态统一交给 close 收口。

原因是:

  • error 事件信息很少,浏览器出于安全原因不会把握手失败的 HTTP 状态码、响应体、底层网络错误完整暴露给 JS。
  • 很多失败场景会先触发 error,随后再触发 close
  • close 事件里有 codereasonwasClean,更适合作为“连接已结束”的统一入口。
  • 用户主动关闭、服务端正常关闭通常只触发 close,不一定触发 error

所以更稳的写法是:

  1. open:标记连接成功,清掉失败提示。
  2. message:处理服务端消息。
  3. error:记录错误状态,但不要在这里重复重连。
  4. 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 有一个关键限制:

前端通常拿不到具体失败原因。

例如服务端返回 401403500,或者握手没有返回 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()不提示失败,不自动重连
非主动关闭manualClosefalseclose提示连接断开,可点击重连

伪代码:

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();
  }
};

这能保证所有失败最终都走同一个出口。

连接失败时怎么提示用户

提示逻辑建议分三层:

  1. 立即告诉用户“连接断开”。
  2. 如果还有自动重连次数,展示倒计时和第几次重连。
  3. 自动重连次数耗尽后,停止自动动作,保留“立即重连”按钮。

原因是 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");
}

状态值:

状态含义
CONNECTING0正在连接
OPEN1已连接,可以发送
CLOSING2正在关闭
CLOSED3已关闭

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
  • 是否区分用户主动关闭和异常断开?
  • 是否避免 errorclose 同时触发导致重复重连?
  • 是否在 close 里统一提示重连?
  • 是否给自动重连设置次数上限?
  • 是否在组件卸载时清理 socket 和 timer?
  • 是否对鉴权失败、服务端异常、异常断开给出不同提示?

如果这些都处理了,WebSocket 组件基本就能应付大多数业务场景。

基于 VitePress 的个人知识库骨架