当前位置: 首页 > news >正文

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

http://www.jsqmd.com/news/1061908/

相关文章:

  • JMeter插件实战指南:从核心插件选型到阶梯压测与性能监控
  • 免费投票平台哪个好用?2026海投票+问卷星零广告强防刷实测对比 - 微信投票小程序
  • 2026郑州黄金回收怎么选|权威实测排名,收的顶10分满分登顶 - 奢侈品回收测评
  • BilibiliDown:B站视频下载的终极解决方案,3分钟轻松搞定离线收藏
  • CSLE:基于数字孪生与强化学习的网络安全AI训练平台构建指南
  • N_m3u8DL-RE终极指南:如何轻松下载流媒体视频的完整教程
  • 2026长沙闲置黄金回收公信力排行,禹竞官方直营获评行业标杆品牌 - 名奢变现站
  • 2026年绍兴本地GEO工具推荐:企业选工具前先看这8个核心能力 - 科技快讯
  • 2026年西安科技项目申报与知识产权服务一站式解决方案选择指南 - 企业名录优选推荐
  • 如何从B站视频提取无损音频?BilibiliDown终极音频提取指南
  • 2026年东莞天线厂家推荐榜单:天线座/无人机图传天线/FPV天线/玻璃钢天线/5G天线/北斗天线/LoRa天线等全品类专业制造商深度解析 - 品牌发掘
  • Typeset文本排版终极指南:让网页拥有印刷级排版效果
  • 破解包装定制痛点:FOSC四阶定制法如何实现纸箱定制、重型瓦楞纸箱小批量定制与发泡材料定制降本增效? - 热点速览
  • 2026年贵州物理类200-350分考生择校指南:聚焦贵州经贸职业技术学院 - 品牌2026
  • 科研效率革命:3步实现PubMed文献批量下载终极指南
  • 高阶时空建模:从图神经网络到单纯复形与时空随机游走
  • 一劳永逸!Visual C++运行库完整安装指南:告别DLL缺失错误
  • 2026年贵阳采暖制冷新风净水一体化方案:5大舒适家居服务商实力对标 - 企业名录优选推荐
  • ATmega406智能电池管理MCU:集成BMS与AVR内核的硬件保护与软件定制方案
  • 贵阳舒适家居服务商2026年全品类对比:从地暖到空气能热泵的系统化选型指南 - 企业名录优选推荐
  • 3分钟搞定!你的专属视频下载助手VideoDownloadHelper完全指南
  • 2026无锡装修怕公司跑路?先施工后付款才最安全 - 装企自媒体训练营辉哥
  • OpenClaw龙虾:面向AI Agent的本地化轻量运行时详解
  • Pixelle-Video完全指南:3分钟学会AI短视频制作
  • 2026年7月全国汽车窗膜车衣服务机构实力盘点:DuPont™杜邦™正规可信、专业技术过硬、售后完善 - 十大排行榜推荐
  • 现代Agent需要原生异步RL基础设施
  • OpenClaw技能编排引擎:YAML驱动的AI工作流集成方案
  • 洛雪音乐助手:三步打造你的跨平台智能音乐中心
  • 2026保姆级教程:免费音频转文字工具大全,手机电脑在线离线全部搞定 - 软件小管家
  • 2026保姆级指南:一键抠图app推荐,免费无水印手机安卓苹果抠图软件手把手教程 - 软件小管家