uni-app 长连接翻车复盘:从页面里到处 connectSocket 到单例管理器

2026-10-05 0 735

去年做了一个客服 IM 项目,App 端用 uni-app 写。功能不复杂:用户和客服在线沟通,消息实时到达,支持文字和图片。开发测试都挺顺利的,演示时候也没问题。上线第一天收到十几个投诉电话,都是同一个现象:消息延迟,有时候干脆收不到,退出重进一下就好了。

那个晚上我熬到凌晨三点。查到最后,问题特别讽刺——不是因为长连接写得不好,恰恰是因为长连接写得太”好”了。整个项目里,每个页面只要需要收消息,都自己 uni.connectSocket() 来一遍,客服列表页、会话页、通知页、订单详情页……同一个 App 会话里同时挂着七八条 WebSocket。

服务端推送可以按连接推送,但它显然不知道怎么推给”这个用户”,只能推给这条连接见过的一个 userId。而一个用户同时有七八条连接,服务端推送时挑一个推,运气不好推到了另一条上,用户当前页面看着的这条连接就收不到。

这篇文章把整个翻车过程和修复过程写出来,希望能让你少走一遍这个弯路。

uni.app 里 WebSocket 长什么样

先把基础 API 摆出来。uni-app 提供的 WebSocket API 跟浏览器上的 WebSocket 对象不一样,是下面这一套:

const task = uni.connectSocket({
  url: 'wss://im.example.com/ws',
  complete() {}
})

task.onOpen(() => {
  console.log('connected')
})

task.onMessage((res) => {
  console.log('recv:', res.data)
})

task.onClose(() => {
  console.log('closed')
})

task.onError((err) => {
  console.error('error:', err)
})

// 发送
task.send({ data: JSON.stringify({ type: 'text', content: 'hi' }) })

// 主动关闭
task.close()

注意这里两个关键点。

第一,uni.connectSocket() 返回的是一个 SocketTask 对象。你必须把这个 task 保存好,后续所有操作都基于它,包括发消息、监听事件、关闭连接。如果丢了 task 引用,这条连接就等于失控了,你既不能给它发消息,也不能主动关闭。

第二,事件监听是绑在 task 上的,不是全局的。这一点特别容易踩坑。开发时如果习惯性地以为”事件是全局的,只要有连接就会回调”,很容易写出下面这种代码:

// 在页面 A 里
uni.connectSocket({ url })
uni.onSocketMessage(res => {
  console.log('A got:', res.data)
})

// 在页面 B 里
uni.connectSocket({ url })
uni.onSocketMessage(res => {
  console.log('B got:', res.data)
})

这段代码看着好像两个页面各自监听,实际上 uni.onSocketMessage 注册的是全局回调,会覆盖上一个。最终只有一个回调生效。这就是典型的”看起来两条连接,实际上是一条被反复覆盖”。这种 bug 在真机上偶发,在模拟器上完全正常,特别难查。

为什么每个页面各自连是错的

除了上面那个回调覆盖的坑,还有几个更根本的原因。

第一,同一条 ws 连接不能被多处独立使用。服务端通常维护一个”user_id -> connection”的映射表。如果同一个 user_id 出现多条连接,服务端要么覆盖旧的(把老连接踢掉),要么在推送时挑一条推。无论哪种策略都对你不利:覆盖策略会导致”客服列表页一断,聊天页也断”;挑选策略会导致”明明在线却收不到消息”。

第二,连接建立的开销很大。TCP 建连、TLS 握手(wss 场景下)、服务端鉴权、订阅主题,一套下来至少 300 毫秒,网络差的时候到 2 秒。如果每个页面都新开一条,从列表页点到聊天页就得等一次握手,体验能好才怪。

第三,多页面共享状态变复杂。未读数、在线状态、推送公告这些都跟连接状态相关。如果每条连接是独立的,那多个页面对同一个状态就会各自维护一份,同步变成噩梦。

结论很清楚:整个 App 会话应该只有一条 WebSocket 连接。这一条连接到顶层的状态管理里,所有页面通过订阅接口来获取自己关心的消息。

