24 WebSocket_异常处理与断线检测_两课时
WebSocket 异常处理与断线检测
关联:索引
要解决的问题
- 为什么“Demo 能连上”,一断网/切后台/服务端重启就变成“假在线”(UI 不变、按钮还能点、消息默默丢)
- close 事件一定可靠么:哪些断线场景不会立刻触发 close,为什么需要断线检测(心跳/超时)
- error 到底能拿到什么:浏览器 WebSocket.onerror 为什么信息少,工程上应该如何记录“可用证据链”
- 自动重连怎么做才不炸:什么时候该重连/不该重连,间隔怎么设计(退避 + 抖动 + 上限)
- 连接状态怎么管:如何把 readyState + 业务状态(重连中/离线/手动关闭)统一成一套可驱动 UI 的状态机
章节内容(本讲核心):
- 连接断开识别:close 事件、close code/reason、网络 offline/online、心跳超时(断线检测)
- error 捕获:onerror 的工程化处理(证据链、关联 close、避免误用)
- 重连机制设计:触发条件、停止条件、手动关闭与自动重连的区分、消息队列策略(可选)
- 重连间隔与退避策略:指数退避、抖动(jitter)、最大间隔、最大次数、离线等待
与前置知识衔接(避免重复):
- 已学:WebSocket 生命周期事件(open/message/error/close)、readyState 状态机、统一 JSON Envelope、最简心跳、useWebSocket 封装、Vue3 + FastAPI 联调
- 本讲不重复:握手/协议基础、Envelope 字段逐项解释、基础收发与渲染
- 本讲新增:断线检测(心跳超时)、自动重连策略(退避 + 抖动)、连接状态机升级(区分“离线/重连/手动关闭”)
项目工坊(本讲交付):
-
开发“自动重连 + 状态提示”模块:以组合式函数为边界(useReconnectingWebSocket),组件只负责展示与调用
-
工程目录:
11_websocket_reconnect/ -
前端:
11_websocket_reconnect/client/(Vue 3 + Vite + TypeScript) -
后端:
11_websocket_reconnect/server/(FastAPI WebSocket)
学生任务(当堂必做):
-
集成重连机制到既有 WebSocket 页面
-
模拟断网/服务端重启测试:能观察状态变化与重连过程,恢复后能继续收发
-
AI:生成“工业级重连策略”代码(退避 + 抖动 + 停止条件 + 离线等待 + 心跳超时)
-
学生:把策略落地到项目并做断网测试,对运行结果负责
作业:
一、断线识别:你需要的不是“一个事件”,而是“证据链”
1) close:最直接,但不一定“立刻发生”
-
close 触发条件:服务端主动断开、客户端 close、网络变化导致连接最终关闭
-
close 的关键字段:
code、reason、wasClean -
常见误区:以为“断网一定立即触发 close”。现实中可能出现“长时间无消息、连接看似还在”的假在线
-
1000:正常关闭(通常是手动 close 或正常退出) -
1006:异常关闭(浏览器侧常见,表示未收到规范的 close frame;断网/崩溃等经常映射到它;注意:应用代码不能用ws.close(1006)主动发出该 code) -
1011:服务端内部错误(服务端明确发送时才会出现) -
记录
code/reason/wasClean,并把它显示到 UI(“可见”是排错前提) -
区分“手动关闭”与“异常断开”:手动关闭不应触发自动重连
2) online/offline:网络层信号,但它不是“连接是否活着”
-
window.onoffline:表示浏览器判断网络断开(WiFi 断开/网线拔掉/系统离线) -
window.ononline:表示网络恢复 -
注意:
online并不代表 WebSocket 已恢复;它只是“允许你尝试重连”的信号 -
offline:立刻把状态标为 OFFLINE,并停止重连计时器(不做无意义的狂重连)
-
online:触发一次“立即重连尝试”
3) 心跳超时:解决“假在线”的关键
核心思想:
- 每隔 N 秒发送一次轻量 ping(应用层心跳),服务端回 pong
- 如果连续超过 timeout 没有收到 pong(或任何消息),判定连接不可用:主动 close 并进入重连流程
这一步的价值:
- close 不及时 → 仍能在可控时间内识别断线
- 断线检测 + 自动重连结合后,用户体验从“卡死”变为“可恢复”
二、error 捕获:不要指望 onerror 给你“错误原因”
浏览器的 WebSocket.onerror 特点:
-
通常拿不到具体错误细节(安全原因)
-
许多情况下 onerror 之后会紧跟 onclose
-
error 只负责:记录“发生过错误”这个事实,更新时间戳,提示用户
-
诊断信息主要来自:Network-WS(握手/Frames)、close code/reason、你自己的日志(状态、重连次数、最近一次成功消息时间)
三、重连机制设计:先定义“什么时候重连/不重连”
- 触发重连:
- close 且非手动关闭
- 心跳超时判定断线
- online 事件触发(网络恢复)
- 停止重连:
- 学生点击“手动断开/退出页面”
- 达到最大重试次数(若设置)
- 进入 OFFLINE 且网络未恢复(等待 online 再继续)
四、重连间隔与退避策略:指数退避 + 抖动(jitter)+ 上限
为什么要退避:
-
断网或服务端崩溃时,快速重连会造成客户端 CPU/日志噪声暴涨,也会给服务端造成雪崩压力
-
基础间隔:
baseDelayMs = 500 -
指数因子:
backoffFactor = 1.8 -
最大间隔:
maxDelayMs = 15_000 -
抖动比例:
jitterRatio = 0.2(让不同客户端错峰重连)
示例:第 n 次重连的延迟(未加抖动):
- delay(n) = min(maxDelay, baseDelay * backoffFactor^(n-1))
加入抖动后:
-
delay = delay(n) * random(1 - jitterRatio, 1 + jitterRatio)
-
连接状态:IDLE / CONNECTING / OPEN / RECONNECTING / OFFLINE / CLOSED / ERROR
-
重连次数 attempt
-
最近一次 close:code/reason/wasClean(若有)
-
最近一次收到消息的时间戳(用于观察“假在线”)
二、实现:useReconnectingWebSocket(组合式函数)
11_websocket_reconnect/client/src/composables/useReconnectingWebSocket.ts
import { computed, onBeforeUnmount, ref } from 'vue'
import { buildMessage } from '../utils/ws'
import type { Message, MsgType } from '../utils/ws'
export type WsConnState =
| 'IDLE'
| 'CONNECTING'
| 'OPEN'
| 'RECONNECTING'
| 'OFFLINE'
| 'CLOSED'
| 'ERROR'
export type ReconnectPolicy = {
baseDelayMs: number
maxDelayMs: number
backoffFactor: number
jitterRatio: number
maxRetries: number | 'infinite'
}
export type HeartbeatOptions = {
enabled: boolean
intervalMs: number
timeoutMs: number
}
export type UseReconnectingWebSocketOptions = {
url: string | (() => string)
protocols?: string | string[]
autoConnect?: boolean
policy?: Partial<ReconnectPolicy>
heartbeat?: Partial<HeartbeatOptions>
logLimit?: number
}
export type CloseSnapshot = { code: number; reason: string; wasClean: boolean; at: number }
function clamp(n: number, min: number, max: number): number {
return Math.min(max, Math.max(min, n))
}
function computeDelayMs(attempt: number, p: ReconnectPolicy): number {
const n = Math.max(1, attempt)
const pure = Math.min(p.maxDelayMs, p.baseDelayMs * Math.pow(p.backoffFactor, n - 1))
const jr = clamp(p.jitterRatio, 0, 1)
const factor = 1 - jr + Math.random() * (2 * jr)
return Math.max(0, Math.round(pure * factor))
}
function safeJsonParse(text: string): unknown {
try {
return JSON.parse(text) as unknown
} catch {
return text
}
}
export function useReconnectingWebSocket(options: UseReconnectingWebSocketOptions) {
const policy: ReconnectPolicy = {
baseDelayMs: 500,
maxDelayMs: 15_000,
backoffFactor: 1.8,
jitterRatio: 0.2,
maxRetries: 'infinite',
...options.policy
}
const heartbeat: HeartbeatOptions = {
enabled: true,
intervalMs: 10_000,
timeoutMs: 25_000,
...options.heartbeat
}
const logLimit = options.logLimit ?? 200
const state = ref<WsConnState>('IDLE')
const attempt = ref(0)
const lastErrorAt = ref<number | null>(null)
const lastClose = ref<CloseSnapshot | null>(null)
const lastMessageAt = ref<number | null>(null)
const logs = ref<string[]>([])
const manualClose = ref(false)
const wsRef = ref<WebSocket | null>(null)
let reconnectTimer: number | null = null
let heartbeatTimer: number | null = null
const isOnline = ref<boolean>(navigator.onLine)
const canSend = computed(() => wsRef.value?.readyState === WebSocket.OPEN)
function pushLog(line: string) {
logs.value.push(line)
if (logs.value.length > logLimit) {
logs.value.splice(0, logs.value.length - logLimit)
}
}
function clearTimers() {
if (reconnectTimer !== null) {
window.clearTimeout(reconnectTimer)
reconnectTimer = null
}
if (heartbeatTimer !== null) {
window.clearInterval(heartbeatTimer)
heartbeatTimer = null
}
}
function markMessageArrived() {
lastMessageAt.value = Date.now()
}
function closeNow(code?: number, reason?: string) {
const ws = wsRef.value
if (!ws) return
try {
ws.close(code, reason)
} catch {
ws.close()
}
}
function shouldRetry(nextAttempt: number) {
return policy.maxRetries === 'infinite' ? true : nextAttempt <= policy.maxRetries
}
function scheduleReconnect() {
if (manualClose.value) return
if (!isOnline.value) {
state.value = 'OFFLINE'
pushLog(`[offline] waiting for online`)
return
}
const nextAttempt = attempt.value + 1
if (!shouldRetry(nextAttempt)) {
state.value = 'CLOSED'
pushLog(`[reconnect] give up: maxRetries reached`)
return
}
attempt.value = nextAttempt
state.value = 'RECONNECTING'
const delay = computeDelayMs(nextAttempt, policy)
pushLog(`[reconnect] attempt=${nextAttempt} delayMs=${delay}`)
reconnectTimer = window.setTimeout(() => {
connectInternal()
}, delay)
}
function startHeartbeat() {
if (!heartbeat.enabled) return
if (heartbeatTimer !== null) window.clearInterval(heartbeatTimer)
heartbeatTimer = window.setInterval(() => {
const ws = wsRef.value
if (!ws || ws.readyState !== WebSocket.OPEN) return
const now = Date.now()
const last = lastMessageAt.value
if (last && now - last > heartbeat.timeoutMs) {
pushLog(`[heartbeat] timeoutMs=${heartbeat.timeoutMs}`)
closeNow(4000, 'heartbeat timeout')
return
}
try {
const ping = buildMessage('ping', { ts: now })
ws.send(JSON.stringify(ping))
pushLog(`[heartbeat] ping`)
} catch {
closeNow(4001, 'heartbeat send failed')
}
}, heartbeat.intervalMs)
}
function connectInternal() {
clearTimers()
manualClose.value = false
const existing = wsRef.value
if (existing && existing.readyState !== WebSocket.CLOSED) {
state.value =
existing.readyState === WebSocket.OPEN
? 'OPEN'
: existing.readyState === WebSocket.CONNECTING
? 'CONNECTING'
: 'RECONNECTING'
return
}
state.value = attempt.value > 0 ? 'RECONNECTING' : 'CONNECTING'
const url = typeof options.url === 'function' ? options.url() : options.url
const ws = new WebSocket(url, options.protocols)
wsRef.value = ws
pushLog(`[connect] url=${url}`)
ws.onopen = () => {
state.value = 'OPEN'
attempt.value = 0
lastClose.value = null
markMessageArrived()
pushLog(`[open]`)
startHeartbeat()
}
ws.onmessage = (e) => {
markMessageArrived()
const raw = typeof e.data === 'string' ? e.data : String(e.data)
const parsed = typeof e.data === 'string' ? safeJsonParse(e.data) : raw
pushLog(`[message] ${typeof parsed === 'string' ? parsed : JSON.stringify(parsed)}`)
}
ws.onerror = () => {
lastErrorAt.value = Date.now()
state.value = 'ERROR'
pushLog(`[error]`)
}
ws.onclose = (e) => {
clearTimers()
wsRef.value = null
lastClose.value = { code: e.code, reason: e.reason, wasClean: e.wasClean, at: Date.now() }
pushLog(`[close] code=${e.code} reason=${e.reason} wasClean=${e.wasClean}`)
if (manualClose.value) {
state.value = 'CLOSED'
return
}
scheduleReconnect()
}
}
function connect() {
if (state.value === 'OPEN' || state.value === 'CONNECTING' || state.value === 'RECONNECTING') return
connectInternal()
}
function disconnect() {
manualClose.value = true
clearTimers()
closeNow(1000, 'manual close')
wsRef.value = null
state.value = 'CLOSED'
}
function sendText(text: string): boolean {
const ws = wsRef.value
if (!ws || ws.readyState !== WebSocket.OPEN) return false
ws.send(text)
pushLog(`[send] ${text}`)
return true
}
function sendEnvelope<TPayload>(type: MsgType, payload: TPayload): boolean {
const m: Message<TPayload> = buildMessage(type, payload)
return sendText(JSON.stringify(m))
}
function onOnline() {
isOnline.value = true
pushLog(`[network] online`)
if (!manualClose.value && (state.value === 'OFFLINE' || state.value === 'CLOSED' || state.value === 'ERROR')) {
attempt.value = 0
connectInternal()
}
}
function onOffline() {
isOnline.value = false
pushLog(`[network] offline`)
clearTimers()
if (!manualClose.value) {
state.value = 'OFFLINE'
closeNow(1001, 'offline')
}
}
window.addEventListener('online', onOnline)
window.addEventListener('offline', onOffline)
onBeforeUnmount(() => {
window.removeEventListener('online', onOnline)
window.removeEventListener('offline', onOffline)
disconnect()
})
if (options.autoConnect ?? true) connect()
return {
state,
attempt,
lastErrorAt,
lastClose,
lastMessageAt,
isOnline,
canSend,
logs,
connect,
disconnect,
sendText,
sendEnvelope
}
}
逐段解释与自检要点:
-
WsConnState:不是直接用 readyState,而是把“离线/重连/错误/手动关闭”也纳入状态机,UI 才能讲清楚发生了什么。 -
computeDelayMs:实现指数退避 + 抖动;attempt从 1 开始计算,延迟会逐步增大并受maxDelayMs限制。 -
manualClose:区分“用户主动断开”与“异常断开”;只有异常断开才scheduleReconnect()。 -
isOnline:用navigator.onLine初始化,用 online/offline 事件更新;offline 时不重连,避免无意义的循环。 -
startHeartbeat:用“超时阈值”解决假在线;这里用lastMessageAt作为“最近活跃”证据(收到任何消息都算活跃)。 -
ws.onerror:只记录时间戳并把 state 标为 ERROR;真正进入重连的是ws.onclose(更可靠)。 -
disconnect:主动关闭会设置manualClose=true,并把 state 设为 CLOSED;防止 close 事件触发重连。 -
connectInternal的 existing guard:避免“旧连接尚未完全 CLOSED 时又创建新连接”造成的重复连接/状态混乱(常见于网络抖动或多处触发 connect)。 -
sendEnvelope:组件侧不直接手拼 JSON,而是用统一 Envelope 构造后发送,避免字段不一致。 -
服务端至少能处理
type=ping并回复type=pong或其他响应,确保“心跳=有来有回”。 -
如果服务端不回任何消息,
lastMessageAt可能只在 onopen 时更新,心跳超时会更容易触发(这是符合预期的断线检测行为)。
三、服务端增量:最简 pong(FastAPI)
11_websocket_reconnect/server/app.py
import json
import time
from typing import Any
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
app = FastAPI()
def now_ms() -> int:
return int(time.time() * 1000)
def make_msg(msg_type: str, payload: Any, msg_id: str | None = None) -> dict[str, Any]:
return {"type": msg_type, "id": msg_id or str(now_ms()), "ts": now_ms(), "payload": payload}
def try_parse_json(text: str) -> dict[str, Any] | None:
try:
data = json.loads(text)
except Exception:
return None
return data if isinstance(data, dict) else None
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket) -> None:
await ws.accept()
await ws.send_text(json.dumps(make_msg("system", {"info": "connected"}), ensure_ascii=False))
try:
while True:
text = await ws.receive_text()
data = try_parse_json(text)
if not data:
await ws.send_text(text)
continue
t = data.get("type")
if t == "ping":
await ws.send_text(json.dumps(make_msg("pong", {}), ensure_ascii=False))
continue
if t == "chat":
payload = data.get("payload")
await ws.send_text(json.dumps(make_msg("chat", {"echo": payload}), ensure_ascii=False))
continue
await ws.send_text(json.dumps(make_msg("system", {"ok": True}), ensure_ascii=False))
except WebSocketDisconnect:
return
逐段解释与自检要点:
await ws.accept():必须先 accept 才能开始收发,否则客户端会表现为“连不上/秒断”。json.loads:解析失败就降级回显文本,避免因为“非 JSON 消息”导致连接循环直接异常退出。type == "ping":回pong,用于客户端断线检测与重连触发。
四、组件集成:状态提示面板(示例)
11_websocket_reconnect/client/src/components/ReconnectPanel.vue
<template>
<section class="panel">
<header class="panel__header">
<h2 class="panel__title">WebSocket 自动重连 + 状态提示</h2>
<div class="panel__meta">
<span class="badge" :data-state="state">{{ state }}</span>
<span class="kv">online: {{ isOnline }}</span>
<span class="kv">attempt: {{ attempt }}</span>
</div>
</header>
<div class="grid">
<div class="card">
<h3 class="card__title">连接信息</h3>
<div class="kv">canSend: {{ canSend }}</div>
<div class="kv">lastMessageAt: {{ lastMessageAtText }}</div>
<div class="kv">lastErrorAt: {{ lastErrorAtText }}</div>
<div class="kv" v-if="lastClose">
lastClose: code={{ lastClose.code }} wasClean={{ lastClose.wasClean }} reason={{ lastClose.reason || '-' }}
</div>
</div>
<div class="card">
<h3 class="card__title">操作</h3>
<div class="row">
<button type="button" class="btn" @click="connect">连接</button>
<button type="button" class="btn" @click="disconnect">断开</button>
<button type="button" class="btn" @click="sendPing">ping</button>
</div>
<div class="row">
<input v-model="msg" class="input" type="text" placeholder="输入 chat 文本" />
<button type="button" class="btn btn--primary" @click="sendChat">发送</button>
</div>
<div class="hint">建议测试:停止服务端 / DevTools 切 Offline / 再恢复,看状态与日志变化。</div>
</div>
</div>
<div class="card">
<h3 class="card__title">日志(最近 {{ logLimit }} 条)</h3>
<pre class="logs">{{ logs.join('\n') }}</pre>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useReconnectingWebSocket } from '../composables/useReconnectingWebSocket'
const logLimit = 200
const msg = ref('')
const {
state,
attempt,
lastClose,
lastErrorAt,
lastMessageAt,
isOnline,
canSend,
logs,
connect,
disconnect,
sendEnvelope
} = useReconnectingWebSocket({
url: 'ws://localhost:8000/ws',
autoConnect: true,
logLimit,
policy: { baseDelayMs: 500, maxDelayMs: 15_000, backoffFactor: 1.8, jitterRatio: 0.2, maxRetries: 'infinite' },
heartbeat: { enabled: true, intervalMs: 10_000, timeoutMs: 25_000 }
})
function toTimeText(ms: number | null) {
return ms ? new Date(ms).toLocaleTimeString() : '-'
}
const lastMessageAtText = computed(() => toTimeText(lastMessageAt.value))
const lastErrorAtText = computed(() => toTimeText(lastErrorAt.value))
function sendChat() {
const text = msg.value.trim()
if (!text) return
if (sendEnvelope('chat', { text })) msg.value = ''
}
function sendPing() {
sendEnvelope('ping', { ts: Date.now() })
}
</script>
逐段解释与自检要点:
- 发送使用
sendEnvelope('chat'|'ping', payload),保证与服务端type分支对齐。 - 日志面板直接用
logs.join('\n')输出:停止服务端、切 Offline、恢复网络等操作都能看到“证据链”变化。
1) 模拟断网(推荐:浏览器 DevTools)
操作:
- 打开 DevTools(F12)
- Network 面板 → 找到 Offline(或 Throttling)并切换到 Offline
- 观察页面状态变化
预期:
isOnline变为 false,state 变为 OFFLINE- 断网期间不会疯狂 attempt 增长(因为 offline 会暂停重连)
2) 模拟服务端崩溃/重启
操作:
- 直接停止 FastAPI 进程,再启动
预期:
- 客户端 close 后进入 RECONNECTING(attempt 递增且间隔逐步变长)
- 服务端恢复后自动连接成功,state 回到 OPEN
3) 模拟“假在线”(验证心跳价值)
操作(两选一即可):
- 服务端不回 pong(临时注释 ping 分支),观察客户端在 timeout 后主动 close 并重连
预期:
- 超过
timeoutMs后触发 close(4000/4001),状态进入重连
把下面提示词给 AI(粘贴你项目的约束与目标),并要求输出“可直接粘贴运行”的 TypeScript 代码:
你是资深前端工程师。请为 Vue3 + TypeScript 项目实现一个 useReconnectingWebSocket 组合式函数,要求:
1) 暴露状态:IDLE/CONNECTING/OPEN/RECONNECTING/OFFLINE/CLOSED/ERROR,能驱动 UI。
2) 断线检测:心跳 ping/pong + 超时阈值(timeoutMs);超时后主动 close 并触发重连。
3) 重连策略:指数退避 + 抖动(jitter)+ 最大间隔;支持 maxRetries 上限或 infinite。
4) 区分手动断开与异常断开:手动断开不重连;异常断开才重连。
5) 支持浏览器 online/offline:offline 时暂停重连,online 时立即尝试重连。
6) 不引入任何第三方库;只使用 vue 的 ref/computed/onBeforeUnmount。
7) 给出可运行示例组件(状态面板),并解释每个关键设计点与潜在坑。
输出:完整 TypeScript + Vue SFC 示例代码,保证语法正确。
- 状态是否完整覆盖:有没有 OFFLINE / RECONNECTING 区分
- onerror 是否“误当成重连触发”:更可靠的是 onclose 触发重连
- 退避是否有上限与抖动:避免多个客户端同一时刻重连
- 是否能停止:手动关闭是否会继续重连(不能)
Markdown 与代码自检(已执行)
- 标题层级:从
#→##→###→####逐级使用,无跳级 - 代码块:均使用 fenced code block,语言标签为
ts/python/vue/text - 策略公式与参数:给出默认值与解释,能对照验证(baseDelay/backoff/jitter/maxDelay/timeout)
- TypeScript 语法:类型联合、Partial、computed/ref、事件监听与清理逻辑闭合