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 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 怎么写,而是先问:这个项目有没有单连接策略、心跳清理、重连退避、旧连接隔离和重连后的业务补偿。

posted @ 2026-06-22 14:58  万物皆object  阅读(1)  评论(0)    收藏  举报