uni-app WebSocket 即时通信实战:心跳保活、断线重连与消息列表

uni-app WebSocket 即时通信实战:心跳保活、断线重连与消息列表

admin
2026-07-31 / 0 评论 / 4 阅读

客服聊天、订单状态推送、实时协作——WebSocket 是小程序实时通信的主力方案。但 uni.connectSocket 只是一根裸管道:弱网下说断就断、切后台被系统杀掉、消息去重和顺序全要自己做。本文实现一套健壮的 Socket 管理层,并以聊天消息列表为落地场景。

一、原生 API 的局限

// 裸用 uni.connectSocket 的典型代码
uni.connectSocket({ url: 'wss://api.example.com/ws' })
uni.onSocketMessage((res) => {
  console.log(res.data) // 然后呢?
})

生产环境马上会遇到的问题:

  1. 连接会静默断开:网络抖动、服务器超时、手机切后台,断了没人通知业务层
  2. 没有重连机制:断开后必须用户手动刷新页面
  3. 消息没有可靠性保障:发送方不知道对方收没收到,重复消息无法识别

所以要封装的核心能力:心跳检测 + 指数退避重连 + 消息 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 或首条消息鉴权)、同一账号多端登录的踢下线策略。

六、小程序特殊限制

  1. wss 必须:正式环境只能连 wss,且域名要配到 socket 合法域名
  2. 并发连接数:微信小程序同时最多 2 个 WebSocket 连接,多路复用靠消息 type 分发而不是开多条连接
  3. 后台挂起:切后台约 5 秒后收不到消息,所以离线补拉机制不是可选项而是必选项
  4. App 端差异:App 端没有 2 连接限制,但同样受省电策略影响,onHide/onShow 的断连重连逻辑全端通用

总结

  • SocketManager 四件套:心跳保活、指数退避重连(带随机抖动)、发送队列、ACK 确认
  • onHide 主动断、onShow 立即连、reconnected 事件触发离线补拉
  • 消息三要素:全局唯一 ID 去重、乐观更新上屏、失败可重发
  • 小程序最多 2 条并发连接,靠 type 分发复用单连接
  • 与后端先对齐协议(ping/ack/id)再动工,能省掉大量联调返工

这套 Socket 层写一次可以用在所有项目里,聊天、推送、协作编辑都只是消息 type 的差异。

0

评论 (0)

取消
0:00