Vue 大屏项目里的 WebSocket 心跳重连:如何避免重复连接和重复消息
互动大屏项目里,WebSocket 是生命线。观众扫码发消息、点歌、霸屏、游戏状态同步,都要靠它把数据推到大屏。
但 WebSocket 最容易被低估。很多文章只讲 new WebSocket(url)、onmessage、onclose,真正到项目里,难点其实是:断线后怎么恢复,重连时怎么避免多个连接并存,心跳定时器怎么清理,页面切换以后消息还会不会继续触发旧组件。
这篇文章聊的就是这个问题。
问题现象
大屏跑久以后,可能出现这些现象:
- 网络抖动一次,日志里出现多次重连。
- 服务端推一条消息,前端展示了两次。
- 进入游戏页后,聊天页的 watcher 还在消费消息。
- 页面关闭后,心跳还在发送。
onerror和onclose几乎同时触发,重连逻辑被执行两次。
这些问题在本地很难复现,因为本地网络太稳定。真正的活动现场,Wi-Fi 抖一下、电脑休眠一下、浏览器切后台一下,就容易出来。
初始写法
很多项目一开始会这样写:
function connect(token) {
socket = new WebSocket(`${wsUrl}?token=${token}`)
socket.onopen = () => {
sendReady()
resetHeartbeat()
}
socket.onmessage = (event) => {
store.commit('setLastMessage', event)
}
socket.onclose = () => {
reconnect()
}
socket.onerror = () => {
reconnect()
}
}
function reconnect() {
setInterval(() => {
if (socket.readyState !== WebSocket.OPEN) {
connect()
}
}, 3000)
}
这段代码的危险点有三个。
第一,reconnect 每调用一次都会创建一个新的 setInterval。如果 onerror 和 onclose 都触发,就可能出现多个重连循环。
第二,旧 socket 没有明确关闭。新连接成功了,旧连接的回调不一定全部失效。
第三,心跳定时器和 socket 生命周期没有绑定。连接断了,心跳可能还在跑。
根因
WebSocket 重连不是“失败后再连一次”,而是一个连接生命周期管理问题。
一个稳定的管理器至少要保证:
- 同一时间只有一个有效连接。
- 同一时间只有一个心跳定时器。
- 同一时间只有一个重连定时器。
- 旧连接的消息不能再进入业务层。
- 主动关闭和异常断开要区分。
- 重连次数和退避策略要可控。
如果这些规则散落在 Vuex、页面组件和工具函数里,后期很难维护。
复现方式
我一般用三个动作复现:
- 打开页面后,在 DevTools Network 里切到 Offline。
- 等待
onerror和onclose触发。 - 再切回 Online,观察连接数、心跳日志和消息消费次数。
还可以在服务端或 mock 里每 2 秒推一条带 id 的消息,前端打印:
[socket] open connectionId=3
[socket] message connectionId=3 id=2001
[socket] message connectionId=2 id=2001
如果同一条消息从两个 connectionId 进来,就说明旧连接没有被隔离。
重构目标
我会把 WebSocket 封装成一个单连接管理器:
connect()
-> close old socket
-> create new socket with connectionId
-> bind events
-> start heartbeat
disconnect()
-> mark manual close
-> clear heartbeat
-> clear reconnect
-> close socket
scheduleReconnect()
-> only one reconnect timer
-> exponential backoff
-> max retry or keep retry by config
关键不是写成 class 还是函数,而是把生命周期收拢到一个地方。
核心代码
下面是一个脱敏后的实现:
function createSocketManager(options) {
let socket = null
let connectionId = 0
let manualClose = false
let heartbeatTimer = null
let reconnectTimer = null
let retryCount = 0
function clearHeartbeat() {
clearInterval(heartbeatTimer)
heartbeatTimer = null
}
function clearReconnect() {
clearTimeout(reconnectTimer)
reconnectTimer = null
}
function startHeartbeat(id) {
clearHeartbeat()
heartbeatTimer = setInterval(() => {
if (!socket || id !== connectionId) return
if (socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ code: 0 }))
}
}, options.heartbeatInterval || 5000)
}
function closeCurrentSocket() {
if (!socket) return
socket.onopen = null
socket.onmessage = null
socket.onerror = null
socket.onclose = null
if (
socket.readyState === WebSocket.CONNECTING ||
socket.readyState === WebSocket.OPEN
) {
socket.close()
}
}
async function connect() {
manualClose = false
clearReconnect()
clearHeartbeat()
closeCurrentSocket()
const id = ++connectionId
const token = await options.getToken()
socket = new WebSocket(`${options.url}?token=${encodeURIComponent(token)}`)
socket.onopen = () => {
if (id !== connectionId) return
retryCount = 0
startHeartbeat(id)
options.onOpen && options.onOpen()
socket.send(JSON.stringify({ code: 0 }))
}
socket.onmessage = (event) => {
if (id !== connectionId) return
options.onMessage && options.onMessage(event)
}
socket.onerror = () => {
if (id !== connectionId) return
scheduleReconnect()
}
socket.onclose = () => {
if (id !== connectionId) return
clearHeartbeat()
if (!manualClose) {
scheduleReconnect()
}
}
}
function scheduleReconnect() {
if (reconnectTimer) return
retryCount += 1
const delay = Math.min(30000, 1000 * Math.pow(2, retryCount))
reconnectTimer = setTimeout(() => {
reconnectTimer = null
connect()
}, delay)
}
function disconnect() {
manualClose = true
connectionId += 1
clearHeartbeat()
clearReconnect()
closeCurrentSocket()
socket = null
}
function send(data) {
if (!socket || socket.readyState !== WebSocket.OPEN) return false
socket.send(typeof data === 'string' ? data : JSON.stringify(data))
return true
}
return {
connect,
disconnect,
send,
getConnectionId: () => connectionId
}
}
这里最重要的是 connectionId。每次连接递增一次,旧连接即使还有异步回调,也会被挡掉。
和 Vuex 的关系
我不建议把完整 socket 对象到处传。Vuex 可以保存业务状态,比如最后一条消息、连接状态、当前屏幕 id,但连接对象本身最好由管理器持有。
页面只订阅消息:
const socketManager = createSocketManager({
url: config.ws,
getToken: () => api.createChatToken({ screenId }),
onMessage(event) {
store.commit('receiveSocketMessage', {
id: Date.now(),
event
})
},
onOpen() {
store.commit('setSocketStatus', 'open')
}
})
这样组件不需要知道重连细节,它只消费业务消息。
方案权衡
我比较倾向于“单连接管理器”方案,而不是在每个页面里连接 WebSocket。
每个页面自己连接,优点是局部简单,缺点是页面切换后非常容易产生多连接。尤其是大屏项目里,聊天页、游戏页、报名页都需要同一条实时通道,拆散以后反而更难控制。
放进 Vuex 也可以,但不要把所有定时器逻辑都写进 mutation。mutation 更适合描述状态变化,不适合承载复杂副作用。
更稳的方式是:管理器负责副作用,Vuex 负责把消息变成可响应的数据。
异常恢复
断线恢复时,不要只考虑“重新连接成功”,还要考虑业务一致性:
- 重连成功后是否需要重新发送当前屏幕 id?
- 游戏进行中是否需要同步一次当前状态?
- 霸屏队列是否继续消费?
- 断线期间丢失的消息要不要通过接口补偿?
如果业务允许丢少量实时消息,可以只恢复连接。如果业务不允许丢,比如游戏结算,就应该在重连成功后主动拉一次当前状态。
async function onReconnectOpen() {
await api.fetchCurrentScreenState(screenId)
await api.fetchCurrentGameState(screenId)
}
这一点很关键。WebSocket 只保证连接,不保证你的业务状态一定完整。
验证清单
我会这样验证:
1. 连续断网 3 次,确认浏览器里始终只有一个 WebSocket 连接。
2. onerror 和 onclose 同时触发时,确认只创建一个重连定时器。
3. 重连成功后,旧 connectionId 的 onmessage 不再进入业务层。
4. 页面销毁后,心跳停止。
5. 手动关闭连接时,不触发自动重连。
6. 服务端推同一条消息,前端只消费一次。
如果能把这些验证跑通,WebSocket 这块就从“能连上”升级成“能长期稳定运行”了。
小结
WebSocket 的难点不在 API,而在生命周期。
一个互动大屏项目,页面可能连续运行几个小时,中间会经历网络抖动、路由切换、活动开始结束、游戏状态变化。只要连接管理不收口,问题迟早会从“偶发重复消息”变成“现场不好解释的问题”。
所以我现在再看这类项目,第一件事不是问 onmessage 怎么写,而是先问:这个项目有没有单连接策略、心跳清理、重连退避、旧连接隔离和重连后的业务补偿。

浙公网安备 33010602011771号