WebSocket 实时通信

1. 先搞清:什么时候该用 WebSocket

后端 HTTP 请求你再熟悉不过:客户端问一次,服务器答一次。但这种模式做不了:

这些场景需要服务器主动推送给客户端。

三种方案对比

方案 方向 协议 适合
轮询(Polling) 客户端主动问 HTTP 低频状态检查(每 30 秒查一次未读)
SSE(Server-Sent Events) 服务器单向推 HTTP 通知流、AI 流式输出(下一篇讲)
WebSocket 双向 WS 聊天、协同、实时游戏

原则需要双向就 WebSocket,只要服务器推就 SSE(更简单、走 HTTP 不用额外基建)。

Java 对照


2. 原生 WebSocket API

浏览器内置,不用装库:

const ws = new WebSocket('ws://localhost:8080/ws/chat')

ws.onopen = () => {
  console.log('连接成功')
  ws.send('hello')
}

ws.onmessage = (event) => {
  console.log('收到消息:', event.data)
}

ws.onerror = (err) => {
  console.error('连接错误:', err)
}

ws.onclose = (event) => {
  console.log('连接关闭', event.code, event.reason)
}

// 主动关闭
ws.close()

URL 协议

连接状态ws.readyState):

注意ws.send(...) 只能在 OPEN 状态调。CONNECTING 时发会报错。


3. Vue 组件里的完整用法

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'

const messages = ref<string[]>([])
const input = ref('')
const connected = ref(false)
let ws: WebSocket | null = null

onMounted(() => {
  ws = new WebSocket('wss://api.example.com/ws/chat')

  ws.onopen = () => { connected.value = true }
  ws.onmessage = (e) => { messages.value.push(e.data) }
  ws.onclose = () => { connected.value = false }
})

onUnmounted(() => {
  ws?.close() // 组件卸载必须关
})

function send() {
  if (ws?.readyState === WebSocket.OPEN && input.value) {
    ws.send(input.value)
    input.value = ''
  }
}
</script>

<template>
  <div>
    <p>状态: {{ connected ? '已连接' : '未连接' }}</p>
    <ul><li v-for="m in messages">{{ m }}</li></ul>
    <input v-model="input" @keyup.enter="send" />
  </div>
</template>

关键


4. 消息协议:约定数据格式

WebSocket 本身只负责传字符串/二进制,消息内容的结构要前后端约定。通常用 JSON:

// 前后端共同定义的消息类型
interface WSMessage {
  type: 'chat' | 'typing' | 'online' | 'error'
  payload: any
  timestamp: number
}

function send(type: WSMessage['type'], payload: any) {
  ws?.send(JSON.stringify({ type, payload, timestamp: Date.now() }))
}

ws.onmessage = (e) => {
  const msg: WSMessage = JSON.parse(e.data)
  switch (msg.type) {
    case 'chat':
      addMessage(msg.payload)
      break
    case 'typing':
      showTyping(msg.payload.userId)
      break
    case 'online':
      updateOnlineList(msg.payload)
      break
  }
}

Java 对照:类似定义 DTO + 用 type 做多态 —— 一条 WS 连接复用成多种消息流。


5. 心跳保活

问题:WebSocket 连接可能被中间设备(Nginx、负载均衡、手机运营商)默默断开。你的代码却不知道——send 时才发现。

心跳:客户端定期发小消息,保持连接活跃,也能及时发现断开。

class HeartbeatWS {
  private ws: WebSocket | null = null
  private heartbeatTimer: number | null = null
  private interval = 30000 // 30 秒

  connect(url: string) {
    this.ws = new WebSocket(url)
    this.ws.onopen = () => this.startHeartbeat()
    this.ws.onmessage = (e) => this.handleMessage(e)
    this.ws.onclose = () => this.stopHeartbeat()
  }

  private startHeartbeat() {
    this.stopHeartbeat()
    this.heartbeatTimer = window.setInterval(() => {
      if (this.ws?.readyState === WebSocket.OPEN) {
        this.ws.send(JSON.stringify({ type: 'ping' }))
      }
    }, this.interval)
  }

  private stopHeartbeat() {
    if (this.heartbeatTimer) {
      clearInterval(this.heartbeatTimer)
      this.heartbeatTimer = null
    }
  }

