移动端WebSocket 连接扛住聊天室

一条 WebSocket 连接扛住聊天室、游戏和互动通知:uni-app H5 实时消息总线复盘

这篇文章不是讲 new WebSocket() 怎么写。

真正难的地方在于:一个 H5 页面里同时有公屏聊天、私聊提醒、游戏开始通知、骰子游戏事件、霸屏入口、未读数刷新。它们看起来是不同业务,但最后都挤在同一条实时链路上。

我在项目中做过一个酒吧互动 H5 项目,用户扫码进入页面后,可以发公屏消息、私聊、送礼、点歌、参与活动和实时小游戏。项目里最后形成了一个很典型的前端问题:

WebSocket 连接只有一条,但消费消息的页面有很多个;连接状态、消息分发、页面生命周期和异常恢复必须一起设计。

如果只把它当成“封装 WebSocket”,很快就会出问题。

先说当时遇到的现象

最开始的问题不是连接不上,而是一些更烦人的小问题:

  1. 用户从聊天室跳到游戏页,再返回聊天室,偶尔会重复收到消息。
  2. H5 在微信里切后台回来后,页面还在,但 WebSocket 已经断了。
  3. 公屏消息、私聊消息、游戏事件都从同一个 onmessage 进来,页面越写越多 if code == ...
  4. 一个页面主动重连,另一个页面也重连,最后可能出现多条连接争抢状态。
  5. 消息来了,但当前页面不一定是这个消息的归属页。例如私聊消息来了,用户可能正在公屏,也可能在私聊详情,也可能在会话列表。

这些问题单独看都不大,但放在移动端 H5 里会互相放大。

尤其是微信内置浏览器,它不像桌面浏览器那样稳定。用户切出去回消息、扫码、支付、返回,页面生命周期会被反复打断。如果连接状态只放在某个页面组件里,就会变得很脆。

项目里的第一版思路

这个项目把 WebSocket 放到了 Vuex 里,核心入口在 store/index.js

  • vuexoneFn:从本地缓存拿 pid 和用户信息,请求聊天 token。
  • webSocketlinkFn:根据 token 建立 WebSocket。
  • onopen:发送准备消息,开启心跳。
  • onmessage:把最新消息塞到全局 lastmsg
  • reconnect:断开后做重连,最多尝试 10 次。
  • send:统一发送消息前判断 readyState

这个方向是对的:连接不应该散落在每个页面里。因为公屏页、私聊页、会话列表页、游戏页都要监听同一条链路,如果每个页面自己建一条,后面就很难控制。

简化后的结构大概是这样:

state: {
  socket: {},
  lastmsg: null,
  reconnectTimeoutTask: null,
  webSocketReconnectCount: 0,
  websocketToken: ''
}

连接建立成功后:

socket.onopen = () => {
  send({ code: 0 })      // 告诉服务端客户端已准备
  resetHeartbeat()       // 开启心跳
  send({ code: 6, token, message: { screenId } })
}

socket.onmessage = (event) => {
  state.lastmsg = event
}

页面侧不直接管理连接,只监听 lastmsg

computed: {
  ...mapState({
    lastmsg: state => state.lastmsg
  })
}

这就是一个很轻量的“前端实时消息总线”。

真正的难点:不是连接,而是消息归属

如果只有公屏聊天,一条消息进来直接 push 到列表就行。

但这个项目不是这样。pages/chat/chat.vue 里同一个 watcher 要处理多类消息:

  • code == 2:公屏聊天消息。
  • code == 3 / code == 4:某类游戏开始和结束。
  • code == 8 / code == 9:摇一摇游戏开始和结束。
  • code == 21 / code == 22:另一类活动开始和结束。
  • code == 70:私聊消息提醒。
  • code == 5:删除公屏消息。
  • code == 60:骰子游戏消息,实际由游戏页面继续消费。

这时候如果继续写成大段 if,代码能跑,但维护成本会越来越高。

更麻烦的是,消息的处理结果不一样:

  • 公屏消息要追加到当前列表并滚到底。
  • 私聊消息在公屏页只显示浮层和未读数。
  • 私聊详情页如果正好打开对应会话,就要追加到详情列表。
  • 会话列表页要把对应会话提到最上面,并更新未读数。
  • 游戏消息要驱动状态机,而不是简单追加 UI。

也就是说,一条 WebSocket 消息在前端不是“展示一下”这么简单,它要经过“识别、路由、归属、去重、落 UI 状态”几个步骤。

我后来更认可的拆法

如果现在重构,我不会让页面直接面对原始 code

我会把链路拆成三层:

WebSocket 连接层
  只负责连接、心跳、重连、关闭、发送

消息协议层
  把原始 code 转成业务事件
  例如 PUBLIC_CHAT_MESSAGE、PRIVATE_CHAT_MESSAGE、DICE_GAME_EVENT

页面消费层
  公屏页、私聊页、会话列表、游戏页只订阅自己关心的事件

伪代码大概是这样:

const messageTypeMap = {
  2: 'PUBLIC_CHAT_MESSAGE',
  5: 'PUBLIC_CHAT_DELETE',
  60: 'DICE_GAME_EVENT',
  70: 'PRIVATE_CHAT_MESSAGE',
  3: 'GAME_ENTRY_OPEN',
  4: 'GAME_ENTRY_CLOSE'
}