单例管理器设计

整个方案的核心是一个单例。它管三件事:连接生命周期、消息路由、订阅关系。

先看骨架:

// utils/socket-manager.js

class SocketManager {
  constructor() {
    this.task = null
    this.status = 'idle'         // idle | connecting | open | closing | closed
    this.url = ''
    this.token = ''
    this.listeners = new Map()    // 事件总线:topic -> Set<callback>
    this.pendingMessages = []     // 未连接时积压的消息
    this.heartbeatTimer = null
    this.reconnectTimer = null
    this.reconnectAttempts = 0
    this.manualClose = false      // 用户主动 close 的标志
  }

  // ... 下面依次展开
}

export const socketManager = new SocketManager()

这里每个字段都有用,简单解释一下。

task 是 SocketTask 的引用,全靠它收发消息。status 是个内部状态机,防止并发 connect 重复建连。listeners 是一个 Map,key 是消息类型(比如 'chat.message'、'notify.broadcast'),value 是回调集合。所有页面调 on('chat.message', callback) 就是往这里塞;消息到达时按类型分发。pendingMessages 用来缓存连接还没建立时用户发出的消息,连接建立后自动补发。

连接:保证幂等

任何地方调 connect(),不管当前是什么状态,都应该保证最终只连一条。

async connect({ url, token }) {
  // 已经连上了,或者是同一个配置,直接返回
  if (this.status === 'open') return
  if (this.status === 'connecting' && this.url === url && this.token === token) {
    return
  }

  // 配置变了,先断开旧的
  if (this.task && (this.url !== url || this.token !== token)) {
    await this.disconnect(false)
  }

  this.url = url
  this.token = token
  this.status = 'connecting'
  this.manualClose = false

  return new Promise((resolve, reject) => {
    const task = uni.connectSocket({
      url,
      header: {
        'Authorization': 'Bearer ' + token,
        'X-Client': 'uniapp-app'
      },
      success: () => { /* URL 合法即触发,不代表连接建立 */ },
      fail: (err) => {
        this.status = 'closed'
        reject(err)
      }
    })

    this.task = task

    task.onOpen(() => {
      this.status = 'open'
      this.reconnectAttempts = 0
      this.startHeartbeat()
      this.flushPending()
      this.emit('_status', 'open')
      resolve()
    })

    task.onMessage((res) => {
      this.handleMessage(res.data)
    })

    task.onClose(() => {
      const wasOpen = this.status === 'open'
      this.status = 'closed'
      this.stopHeartbeat()
      this.emit('_status', 'closed')

      // 非用户主动关闭,触发重连
      if (!this.manualClose) {
        this.scheduleReconnect()
      }
    })

    task.onError((err) => {
      this.emit('_status', 'error', err)
      // onError 之后 onClose 一般也会触发,不在这里重连
    })
  })
}

这里有三个细节值得展开。

第一,uni.connectSocket() 的 success 回调不代表连接建立。它只表示”URL 语法合法、请求已发出”。连接是否成功,必须通过 onOpen 判断。很多人在这里写错,导致业务代码以为连上了,实际上还没建立,发消息全丢。

第二,重连只在 onClose 里做,不在 onError 里做。因为 WebSocket 的错误事件之后通常还会跟着一个关闭事件。如果两个地方都触发重连,可能会同时开两条。只认 onClose 这一个终态,逻辑最干净。

第三,manualClose 标志是必须的。用户主动退出登录、切换账号时,我们不希望触发重连。用一个标志位区分”被动断开”和”主动关闭”。

心跳:别偷懒

很多人觉得 WebSocket 是长连接,断了浏览器/系统会自动重连。这是错的。

移动网络下,情况尤其糟糕。用户从 WiFi 切换成 4G,或者从地铁进隧道再出来,网络链路会在系统层面悄悄断掉。但 JavaScript 层的 onClose 事件不一定会触发——因为四层链路断了,应用层根本感知不到,连接看起来还在”open”状态。等你十分钟后想发消息,才发现发不出去。这时候重连已经晚了。