  private handleMessage(e: MessageEvent) {
    const msg = JSON.parse(e.data)
    if (msg.type === 'pong') return // 心跳响应,吞掉
    // 业务消息处理
  }
}

约定:客户端发 ping,服务器回 pong(或 Nginx 配置 WebSocket 不超时)。

Nginx 配置

location /ws {
    proxy_pass http://backend:8080;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_read_timeout 86400s; # 24 小时不超时
}

6. 自动重连

断开后要自动重连,但不能简单 while(true) reconnect() —— 会把后端打爆。用指数退避

class ReconnectingWS {
  private ws: WebSocket | null = null
  private url = ''
  private reconnectAttempts = 0
  private maxAttempts = 10
  private shouldReconnect = true

  connect(url: string) {
    this.url = url
    this.shouldReconnect = true
    this.open()
  }

  private open() {
    this.ws = new WebSocket(this.url)
    this.ws.onopen = () => {
      console.log('连接成功')
      this.reconnectAttempts = 0
    }
    this.ws.onclose = () => {
      if (this.shouldReconnect) this.scheduleReconnect()
    }
  }

  private scheduleReconnect() {
    if (this.reconnectAttempts >= this.maxAttempts) {
      console.error('重连次数超限,放弃')
      return
    }
    // 指数退避: 1s, 2s, 4s, 8s, 16s, 30s 封顶
    const delay = Math.min(30000, 1000 * 2 ** this.reconnectAttempts)
    console.log(`${delay}ms 后重连...`)
    setTimeout(() => {
      this.reconnectAttempts++
      this.open()
    }, delay)
  }

  close() {
    this.shouldReconnect = false
    this.ws?.close()
  }
}

指数退避的意义


7. 用库:@vueuse/coreuseWebSocket

上面那堆手写的东西,VueUse 一行搞定:

import { useWebSocket } from '@vueuse/core'

const { status, data, send, open, close } = useWebSocket('wss://api.example.com/ws', {
  heartbeat: {
    message: JSON.stringify({ type: 'ping' }),
    interval: 30000,
  },
  autoReconnect: {
    retries: 10,
    delay: 1000,
    onFailed() {
      alert('连接失败')
    },
  },
})

// status: 'CONNECTING' | 'OPEN' | 'CLOSED'
// data: 最新收到的消息
// send(msg) 发送消息

生产项目推荐直接用 VueUse,自己写容易漏边界。


8. STOMP:企业项目常见

Spring 后端很多用 STOMP(Simple Text Oriented Messaging Protocol)over WebSocket,带订阅 / 路由 / ACK 机制。

后端:

@MessageMapping("/chat")
@SendTo("/topic/messages")
public Message handle(Message msg) { ... }

前端用 @stomp/stompjs

pnpm add @stomp/stompjs
import { Client } from '@stomp/stompjs'

const client = new Client({
  brokerURL: 'wss://api.example.com/ws',
  reconnectDelay: 5000,
  heartbeatIncoming: 10000,
  heartbeatOutgoing: 10000,
})

client.onConnect = () => {
  // 订阅话题
  client.subscribe('/topic/messages', (msg) => {
    console.log('收到:', msg.body)
  })

  // 发消息
  client.publish({ destination: '/app/chat', body: 'hello' })
}

client.activate()

STOMP vs 原生 WS 选哪个


9. 鉴权:token 怎么带

WebSocket 握手是 HTTP 升级协议,不能加自定义 header(浏览器限制)。三种方案:

9.1 URL 参数(简单但安全性差)

new WebSocket(`wss://api.example.com/ws?token=${token}`)

风险:URL 可能进日志、浏览器历史。短期 token 还能接受,长期 token 别这么玩。

9.2 连接后第一帧发 token

ws.onopen = () => {
  ws.send(JSON.stringify({ type: 'auth', token }))
}

服务端不校验就断开。推荐

9.3 Cookie(同域)

// 前端什么都不用做,浏览器自动带 httpOnly cookie
new WebSocket('wss://same-origin.example.com/ws')

限制:必须同域(不能跨站),后端要从 cookie 读 token。in4vue 这种同域部署可以用。


10. 组件封装:useRealtimeChannel composable

