去年做了一个客服 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),不用碰任何连接逻辑。
代码已经稳定跑了几个月,中间经历了几次大促流量高峰,没有再出现过上线第一晚那种情况。写这篇文章,一是梳理自己的思路,二是希望后来人少踩一个坑。
长连接这种东西,只要写完第一次能跑到生产,后面基本就不动了。所以前期的设计值得多花点时间。别嫌麻烦,这一两天的设计时间能省你一个通宵。