function normalizeMessage(rawEvent) {
  const msg = JSON.parse(rawEvent.data)
  return {
    type: messageTypeMap[msg.code] || 'UNKNOWN',
    code: msg.code,
    payload: msg.message,
    receivedAt: Date.now()
  }
}

页面里就不再关心 code == 70 这种细节,而是写业务判断:

if (event.type === 'PRIVATE_CHAT_MESSAGE') {
  updateUnread(event.payload)
}

这个拆法的好处不是“更优雅”,而是出问题时好查。

比如用户说“我在私聊页收不到消息”,你可以先看连接层有没有消息,再看协议层有没有识别成 PRIVATE_CHAT_MESSAGE,最后看私聊页有没有判断 conversationId。排查路径会清楚很多。

重连一定要做成单连接

项目里的 vuexoneFn 有一个关键判断:

if (state.socket.readyState == 1) return
if (state.socket.readyState == 0) return

这两行很重要。它避免了两种重复连接:

  • 已连接时重复创建连接。
  • 正在连接时又发起一次连接。

但只靠这两行还不够。因为真实环境里,重连触发点可能很多:

  • onclose 触发重连。
  • onerror 延迟重连。
  • 页面 onShow 发现连接断了也触发重连。
  • token 重新获取成功后又触发连接。

所以我更建议加一个显式的连接状态:

const SocketStatus = {
  IDLE: 'idle',
  CONNECTING: 'connecting',
  OPEN: 'open',
  RECONNECTING: 'reconnecting',
  CLOSED_BY_USER: 'closed_by_user'
}

连接层只允许状态这样流转:

idle -> connecting -> open
open -> reconnecting -> connecting
open -> closed_by_user
reconnecting -> closed_by_user

主动关闭和异常断开一定要分开。用户离开页面时主动关闭,不应该马上被 onclose 拉回重连;网络断开时关闭,才应该进入重连。

这也是很多实时 H5 项目里最容易忽略的点。

心跳也不是发个 ping 就完事

项目里用 10 秒定时发送一次 code: 0 心跳。

这能解决“服务端知道客户端还在”的问题,但如果想做得更稳,心跳要回答两个问题:

  1. 客户端有没有定时发出去?
  2. 服务端有没有在合理时间内回过来?

只发不收,最多说明前端还在努力发消息,不代表链路真的可用。

更完整的心跳机制可以这么做:

每 10 秒发送一次 ping
记录 pingAt
收到 pong 后记录 pongAt
如果连续 N 次 ping 没有 pong,主动 close 并进入 reconnect

在移动端 H5 里,这比等浏览器触发 onclose 更可靠。因为有些弱网环境下,连接处于一种“看起来没断,但消息进不来”的状态。

消息总线要考虑页面生命周期

uni-app H5 页面有 onShowonHideonUnload。这个项目在聊天室 onShow 里会检查全局 socket 状态:

if (store.state.socket.readyState != 1) {
  store.commit('reconnect')
}

这个动作很实际。因为用户切后台、支付返回、从游戏页回来,都可能导致连接中断。

不过这里有一个经验:页面只应该“请求恢复连接”,不应该自己接管连接。

也就是说,页面可以告诉连接层:

ensureSocketAlive()

但不能在页面里重新写一套 new WebSocketonmessageresetreconnect

项目里的聊天室页面其实还保留了一套局部 WebSocket 方法,这很像项目迭代中常见的痕迹:一开始页面自己连,后来抽到 Vuex,但老代码没有完全删除。这个点很适合在复盘里写出来,因为很多真实项目都是这样长大的。

我会怎么验证这个方案

实时消息系统不能只靠“看起来能收到消息”。

我会至少做这几组验证:

  1. 打开聊天室,发送公屏消息,确认只追加一次。
  2. 打开私聊详情,另一端发送私聊,确认当前会话直接追加,未读不重复加。
  3. 停留在公屏页,另一端发送私聊,确认只出现浮层和未读数。
  4. 停留在会话列表,另一端发送私聊,确认对应会话上移到第一条。
  5. 模拟断网 10 秒再恢复,确认不会出现两条 WebSocket 连接。
  6. 切后台再回前台,确认 onShow 只触发一次恢复连接。
  7. 连接失败超过最大重连次数,页面要有可见的降级提示,而不是静默失效。

验证重点不是“WebSocket 能不能连上”,而是“连接恢复后,消息会不会重复消费、漏消费、错页面消费”。

这个项目给我的经验

实时 H5 里的 WebSocket,最怕写成某个页面的工具函数。

只要业务里出现了这些特征,就应该提前按消息总线来设计:

  • 多个页面都要消费同一条实时连接。
  • 消息类型超过三种。
  • 页面之间存在未读数、弹窗、列表排序、详情追加这种联动。
  • 移动端存在切后台、支付跳转、授权返回。
  • 需要重连、心跳、异常恢复。

这个时候,“封装 WebSocket”只是第一步。真正要封装的是:

连接生命周期 + 消息协议 + 业务分发 + 页面状态同步

否则项目越做越像在 watch(lastmsg) 里加补丁。短期能跑,长期会让每一次新活动都变成风险。

我更愿意把它看成一个小型实时消息系统。哪怕项目规模不大,只要消息会跨页面流动,就值得按系统来设计。

posted @ 2026-06-22 15:45  万物皆object  阅读(6)  评论(0)    收藏  举报