在实时推送这件事上,大多数前端的第一反应都是 new WebSocket(url) 一把梭。本地开发跑得飞起,一上生产就翻车——用户网络切了一下 4G,页面就再也收不到消息了;服务器半夜发版重启,连接断了还显示“已连接”,用户以为一切正常,实际上早就成了数据孤岛。这不是个案,是原生 WebSocket 最原始的问题:它不会帮你重连,也不会告诉你连接其实已经死了。
更隐蔽的是“僵死连接”。客户端 readyState 还是 OPEN,心跳却早已断了。你发一条消息,send 不报错,服务端也收不到——这种状态比断开更危险,因为业务层完全感知不到。而 Vue 3 的组合式 API,恰好能把连接管理、心跳检测、自动重连、生命周期清理收拢到一个 composable 里,让组件只管收消息,不用操心连接死活。
从原生 WebSocket 到组合式封装:为什么需要心跳与重连
先画一条线:原生 WebSocket 是 TCP 长连接的上层封装,但它不负责“连接健康度”。网络断开时,浏览器可能需要几十秒甚至更久才能触发 onclose——这段时间里,应用对后端的状态是失明的。而服务端主动断开(比如 Nginx 空闲超时设了 60 秒),客户端也未必立刻感知。这就是心跳检测存在的理由:客户端每隔一段固定时间发一个 Ping 帧或自定义心跳包,服务端回复 Pong;如果连续几次没收到回复,就主动关闭重连。
另一个是重连策略。很多初版实现直接用 setInterval 每隔 3 秒重试一次——服务端还没重启完,客户端已经把连接池打满了。合理的做法是“指数退避 + 随机偏移”:第一次重连等 1 秒,第二次 2 秒,第三次 4 秒,最大间隔 30 秒,每次加一个 0~500ms 的随机抖动,避免一堆客户端同时重连引发雪崩。
组合式 API 在这里的价值不是“能写”而是“能拆”。你可以把 WebSocket 实例、定时器句柄、重连计数、心跳状态全部放在 setup 函数内,通过 ref / reactive 暴露给模板;组件卸载时 onUnmounted 里自动 并 ws.close(),不会留下定时器泄漏或未关闭的连接。相比之下,Vue 2 的 mixin 方案很难彻底清理副作用,容易在切换路由后残留心跳定时器。
说到底,封装不是炫技,而是把那些容易忘的边界——网络切换、断线检测、重连节流、生命周期绑定——收进一个函数里,让接入方只关心 onMessage 里的业务逻辑。下一章我们直接上手写一个 composable,从单例连接到心跳重连一步步拆开来看。

方案对比:手写类 vs VueUse useWebSocket
上一章说到“组合式 API 的价值是能拆”,但拆到什么粒度才不重复造轮子?我们真该从 new WebSocket(url) 开始写个 类吗?
手写类的三处静默崩塌点
readyState 是个幻觉。onopen 触发时,它可能还是 CONNECTING;send 瞬间报错“Still in CONNECTING state”——这问题在 Chrome 124+、Safari 17.5 里依然稳定复现。重连定时器若没用 ref 清理,切换路由后 setInterval 还在后台狂跑。心跳检测更隐蔽:只看 send 成功就认为通了,结果服务端 Pong 压根没发回来,客户端却继续发业务消息。
useWebSocket 不是“开箱即用”,是“开箱即稳”
VueUse 的 内置了连接状态同步(status ref)、指数退避重连(autoReconnect: { retries: 5, delay: 1000 })、心跳帧自动发送与超时判定(heartbeat: { interval: 30000, timeout: 5000 }),且 onUnmounted 自动调用 ws.close() 和清除所有定时器。你传个 URL 和 onMessage,剩下的它扛着。
但企业级场景里,它只是积木,不是整栋楼
一个后台系统常需同时连消息中心、告警通道、设备状态流三个 ws 地址。 默认是单连接实例,硬套单例模式反而破坏响应式——这时候得自己包一层 ,用 Map<string, ReturnType<typeof useWebSocket>> 管理多连接,再暴露统一的 sendMessage(channel, data)。别迷信封装,信边界。