唯一的应对方式是心跳。客户端定时往服务端发一个 ping,服务端回一个 pong。如果连续几次收不到响应,客户端就主动断开并触发重连。

startHeartbeat() {
  this.stopHeartbeat()

  this.heartbeatTimer = setInterval(() => {
    if (this.status !== 'open') return

    const now = Date.now()
    // 上次收到 pong 的时间超过 2 个心跳周期,认为连接已死
    if (this.lastPongAt && now - this.lastPongAt > 45000) {
      console.warn('[socket] heartbeat timeout, force close')
      this.task && this.task.close({ code: 4000, reason: 'heartbeat timeout' })
      return
    }

    this.task && this.task.send({
      data: JSON.stringify({ type: '_ping', ts: now })
    })
  }, 20000)
}

stopHeartbeat() {
  if (this.heartbeatTimer) {
    clearInterval(this.heartbeatTimer)
    this.heartbeatTimer = null
  }
}

心跳周期我一般设 20 秒。太频繁了费电,尤其是 App 后台运行的时候;太长了对网络异常反应慢。20 秒是个平衡点,配合 45 秒的超时阈值,可以覆盖大部分移动网络的切换场景。

在 handleMessage 里,遇到 pong 消息要处理一下:

handleMessage(raw) {
  let msg
  try {
    msg = JSON.parse(raw)
  } catch (e) {
    console.warn('[socket] invalid message:', raw)
    return
  }

  if (msg.type === '_pong') {
    this.lastPongAt = Date.now()
    return
  }

  // 其他消息按 type 分发
  this.emit(msg.type, msg.payload)
}

断线重连:退避要聪明

重连不能一断开就立刻连,那样网络短暂抖动时会把服务端打爆。合适的做法是指数退避,加一点随机抖动。

scheduleReconnect() {
  if (this.reconnectTimer) return

  const attempts = this.reconnectAttempts++
  // 1s, 2s, 4s, 8s, 15s, 15s, 15s ... 封顶 15 秒
  const baseDelay = Math.min(1000 * Math.pow(2, attempts), 15000)
  // 加 ±20% 抖动,防止多个客户端同时重连
  const jitter = baseDelay * 0.2 * (Math.random() - 0.5)
  const delay = baseDelay + jitter

  console.log(`[socket] reconnect in ${Math.round(delay)}ms (attempt ${attempts + 1})`)
  this.emit('_status', 'reconnecting', { attempt: attempts + 1, delay })

  this.reconnectTimer = setTimeout(() => {
    this.reconnectTimer = null
    this.connect({ url: this.url, token: this.token }).catch(() => {
      // 连接失败,下一次 onClose 或这里会继续 scheduleReconnect
      // 但 connect 失败不一定触发 onClose,所以这里手动调度
      this.scheduleReconnect()
    })
  }, delay)
}

回退序列是 1、2、4、8、15、15、15 秒,封顶 15 秒。为什么封顶是 15 秒而不是 60 秒?因为移动网络恢复很快,真的要等 60 秒,用户的体验就崩了。15 秒是一个既能避免服务端被打爆,又不至于让用户等太久的平衡点。

抖动不能省。如果服务端重启,所有客户端都在同一秒重连,抖动能把并发峰值削掉一部分。

订阅机制:让页面只关心自己的消息

消息分发是另一个核心。不能所有消息都推给所有页面,那样页面里到处是 if-else。用发布订阅模式把那层判断隔离出来。

on(topic, callback) {
  if (!this.listeners.has(topic)) {
    this.listeners.set(topic, new Set())
  }
  this.listeners.get(topic).add(callback)

  // 返回取消订阅函数
  return () => {
    const set = this.listeners.get(topic)
    if (set) {
      set.delete(callback)
      if (set.size === 0) this.listeners.delete(topic)
    }
  }
}

