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