业务里反复写连接/消息/重连代码很烦,抽一个 composable:

// src/composables/useRealtimeChannel.ts
import { useWebSocket } from '@vueuse/core'
import { computed, ref } from 'vue'

interface ChannelMessage<T = any> {
  type: string
  payload: T
  timestamp: number
}

export function useRealtimeChannel(url: string) {
  const listeners = new Map<string, Set<(payload: any) => void>>()

  const { status, data, send: rawSend } = useWebSocket(url, {
    heartbeat: { message: JSON.stringify({ type: 'ping' }), interval: 30000 },
    autoReconnect: { retries: 10, delay: 1000 },
    onMessage: (_ws, event) => {
      try {
        const msg: ChannelMessage = JSON.parse(event.data)
        if (msg.type === 'pong') return
        listeners.get(msg.type)?.forEach(fn => fn(msg.payload))
      } catch (err) {
        console.warn('bad message', event.data)
      }
    },
  })

  function on<T = any>(type: string, fn: (payload: T) => void) {
    if (!listeners.has(type)) listeners.set(type, new Set())
    listeners.get(type)!.add(fn)
    return () => listeners.get(type)!.delete(fn)
  }

  function send(type: string, payload: any) {
    rawSend(JSON.stringify({ type, payload, timestamp: Date.now() }))
  }

  return {
    status,
    isConnected: computed(() => status.value === 'OPEN'),
    send,
    on,
  }
}

使用:

const { isConnected, send, on } = useRealtimeChannel('wss://api.example.com/ws')

const unsub = on<string>('chat', (msg) => {
  messages.value.push(msg)
})
onUnmounted(unsub)

send('chat', 'hello')

封装的收益


11. Pinia 里存 WebSocket?

不要。WebSocket 是带生命周期的"连接对象",放 Pinia 里很容易踩坑:

推荐:用全局 composable(像第 10 节那种),或单例 service:

// src/services/realtime.ts
import { createRealtimeChannel } from './createRealtimeChannel'

let instance: ReturnType<typeof createRealtimeChannel> | null = null

export function getRealtime() {
  if (!instance) {
    instance = createRealtimeChannel('wss://api.example.com/ws')
  }
  return instance
}

main.ts 里按需初始化,用户登出时 close() 并重置。


12. 调试技巧

12.1 Chrome DevTools 的 Network 标签

console.log 强太多:不用你手动加代码,自动记录。

12.2 Postman 也能测 WebSocket

Postman 新建 WebSocket Request → 连接 → 收发消息。调试后端服务不用改前端代码。


13. 常见坑点

现象 原因 解法
连上后几分钟断 中间设备超时 加心跳保活
页面切换后消息还在收 组件没销毁连接 onUnmountedclose
HMR 后出现多个连接 旧连接没关 Vite 的 HMR hook 清理,或用单例
跨域连不上 CORS for WebSocket Nginx / 后端配 CORS
重连风暴 固定间隔重试 指数退避 + 最大次数
消息顺序错乱 多消息并发到 消息里带序号 / 时间戳
token 过期断开 鉴权失败 断开后走刷新 token 流程再重连

14. 心智模型

HTTP: 短对话(问一句答一句,挂)
WebSocket: 电话(接通后双方随时说话,直到有人挂)

接通时机: 用户进入页面 / 登录后
挂断时机: 用户离开页面 / 登出 / 业务完成

和 Pinia 的关系: 不存实例,存从它收到的数据

稳定三要素:
  1. 心跳 — 防被中间设备掐
  2. 重连 — 防网络抖动
  3. 清理 — 防组件销毁后泄漏

15. in4vue 会用到吗

in4vue 目前的 MVP 不需要 WebSocket(AI 问答用 SSE,笔记是静态的)。

未来可能用的场景

学习上值得了解,实际用时再配


小练习

  1. 本地起一个简单 WebSocket server(Node.js ws 包 20 行代码),前端连上发收消息
  2. useWebSocket 改写成 Vue 组件,观察自动重连
  3. 加心跳:每 10 秒发 ping,服务器回 pong
  4. 故意断网 30 秒再恢复,看重连是否成功
  5. 把连接过程在 DevTools Network → WS 里看一次

延伸阅读