emit(topic, payload) {
  const set = this.listeners.get(topic)
  if (!set || set.size === 0) return

  // 复制一份再遍历,防止回调内部修改 listeners
  Array.from(set).forEach(cb => {
    try {
      cb(payload)
    } catch (err) {
      console.error(`[socket] listener error on ${topic}:`, err)
    }
  })
}

页面里的使用方式:

<script setup>
import { onMounted, onUnmounted } from 'vue'
import { socketManager } from '@/utils/socket-manager'

let unsubscribe = null

onMounted(() => {
  unsubscribe = socketManager.on('chat.message', (payload) => {
    // 只处理属于当前会话的消息
    if (payload.sessionId !== currentSessionId.value) {
      unreadCount.value++
      return
    }
    messages.value.push(payload)
  })
})

onUnmounted(() => {
  unsubscribe && unsubscribe()
})
</script>

这段代码里最重要的是 onUnmounted 里取消订阅。如果不取消,页面销毁了回调还在,下次消息来了会直接修改已经销毁的组件状态,引发各种诡异报错。用 Vue3 的 setup 语法里,onUnmounted 一定要写。

如果记性不好容易忘,可以在页面组件里封装一个钩子:

export function useSocket(topic, handler) {
  let unsubscribe = null
  onMounted(() => { unsubscribe = socketManager.on(topic, handler) })
  onUnmounted(() => { unsubscribe && unsubscribe() })
}

页面里就能写成:

useSocket('chat.message', payload => { ... })
useSocket('notify.broadcast', payload => { ... })

干净很多,也不会漏。这个小封装是我最喜欢的东西之一。

离线消息队列

用户点发送的那一刻,如果连接不可用,消息不能丢。要么显示”发送失败”让用户重试,要么先暂存,等连接恢复自动补发。前端的常见做法是后者,体验更好。

send(payload, options = {}) {
  const message = {
    ...payload,
    _clientId: this.genClientId(),
    _ts: Date.now()
  }

  const raw = JSON.stringify(message)

  if (this.status !== 'open') {
    // 排队等连接
    this.pendingMessages.push(raw)
    this.emit('_outgoing', { status: 'pending', message })
    return Promise.resolve({ queued: true, clientId: message._clientId })
  }

  return new Promise((resolve, reject) => {
    this.task.send({
      data: raw,
      success: () => {
        this.emit('_outgoing', { status: 'sent', message })
        resolve({ clientId: message._clientId })
      },
      fail: (err) => {
        this.pendingMessages.push(raw)
        this.emit('_outgoing', { status: 'pending', message })
        reject(err)
      }
    })
  })
}

flushPending() {
  if (this.pendingMessages.length === 0) return

  const list = this.pendingMessages.splice(0)
  console.log(`[socket] flushing ${list.length} pending messages`)

  list.forEach(raw => {
    this.task.send({
      data: raw,
      fail: () => {
        // 补发失败,再排回队列
        this.pendingMessages.push(raw)
      }
    })
  })
}

genClientId() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 8)
}

这里 _clientId 是关键。服务端收到消息后,会在 ack 里回传这个 ID。前端可以根据它把”本地已发送但未确认”的消息替换成”已确认”。微信那种消息旁边的转圈圈和打勾,就是这么实现的。

如果要求更高,可以把 pendingMessages 落到本地存储里,App 被系统杀掉后重开,未发送的消息还能恢复。这一层要不要做,看业务场景。

多端差异处理

uni-app 最大的特点是”一次开发,多端运行”。WebSocket 在不同端上确实有些差异。

App 端:后台运行时,连接会被系统挂起。iOS 尤其严格,App 进入后台几秒后就冻结所有网络活动。回到前台时需要主动检查连接状态,如果断了要重连。

H5 端:如果页面被切到后台标签页,浏览器可能会节流定时器。心跳的 setInterval 可能间隔变长。回到前台时应该重新计算时间和重连。

小程序端:微信小程序对于 WebSocket 有并发上限(通常是 5 条),超过会报错。此外小程序后台运行时连接会被主动断开,回到前台需要重连。

处理方式是在 App 的生命周期里加检查:

