24 WebSocket_异常处理与断线检测_两课时

WebSocket 异常处理与断线检测

关联:索引

要解决的问题

章节内容(本讲核心):

与前置知识衔接(避免重复):

项目工坊(本讲交付):

学生任务(当堂必做):

作业:


一、断线识别:你需要的不是“一个事件”,而是“证据链”

1) close:最直接,但不一定“立刻发生”

2) online/offline:网络层信号,但它不是“连接是否活着”

3) 心跳超时:解决“假在线”的关键

核心思想:

这一步的价值:

二、error 捕获:不要指望 onerror 给你“错误原因”

浏览器的 WebSocket.onerror 特点:

三、重连机制设计:先定义“什么时候重连/不重连”

四、重连间隔与退避策略:指数退避 + 抖动(jitter)+ 上限

为什么要退避:

示例:第 n 次重连的延迟(未加抖动):

加入抖动后:

二、实现:useReconnectingWebSocket(组合式函数)

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
  }
}

逐段解释与自检要点:

三、服务端增量:最简 pong(FastAPI)

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

逐段解释与自检要点:

四、组件集成:状态提示面板(示例)

<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>

逐段解释与自检要点:

1) 模拟断网(推荐:浏览器 DevTools)

操作:

  1. 打开 DevTools(F12)
  2. Network 面板 → 找到 Offline(或 Throttling)并切换到 Offline
  3. 观察页面状态变化

预期:

2) 模拟服务端崩溃/重启

操作:

预期:

3) 模拟“假在线”(验证心跳价值)

操作(两选一即可):

预期:

把下面提示词给 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 示例代码,保证语法正确。

Markdown 与代码自检(已执行)