Vue 大屏项目里的 WebSocket 心跳重连:如何避免重复连接和重复消息

Vue 大屏项目里的 WebSocket 心跳重连:如何避免重复连接和重复消息

互动大屏项目里,WebSocket 是生命线。观众扫码发消息、点歌、霸屏、游戏状态同步,都要靠它把数据推到大屏。

但 WebSocket 最容易被低估。很多文章只讲 new WebSocket(url)onmessageonclose,真正到项目里,难点其实是:断线后怎么恢复,重连时怎么避免多个连接并存,心跳定时器怎么清理,页面切换以后消息还会不会继续触发旧组件。

这篇文章聊的就是这个问题。

问题现象

大屏跑久以后,可能出现这些现象:

  • 网络抖动一次,日志里出现多次重连。
  • 服务端推一条消息,前端展示了两次。
  • 进入游戏页后,聊天页的 watcher 还在消费消息。
  • 页面关闭后,心跳还在发送。
  • onerroronclose 几乎同时触发,重连逻辑被执行两次。

这些问题在本地很难复现,因为本地网络太稳定。真正的活动现场,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。如果 onerroronclose 都触发,就可能出现多个重连循环。

第二,旧 socket 没有明确关闭。新连接成功了,旧连接的回调不一定全部失效。

第三,心跳定时器和 socket 生命周期没有绑定。连接断了,心跳可能还在跑。

根因

WebSocket 重连不是“失败后再连一次”,而是一个连接生命周期管理问题。

一个稳定的管理器至少要保证:

  • 同一时间只有一个有效连接。
  • 同一时间只有一个心跳定时器。
  • 同一时间只有一个重连定时器。
  • 旧连接的消息不能再进入业务层。
  • 主动关闭和异常断开要区分。
  • 重连次数和退避策略要可控。

如果这些规则散落在 Vuex、页面组件和工具函数里,后期很难维护。

复现方式

我一般用三个动作复现:

  1. 打开页面后,在 DevTools Network 里切到 Offline。
  2. 等待 onerroronclose 触发。
  3. 再切回 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 heartbeatdisconnect()-> mark manual close-> clear heartbeat-> clear reconnect-> close socketscheduleReconnect()-> only one reconnect timer-> exponential backoff-> max retry or keep retry by config

关键不是写成 class 还是函数,而是把生命周期收拢到一个地方。

核心代码

下面是一个脱敏后的实现:

function createSocketManager(options) {let socket = nulllet connectionId = 0let manualClose = falselet heartbeatTimer = nulllet reconnectTimer = nulllet retryCount = 0function clearHeartbeat() {clearInterval(heartbeatTimer)heartbeatTimer = null}function clearReconnect() {clearTimeout(reconnectTimer)reconnectTimer = null}function startHeartbeat(id) {clearHeartbeat()heartbeatTimer = setInterval(() => {if (!socket || id !== connectionId) returnif (socket.readyState === WebSocket.OPEN) {socket.send(JSON.stringify({ code: 0 }))}}, options.heartbeatInterval || 5000)}function closeCurrentSocket() {if (!socket) returnsocket.onopen = nullsocket.onmessage = nullsocket.onerror = nullsocket.onclose = nullif (socket.readyState === WebSocket.CONNECTING ||socket.readyState === WebSocket.OPEN) {socket.close()}}async function connect() {manualClose = falseclearReconnect()clearHeartbeat()closeCurrentSocket()const id = ++connectionIdconst token = await options.getToken()socket = new WebSocket(`${options.url}?token=${encodeURIComponent(token)}`)socket.onopen = () => {if (id !== connectionId) returnretryCount = 0startHeartbeat(id)options.onOpen && options.onOpen()socket.send(JSON.stringify({ code: 0 }))}socket.onmessage = (event) => {if (id !== connectionId) returnoptions.onMessage && options.onMessage(event)}socket.onerror = () => {if (id !== connectionId) returnscheduleReconnect()}socket.onclose = () => {if (id !== connectionId) returnclearHeartbeat()if (!manualClose) {scheduleReconnect()}}}function scheduleReconnect() {if (reconnectTimer) returnretryCount += 1const delay = Math.min(30000, 1000 * Math.pow(2, retryCount))reconnectTimer = setTimeout(() => {reconnectTimer = nullconnect()}, delay)}function disconnect() {manualClose = trueconnectionId += 1clearHeartbeat()clearReconnect()closeCurrentSocket()socket = null}function send(data) {if (!socket || socket.readyState !== WebSocket.OPEN) return falsesocket.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 怎么写,而是先问:这个项目有没有单连接策略、心跳清理、重连退避、旧连接隔离和重连后的业务补偿。