// App.vue
onShow() {
  socketManager.ensureConnected()
}

onHide() {
  // App 进入后台,可以选择保留或断开连接
  // 我不建议在 onHide 里主动断开,因为用户很快回来的话重新连接体验很差
}

ensureConnected() 里做的事情是:如果当前状态不是 ‘open’,就触发一次重连。注意不要在这里无脑调 connect(),因为可能已经连上了,重复调用会打乱状态机。

ensureConnected() {
  if (this.status === 'open') return
  if (this.status === 'connecting') return
  this.scheduleReconnect()
}

顺手提一句:如果做的是 IM 类的 App,还需要考虑推送兜底。App 进入后台之后 WebSocket 一定会断,此时新消息只能靠 APNs 或者厂商推送通道到达。也就是说,WebSocket 只是前台在线时的快通道,推送通道是断线时的兜底。两条链路要同时设计,别指望 WebSocket 全包。

压测和监控

改完之后我们做了一轮压测。测试场景模拟 200 个客户端同时在线,随机发消息、随机断网重连、随机切后台。

指标 改造前 改造后
平均连接数(单客户端) 3.7 1.0
消息到达率 82% 99.6%
断线检测平均耗时 不检测,永久假死 45 秒(心跳超时)
网络恢复后重连耗时 N/A 1.2 秒(首次退避)
后台切回前台消息恢复 需要手动退出重进 自动补发 100%

监控这块,我加了几个埋点:连接建立成功/失败次数、心跳超时次数、重连次数、消息发送失败次数、消息接收延迟(用 msg._ts 和当前时间差计算)。这些指标放到我们自己的监控系统里,过去一周稳定运行,异常基本一眼能看出来。

几个额外踩到的坑

坑一:onSocketOpen 在不同端上的时序不一样。App 端和 H5 端的 onOpen 触发时机略有差异,如果你的代码依赖”onOpen 一定在 connect 之后立刻触发”这个假设,可能会在某个端上偶发失败。用 Promise 封装,等 resolve 再进行后续操作最稳。

坑二:uni.closeSocket() 和 task.close() 不是一回事。前者是关闭全局的所有 socket,后者只关闭当前 task 对应的连接。单例模式下我们要的是后者,不要用 uni.closeSocket(),可能误伤到别的库的 WebSocket。

坑三:服务端重启时,客户端重连报文里要带 resume token。否则服务端认不出这个客户端是不是之前那个,可能会拒绝重连或者重新走一遍鉴权。我们在 connect 的 header 里带了上一次连接的 sessionId,服务端能在 5 分钟的窗口内识别为同一会话,直接恢复。

坑四:task.send() 里 data 字段在 H5 端有大小限制。超过 1MB 的字符串可能会被拒绝。图片这类大文件不要走 WebSocket,走 HTTP 上传,然后通过 WebSocket 推一个文件 URL 过去。这个反得很早,没翻车。

写在最后

这次翻车让我最大的感触是:长连接不是”每个页面都连一下”这么简单。它是一份全局资源,必须用单例的方式管理。多页面的场景下,”每页各连”看着很解耦,实际上把复杂度嫁接到了服务端和排障上。

我们现在的做法是把 socketManager 放在 App.vue 里初始化一次,所有页面通过订阅接口获取消息。加新页面只需要一行 useSocket('xxx', cb),不用碰任何连接逻辑。

代码已经稳定跑了几个月,中间经历了几次大促流量高峰,没有再出现过上线第一晚那种情况。写这篇文章,一是梳理自己的思路,二是希望后来人少踩一个坑。

长连接这种东西,只要写完第一次能跑到生产,后面基本就不动了。所以前期的设计值得多花点时间。别嫌麻烦,这一两天的设计时间能省你一个通宵。

uni-app 长连接翻车复盘:从页面里到处 connectSocket 到单例管理器
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 uniapp uni-app 长连接翻车复盘:从页面里到处 connectSocket 到单例管理器 https://www.taomawang.com/web/uniapp/2895.html

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务