客服聊天、订单状态推送、实时协作——WebSocket 是小程序实时通信的主力方案。但 uni.connectSocket 只是一根裸管道:弱网下说断就断、切后台被系统杀掉、消息去重和顺序全要自己做。本文实现一套健壮的 Socket 管理层,并以聊天消息列表为落地场景。
一、原生 API 的局限
// 裸用 uni.connectSocket 的典型代码
uni.connectSocket({ url: 'wss://api.example.com/ws' })
uni.onSocketMessage((res) => {
console.log(res.data) // 然后呢?
})生产环境马上会遇到的问题:
- 连接会静默断开:网络抖动、服务器超时、手机切后台,断了没人通知业务层
- 没有重连机制:断开后必须用户手动刷新页面
- 消息没有可靠性保障:发送方不知道对方收没收到,重复消息无法识别
所以要封装的核心能力:心跳检测 + 指数退避重连 + 消息 ACK。
二、Socket 管理类实现
// utils/socket.js
class SocketManager {
constructor(options = {}) {
this.url = options.url
this.heartbeatInterval = options.heartbeatInterval || 30000
this.reconnectMaxDelay = options.reconnectMaxDelay || 30000
this.task = null // SocketTask
this.isConnected = false
this.manualClosed = false // 主动关闭标记,区分意外断开
this.heartbeatTimer = null
this.reconnectTimer = null
this.reconnectCount = 0 // 重连次数(用于退避)
this.messageHandlers = new Map() // 按消息类型分发
this.pendingQueue = [] // 未连接时的待发送队列
this.seq = 0 // 消息序号,用于 ACK
this.pendingAcks = new Map() // seq -> { resolve, timer }
}
// ========== 连接管理 ==========
connect() {
if (this.isConnected) return
this.manualClosed = false
// uni-app 返回 SocketTask,用任务级监听而非全局监听(支持多连接)
this.task = uni.connectSocket({
url: this.url,
success: () => {},
fail: () => this.scheduleReconnect()
})
this.task.onOpen(() => {
this.isConnected = true
this.reconnectCount = 0
this.startHeartbeat()
// 重连成功后:重新拉取离线消息(关键!)
this.emit('reconnected')
// 冲积积压消息
while (this.pendingQueue.length) {
const payload = this.pendingQueue.shift()
this.task.send({ data: JSON.stringify(payload) })
}
})
this.task.onMessage((res) => this.handleMessage(res))
this.task.onClose(() => {
this.isConnected = false
this.stopHeartbeat()
if (!this.manualClosed) {
this.scheduleReconnect() // 意外断开才重连
}
})
this.task.onError(() => {
this.isConnected = false
this.stopHeartbeat()
if (!this.manualClosed) {
this.scheduleReconnect()
}
})
}
close() {
this.manualClosed = true
this.stopHeartbeat()
clearTimeout(this.reconnectTimer)
this.task && this.task.close({})
}
// ========== 指数退避重连 ==========
scheduleReconnect() {
if (this.manualClosed) return
if (this.reconnectTimer) return // 已有重连任务
this.reconnectCount++
if (this.reconnectCount > 10) {
this.emit('dead') // 放弃重连,通知上层
return
}
// 指数退避:1s 2s 4s 8s...封顶 30s,加随机抖动防止雪崩
const delay = Math.min(
1000 * Math.pow(2, this.reconnectCount - 1),
this.reconnectMaxDelay
) + Math.random() * 1000
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null
this.connect()
}, delay)
}
// ========== 心跳保活 ==========
startHeartbeat() {
this.stopHeartbeat()
this.heartbeatTimer = setInterval(() => {
// 期望 5 秒内收到 pong,超时视为假死
this.sendWithTimeout({ type: 'ping' }, 5000)
.catch(() => {
// pong 没回来:连接假死,强制重建
this.task && this.task.close({})
this.isConnected = false
this.scheduleReconnect()
})
}, this.heartbeatInterval)
}
stopHeartbeat() {
clearInterval(this.heartbeatTimer)
this.heartbeatTimer = null
}
// ========== 消息处理 ==========
handleMessage(res) {
let msg
try {
msg = JSON.parse(res.data)
} catch (e) {
return
}
// 心跳响应单独处理
if (msg.type === 'pong') {
this.emit('_pong', msg)
return
}
// ACK 响应:匹配待确认消息
if (msg.type === 'ack' && this.pendingAcks.has(msg.ackFor)) {
const pending = this.pendingAcks.get(msg.ackFor)
clearTimeout(pending.timer)
this.pendingAcks.delete(msg.ackFor)
pending.resolve()
return
}
// 业务消息按类型分发
this.emit(msg.type, msg)
}
// ========== 发送(带 ACK 确认)==========
send(data, { ack = true } = {}) {
if (!this.isConnected) {
// 未连接先入队,连接成功后冲积
this.pendingQueue.push(data)
return Promise.resolve()
}
if (!ack) {
return new Promise((resolve, reject) => {
this.task.send({ data: JSON.stringify(data), success: resolve, fail: reject })
})
}
// 带 seq 的可靠发送:超时未 ACK 视为失败
this.seq++
const payload = { ...data, seq: this.seq }
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
this.pendingAcks.delete(this.seq)
reject(new Error('消息未确认'))
}, 10000)
this.pendingAcks.set(this.seq, { resolve, timer })
this.task.send({ data: JSON.stringify(payload) })
})
}
sendWithTimeout(data, timeout) {
return Promise.race([
this.send(data),
new Promise((_, reject) => setTimeout(reject, timeout))
])
}
// ========== 事件订阅 ==========
on(event, handler) {
if (!this.messageHandlers.has(event)) {
this.messageHandlers.set(event, new Set())
}
this.messageHandlers.get(event).add(handler)
return () => this.off(event, handler) // 返回取消函数
}
off(event, handler) {
this.messageHandlers.get(event)?.delete(handler)
}
emit(event, payload) {
this.messageHandlers.get(event)?.forEach(fn => fn(payload))
}
}
export default SocketManager三、与生命周期联动
Socket 生命周期必须挂到 App 和页面上:
// App.vue
import SocketManager from '@/utils/socket'
import { useUserStore } from '@/stores/user'
let socket = null
export default {
onLaunch() {
socket = new SocketManager({
url: `wss://api.example.com/ws?token=${useUserStore().token}`
})
uni.$socket = socket
},
onShow() {
// 从后台回前台:直接重连(后台时连接多半已被杀)
uni.$socket.connect()
},
onHide() {
// 切后台:主动断开省电省流量,回前台再连
uni.$socket.close()
}
}切后台策略:小程序切后台后 WebSocket 会在数秒内被系统挂起,与其等它假死,不如 onHide 主动 close、onShow 立即重连,配合服务端的离线消息补拉,体验反而更好。
四、实战:聊天消息列表
<script setup>
import { ref, nextTick } from 'vue'
const messages = ref([])
const inputText = ref('')
const scrollTo = ref('')
let offHandlers = []
onLoad() {
const socket = uni.$socket
// 订阅新消息
offHandlers.push(
socket.on('chat:message', (msg) => {
// 去重:服务端消息 ID
if (messages.value.some(m => m.id === msg.id)) return
messages.value.push({
id: msg.id,
content: msg.content,
fromMe: false,
time: msg.time
})
scrollToBottom()
}),
// 重连后补拉离线消息
socket.on('reconnected', async () => {
const lastId = messages.value.length
? messages.value[messages.value.length - 1].id
: null
const offline = await request.get('/chat/messages', {
afterId: lastId,
limit: 50
})
// 按时间合并去重
const existIds = new Set(messages.value.map(m => m.id))
offline.filter(m => !existIds.has(m.id))
.forEach(m => messages.value.push({
id: m.id, content: m.content, fromMe: false, time: m.time
}))
scrollToBottom()
})
)
}
onUnload() {
// 页面销毁必须解绑,否则消息处理器泄漏
offHandlers.forEach(off => off())
}
async function send() {
const text = inputText.value.trim()
if (!text) return
inputText.value = ''
// 乐观更新:先上屏,发送失败再标记
const tempId = `temp_${Date.now()}`
messages.value.push({
id: tempId, content: text, fromMe: true,
time: Date.now(), sending: true
})
scrollToBottom()
try {
await uni.$socket.send({ type: 'chat:send', content: text })
const msg = messages.value.find(m => m.id === tempId)
if (msg) msg.sending = false
} catch (e) {
const msg = messages.value.find(m => m.id === tempId)
if (msg) {
msg.sending = false
msg.failed = true // 标记失败,支持点击重发
}
}
}
function scrollToBottom() {
nextTick(() => {
scrollTo.value = `msg-${messages.value.length - 1}`
})
}
</script>
<template>
<scroll-view scroll-y class="chat-list" :scroll-into-view="scrollTo">
<view
v-for="(msg, index) in messages"
:key="msg.id"
:id="`msg-${index}`"
class="msg-row"
:class="{ mine: msg.fromMe }"
>
<view class="bubble">
<text>{{ msg.content }}</text>
<text v-if="msg.sending" class="status">…</text>
<text v-else-if="msg.failed" class="status failed" @click="resend(msg)">!</text>
</view>
</view>
</scroll-view>
<view class="input-bar">
<input v-model="inputText" confirm-type="send" @confirm="send" />
<button size="mini" @click="send">发送</button>
</view>
</template>几个体验细节:
- 乐观更新 + 状态标记:消息立即上屏,sending 三点、failed 感叹号可点重发
- scroll-into-view 滚到底:比手动计算 scroll-top 简单可靠
- onUnload 解绑:
on()返回的取消函数必须调用,否则页面销毁后 handler 仍在跑
五、服务端配合要点
前端这层机制需要后端按约定配合,联调前对齐协议:
// 客户端 ping → 服务端 pong
{ "type": "ping" }
{ "type": "pong" }
// 客户端带 seq 发送 → 服务端回 ack
{ "type": "chat:send", "content": "hello", "seq": 1001 }
{ "type": "ack", "ackFor": 1001 }
// 服务端推送(带全局唯一 id,客户端用于去重)
{ "type": "chat:message", "id": "srv_888888", "content": "hi", "time": 1724360000000 }服务端还需要:消息落库 + 离线消息接口(客户端重连后按 afterId 补拉)、单连接的 token 校验(URL 带 token 或首条消息鉴权)、同一账号多端登录的踢下线策略。
六、小程序特殊限制
- wss 必须:正式环境只能连 wss,且域名要配到 socket 合法域名
- 并发连接数:微信小程序同时最多 2 个 WebSocket 连接,多路复用靠消息 type 分发而不是开多条连接
- 后台挂起:切后台约 5 秒后收不到消息,所以离线补拉机制不是可选项而是必选项
- App 端差异:App 端没有 2 连接限制,但同样受省电策略影响,onHide/onShow 的断连重连逻辑全端通用
总结
- SocketManager 四件套:心跳保活、指数退避重连(带随机抖动)、发送队列、ACK 确认
- onHide 主动断、onShow 立即连、reconnected 事件触发离线补拉
- 消息三要素:全局唯一 ID 去重、乐观更新上屏、失败可重发
- 小程序最多 2 条并发连接,靠 type 分发复用单连接
- 与后端先对齐协议(ping/ack/id)再动工,能省掉大量联调返工
这套 Socket 层写一次可以用在所有项目里,聊天、推送、协作编辑都只是消息 type 的差异。
评论 (0)