实战:用 useWebSocket 封装全局 WebSocket 服务
先别急着写类。我们直接从组合函数入手,把单例连接、心跳检测、自动重连塞进一个 composable 里。接入方只需要传 URL 和 onMessage 回调,其他边界由 扛着。
下面这段代码是核心骨架。注意 heartbeat 参数里的 interval 和 timeout ——前者控制多久发一次心跳帧,后者决定等多久没收到 Pong 就算断线。很多封装只发了心跳却不设超时,结果服务端早挂了,客户端还傻等。
import { useWebSocket } from '@vueuse/core'
import { ref, watch } from 'vue'
export function useGlobalWebSocket(url: string) {
const message = ref<any>(null)
const connectionStatus = ref<'CONNECTING' | 'OPEN' | 'CLOSING' | 'CLOSED'>('CLOSED')
const { send, open, close, status } = useWebSocket(url, {
autoReconnect: {
retries: 6,
delay: 1000,
onFailed() {
console.error('重连 6 次均失败,请检查网络或服务端')
}
},
heartbeat: {
interval: 30000,
timeout: 5000,
pongTimeoutMessage: '服务端未响应 Pong,触发重连'
},
onConnected() {
connectionStatus.value = 'OPEN'
},
onDisconnected() {
connectionStatus.value = 'CLOSED'
},
onMessage(ws: WebSocket, event: MessageEvent) {
message.value = JSON.parse(event.data)
}
})
watch(status, (val) => {
connectionStatus.value = val as any
})
function sendMessage(data: Record<string, any>) {
if (status.value !== 'OPEN') {
console.warn('连接未就绪,消息已缓存等待重发')
return
}
send(JSON.stringify(data))
}
return {
connectionStatus,
sendMessage,
message,
close
}
}
单例模式怎么搞?用 provide/inject 或者 Pinia store 都行。我更倾向在 App.vue 里实例化一次,然后 provide 出去:
// App.vue
const ws = useGlobalWebSocket('wss://api.example.com/ws')
provide('ws', ws)
子组件通过 inject('ws') 拿到同一个连接实例。这样不会每进一个页面就 new 一个 WebSocket,也不会因为路由切换导致多个定时器打架。
但如果你需要同时连三个不同地址(消息中心、告警通道、设备流),硬套单例就麻烦了。这时候得用 Map<string, ReturnType<typeof useGlobalWebSocket>> 管理多连接,暴露统一的 sendMessage(channel, data)。别一条路走到黑。
最后提醒一句:心跳帧的 payload 别写死了。有些服务端要求传 { type: 'ping' },有些要求 { heartbeat: true },记得在 的 heartbeat 参数里配 message 字段。不然心跳通了,业务数据还是发不出去——那才叫白忙活。
断线自动恢复:重连策略与指数退避
心跳保活只能算个开胃菜,真正让后端同事血压拉满的,往往是断线重连那几秒。客户端要是每秒都发连接请求,服务端 GC 抖动还没缓过来,就能被你的重连风暴直接打趴——线上报警群炸过,亲身经历。
所以重连不能无脑重试,得讲点规矩。
监听 onClose 与 onError:重连的触发时机
暴露的 onClose 和 onError 回调,就是重连的入口。但别两个都单独调重连——onError 之后通常紧跟着 onClose,重复触发会把重试计数器搞乱。
我的做法是只在一个地方触发重连逻辑:onClose 内部判断。因为 WebSocket 规范里,error 事件发生后连接必然关闭,所以等 close 事件再动手,不会漏也不会重复。
// 伪逻辑示意
onClose((event: CloseEvent) => {
if (event.code === 1000) {
// 正常关闭,不重连
return
}
scheduleReconnect()
})
CloseEvent 的 code 字段值得留意。1000 是正常关闭,1006 是异常断开——后者才是我们需要重试的场景。有些代理超时会返回 1001,同样要触发重连。我的建议是只放过 1000,其他一律进重连流程。
指数退避:别让服务器在伤口上撒盐
指数退避不是新概念,但在 WebSocket 重连场景里特别实用。第一次断线等 1 秒,第二次等 2 秒,第三次 4 秒,第四次 8 秒……直到最大阈值。这样既能在短时网络抖动时快速恢复,也能在服务端故障时避免连接风暴。
const RECONNECT_BASE_DELAY = 1000 // 1 秒
const RECONNECT_MAX_DELAY = 30000 // 30 秒
const MAX_RETRIES = 10
let retryCount = 0
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
function scheduleReconnect() {
if (retryCount >= MAX_RETRIES) {
console.warn(`已达最大重试次数 ${MAX_RETRIES},停止重连`)
return
}
const delay = Math.min(
RECONNECT_BASE_DELAY * Math.pow(2, retryCount) + Math.random() * 1000,
RECONNECT_MAX_DELAY
)
console.log(`计划重连: 第 ${retryCount + 1} 次, ${delay}ms 后执行`)
reconnectTimer = setTimeout(() => {
retryCount++
initConnection()
}, delay)
}
这里加了个 Math.random() * 1000 的抖动值,防止多个客户端同时重连造成惊群效应。业界叫它「jitter」,说白了就是让大家的请求时间错开一点。
生产环境里,我见过把 设成 500ms 的,结果服务端重启时 CPU 瞬间拉到 90%,全是重连请求在握手。后来改成 2 秒起步,情况好多了。这个阈值得根据你的业务场景调,消息推送要求高的系统可以激进一些,数据上报型的就保守点。
最大重试次数与手动重置
无限重连是灾难。使用者已经关掉页面了,后台还在疯狂尝试连一个不存在的服务——这种情况在单页应用里很常见,读者切换了账号或者退出了系统,WebSocket 还在试图重连。
所以我一般暴露一个 resetReconnect() 方法,在用户主动退出或登录态失效时调用,把 retryCount 清零并清除定时器。同时配合 MAX_RETRIES 兜底,到了次数上限就彻底停掉,除非外界手动调用 resetAndReconnect()。
function resetReconnect() {
if (reconnectTimer) {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
retryCount = 0
}
function resetAndReconnect() {
resetReconnect()
initConnection()
}
这里有个细节:close() 方法里要主动调 resetReconnect(),否则用户手动关闭连接后,定时器还在跑,几秒后又自动连上了——用户会觉得这程序有 bug。
最后说个容易被忽略的点:重连成功后,记得把 retryCount 归零。这样下次网络波动又能从 1 秒开始退避,而不是累加到几十秒的间隔里。
消息订阅与业务解耦:让多个组件各取所需
连接稳了,心跳稳了,重连也稳了。然后呢?
消息推过来,总不能让每个组件都去 onMessage 里写 if/else 判断消息类型吧。那代码会比老太太的裹脚布还臭。而且组件一多,你根本搞不清谁订阅了什么,卸载时漏清理,回调还在跑——控制台报错“Cannot set properties of null”都是轻的。
一个响应式 Map 解决问题
我习惯在 内部维护一个 Map<string, Set<Function>>,消息类型当 key,回调函数集合当 value。收到消息时,解析 JSON 拿到 type 字段,直接 Map.get(type)?.forEach(fn => fn(data))。简单粗暴,但够用。
const handlers = new Map<string, Set<Function>>()
function onMessage(event: MessageEvent) {
const { type, payload } = JSON.parse(event.data)
handlers.get(type)?.forEach(cb => cb(payload))
}
function subscribe(type: string, callback: Function) {
if (!handlers.has(type)) handlers.set(type, new Set())
handlers.get(type)!.add(callback)
// 返回取消订阅函数
return () => handlers.get(type)?.delete(callback)
}
subscribe 返回一个取消函数,组件卸载时调一下就行。但人总会忘,所以我在 的返回值里直接塞了一个 组合函数,内部用 onUnmounted 自动清理。
组件里怎么用
拿一个消息列表组件举例。它只关心 new_message 和 两种消息,别的推送跟它没关系。
const { subscribe } = useWebSocket()
const messages = ref<Message[]>([])
onMounted(() => {
const unsub1 = subscribe('new_message', (msg: Message) => {
messages.value.push(msg)
})
const unsub2 = subscribe('message_deleted', (id: string) => {
messages.value = messages.value.filter(m => m.id !== id)
})
// 用 watchEffect 自动绑定卸载
watchEffect((onCleanup) => {
onCleanup(() => {
unsub1()
unsub2()
})
})
})
等等,这里有个坑。watchEffect 的回调里直接调用 subscribe 会立即执行,但 onCleanup 只在副作用重跑或组件卸载时触发。如果组件多次挂载卸载,每次挂载都会新增订阅,旧的回调却还在 handlers 里躺尸。所以上面那个写法其实是错的。
正确做法:在 watchEffect 外面先调 subscribe,把取消函数存下来,再在 onCleanup 里执行。或者干脆把 subscribe 放在 onMounted 里,用 onUnmounted 收尾。
let unsub1: (() => void) | null = null
let unsub2: (() => void) | null = null
onMounted(() => {
unsub1 = subscribe('new_message', handler1)
unsub2 = subscribe('message_deleted', handler2)
})
onUnmounted(() => {
unsub1?.()
unsub2?.()
})
别嫌啰嗦。内存泄漏这玩意儿,上线前测不出来,人用着用着页面越来越卡,末了卡死——你还不知道怎么死的。
单例模式下消息分发是全局的
因为 是单例,所有组件共享同一个连接和同一个 handlers Map。A 组件订阅了 stock_price,B 组件订阅了 user_login,互不干扰。收到一条股票行情推送,只有 A 组件的回调被触发,B 组件毫无感知。
这比事件总线爽在哪?事件总线你得手动 emit 和 off,而且命名冲突了很难排查。这里消息类型天然就是 WebSocket 协议里的字段,跟后端约定好就行,不用额外维护一套事件名。
说实话,我见过有人用 Vuex 或 Pinia 存 WebSocket 消息,再让组件去 store 里 watch。那性能损耗……消息一多,整个 store 的响应式树都在重新计算。直接订阅回调,才是正道。
一个提醒:subscribe 的回调里如果涉及 DOM 操作或组件状态更新,记得用 nextTick 包裹一下,避免在同一个 tick 里批量更新导致渲染抖动。这个坑我在做实时大盘的时候踩过,数据一秒来 30 条,图表直接卡成 PPT。
踩坑与优化:从上线到稳定的关键细节
讲完了单例和消息分发,你以为这就稳了?别急,上线第一天就能给你上一课。
我们当时在测试环境跑了三天,一切正常。结果一上生产,半小时内运维群里就开始刷告警:WebSocket 连接数暴涨,服务器 CPU 直线飙升。排查了半天,发现是某个组件在路由切换时被销毁了,但 close() 没有被调用——因为单例模式下,组件卸载时根本没走我们预设的清理逻辑。新组件挂载时又调了一次 connect(),导致同一个页面上存在两条连接。
这个坑的根因在于:onUnmounted 里只清理了订阅回调,没断开连接。单例的好处是全局共享,坏处也在这——你不敢在 onUnmounted 里直接 ws.close(),因为别的组件还在用。所以必须加引用计数。每次 connect() 调用时 +1,disconnect() 时 -1,减到 0 才真正关闭底层连接。别偷懒,不做引用计数的单例,就是定时炸弹。
第二个坑是心跳间隔与服务端超时时间不匹配。我们心跳设了 10 秒,服务端 Nginx 的 是 60 秒,本来没问题。但后来运维升级了网关,新网关的 idle 超时只有 30 秒——而且没人通知前端。结果就是:心跳包还在发,但网关觉得这条连接太久没业务数据,直接掐了。客户端收不到服务端的 pong,触发重连,但重连后又被掐,循环往复。
解决方案很粗暴:心跳间隔不要拍脑袋定,去问运维要网关和负载均衡的超时配置,取最小值再打七折。比如网关超时 60 秒,心跳就设 40 秒。另外,服务端返回的 pong 包里最好带一个时间戳,客户端收到后比对一下,如果延迟超过 5 秒,说明网络链路已经有问题了,主动触发重连比等着断线更靠谱。
最后补一条生产环境的建议——加个连接状态监控面板,别嫌鸡肋。线上出了问题,你连现场都摸不着。我们自己搞了个特别精简的调试组件,塞在页面角落,就三行数据:
连接状态: ✅ 已连接 (已保持 2h 13m)
最后心跳: 2026-05-20 14:32:07
累计重连: 3 次 (上次重连: 1h 前)
这个面板只在开发环境和生产环境的隐身模式下显示,普通用户看不到。但一旦出问题,让运维截个图发过来,比看日志快多了。代码量不大,就是一个 reactive 对象,在 内部维护,暴露一个 getStatus() 方法即可。
折腾一圈下来,真正让人头疼的从来不是 WebSocket 那几条 API——连接、发送、接收、关闭,翻来覆去就这几个动作。真正埋坑的是那些边界:用户手滑关了页面但卸载没执行、地铁里信号切换导致连接漂移、后端半夜发版搞了个优雅重启直接把长连接掐了、还有浏览器那套省电策略把标签页冻住心跳都发不出去。把这几个场景一个个跑顺了,再聊什么性能优化、消息压缩都不迟。





评论