WebSocket 实时通信
1. 先搞清:什么时候该用 WebSocket
后端 HTTP 请求你再熟悉不过:客户端问一次,服务器答一次。但这种模式做不了:
- 聊天室:别人说话你这边要立刻看到
- 实时看板:股价、订单状态变更时主动推
- 协同编辑:多人同时改一个文档
这些场景需要服务器主动推送给客户端。
三种方案对比:
| 方案 | 方向 | 协议 | 适合 |
|---|---|---|---|
| 轮询(Polling) | 客户端主动问 | HTTP | 低频状态检查(每 30 秒查一次未读) |
| SSE(Server-Sent Events) | 服务器单向推 | HTTP | 通知流、AI 流式输出(下一篇讲) |
| WebSocket | 双向 | WS | 聊天、协同、实时游戏 |
原则:需要双向就 WebSocket,只要服务器推就 SSE(更简单、走 HTTP 不用额外基建)。
Java 对照:
- 轮询 ≈ 前端版
@Scheduled定时调接口 - SSE ≈ Spring WebFlux 的
Flux<ServerSentEvent> - WebSocket ≈ Spring
@ServerEndpoint/ STOMP
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://—— 明文(开发用)wss://—— TLS 加密(生产必须用)
连接状态(ws.readyState):
0 (CONNECTING)连接中1 (OPEN)已连2 (CLOSING)关闭中3 (CLOSED)已关闭
注意: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>
关键:
onUnmounted里关连接,否则切页面也在收消息- 发送前检查
readyState
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()
}
}
指数退避的意义:
- 瞬时网络抖动 → 1 秒就能恢复
- 服务器挂了 → 不会每秒 10 次请求打爆
- 长时间断开 → 稳定在 30 秒一次检查
7. 用库:@vueuse/core 的 useWebSocket
上面那堆手写的东西,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 选哪个:
- 后端用 Spring Messaging +
@MessageMapping→ STOMP - 后端自己
@ServerEndpoint→ 原生 - 新项目轻量场景 → 原生 + JSON,别上 STOMP
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')
封装的收益:
- 业务代码只关心"订阅什么 / 发什么"
- 心跳、重连、协议细节藏起来
- 类型安全(可以进一步把 type 限定为字面量联合类型)
11. Pinia 里存 WebSocket?
不要。WebSocket 是带生命周期的"连接对象",放 Pinia 里很容易踩坑:
- HMR 时连接泄漏
- 组件树销毁后 store 还在,连接不清理
推荐:用全局 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 标签
- 切到 WS 子标签
- 点连接 → Messages 看所有收发消息(带方向箭头)
- 绿色 ↑ 发送,红色 ↓ 接收
- Frames 标签显示每帧的时间戳
比 console.log 强太多:不用你手动加代码,自动记录。
12.2 Postman 也能测 WebSocket
Postman 新建 WebSocket Request → 连接 → 收发消息。调试后端服务不用改前端代码。
13. 常见坑点
| 现象 | 原因 | 解法 |
|---|---|---|
| 连上后几分钟断 | 中间设备超时 | 加心跳保活 |
| 页面切换后消息还在收 | 组件没销毁连接 | onUnmounted 里 close |
| 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,笔记是静态的)。
未来可能用的场景:
- "谁正在看这篇笔记"(在线人数实时更新)
- 多人协同编辑笔记
- AI 助手的"其他人正在问..."共享提示
学习上值得了解,实际用时再配。
小练习
- 本地起一个简单 WebSocket server(Node.js
ws包 20 行代码),前端连上发收消息 - 用
useWebSocket改写成 Vue 组件,观察自动重连 - 加心跳:每 10 秒发 ping,服务器回 pong
- 故意断网 30 秒再恢复,看重连是否成功
- 把连接过程在 DevTools Network → WS 里看一次
延伸阅读
- MDN - WebSocket API
- VueUse - useWebSocket
- @stomp/stompjs
- Spring WebSocket 官方
- Socket.IO(带更多特性的封装库,但多套独立协议,比原生 WS 重)