去年接手了一个客服对话类的小程序项目,核心需求是保持客户端与服务器之间的双向实时通信。一开始用 `uni.connectSocket` 直接写了一版,简单场景跑着还好,没过两天测试那边就提了好几个问题:把小程序切到后台再回来,消息就收不到了;网络从 WiFi 切换到 4G,连接悄悄断了但页面没有任何反应;用户同时打开聊天列表和对话页,两个页面各建了一个 WebSocket 连接,服务端日志里同一个用户出现了两次。这些细碎的问题单个看都不大,堆在一起就是一套完整的长连接管理方案缺失的表现。
后来用了一个多星期的碎片时间,从零搭了一套在 UniApp 中可复用的 WebSocket 管理器,专门解决跨端环境下连接建立、保活、重连以及全局消息分发这些脏活累活。这篇文章就把整个设计和实现过程完整写出来,包括踩过的坑和最终落地的代码,如果你正在做即时通讯相关的需求,应该能直接拿去用。
一、先明确目标:这个管理器到底要解决哪些问题
在做任何封装之前,我先把测试那边提的问题和自己的预期整理成了一份功能清单。一个能在生产环境用的 WebSocket 管理器,至少要处理这几件事:
- 单例连接:不管多少个页面需要 WebSocket 服务,全局只能有一条连接,避免重复建连和资源浪费。
- 心跳机制:定期发送心跳包让服务端知道客户端还在线,同时也能检测出连接是否已经僵死。
- 断线重连:网络波动、切后台、弱网导致连接断开后,能自动按策略重连,并且把重连期间的消息缓存下来,恢复后补发。
- 消息分发:不同页面可能需要订阅不同类型的消息,管理器需要支持多观察者的消息推送,页面注销时自动取消订阅。
- 跨端兼容:小程序、App、H5 的 WebSocket API 行为有细微差异,管理器内部要抹平这些差异。
整理完这些,我决定把管理器做成一个纯逻辑模块,不依赖任何页面实例,这样可以在 App 启动时就初始化,页面里只需要按需订阅消息即可。
二、核心骨架:单例SocketManager的搭建
先搭出最基本的骨架,用 ES6 的 class 实现单例模式,把 UniApp 的连接、消息、关闭等原生 API 做一层薄薄的包装。
class SocketManager {
constructor() {
// 单例保证
if (SocketManager.instance) return SocketManager.instance;
this.socketTask = null;
this.url = '';
this.heartbeatTimer = null;
this.reconnectTimer = null;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 10;
this.reconnectInterval = 2000; // 初始重连间隔2秒
this.isManualClose = false; // 是否主动关闭,用于区分断线类型
this.messageQueue = []; // 重连期间暂存的消息
this.listeners = new Map(); // 消息监听者集合
SocketManager.instance = this;
}
static getInstance() {
if (!SocketManager.instance) {
return new SocketManager();
}
return SocketManager.instance;
}
connect(url) {
this.url = url;
this.isManualClose = false;
this._doConnect();
}
_doConnect() {
this.socketTask = uni.connectSocket({
url: this.url,
success: () => {
console.log('WebSocket 连接创建成功');
},
fail: (err) => {
console.error('WebSocket 创建失败', err);
this._tryReconnect();
}
});
this.socketTask.onOpen(() => {
console.log('WebSocket 已连接');
this.reconnectAttempts = 0;
this.reconnectInterval = 2000;
this._startHeartbeat();
this._flushMessageQueue();
});
this.socketTask.onMessage((res) => {
this._dispatchMessage(res.data);
});
this.socketTask.onError((err) => {
console.error('WebSocket 错误', err);
});
this.socketTask.onClose(() => {
this._stopHeartbeat();
if (!this.isManualClose) {
this._tryReconnect();
}
});
}
}
这里有三个设计决策需要解释一下。第一个是 `isManualClose` 标志位,用来区分连接是由于客户端主动调用 `close` 关闭的,还是因为网络等原因被动断开的。只有被动断开才需要自动重连。第二个是 `messageQueue`,它负责存储在连接断开期间业务层尝试发送的消息,等重连成功后再一并发出。第三个是 listeners Map,稍后会用来实现多页面消息订阅。
三、心跳机制:不只是定时发包那么简单
心跳有两个作用:告诉服务端客户端还活着,以及检测连接是否已经僵死。UniApp 的 WebSocket 在部分平台(尤其是小程序)上,如果因为网络变化导致连接中断,`onClose` 回调可能不会立刻触发,出现“假连接”状态。因此客户端不能完全依赖 `onClose`。
我采用的策略是:客户端每隔 15 秒发送一个心跳 ping 帧,同时记录发送时间。如果 10 秒内收到服务端的 pong 回复,则连接正常;如果在等待 pong 期间 `onClose` 被触发,说明底层已经断开;如果超过 10 秒既没收到 pong 也没有 `onClose`,则主动断开并进入重连流程。
_startHeartbeat() {
this._clearHeartbeat();
this.lastPingTime = Date.now();
this.pongReceived = true;
this.heartbeatTimer = setInterval(() => {
// 发送心跳
this.send({ type: 'ping' });
this.lastPingTime = Date.now();
this.pongReceived = false;
// 启动超时检测
setTimeout(() => {
if (!this.pongReceived) {
console.warn('心跳超时,主动断开重连');
this.socketTask.close();
this._tryReconnect();
}
}, 10000);
}, 15000);
}
_stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
对应的,在 `_dispatchMessage` 里需要识别 pong 消息,把 `pongReceived` 标记为 true:
_dispatchMessage(raw) {
let data;
try {
data = JSON.parse(raw);
} catch {
data = raw;
}
// 处理心跳回复
if (data && data.type === 'pong') {
this.pongReceived = true;
return;
}
// 普通消息分发给订阅者
this.listeners.forEach((callback, key) => {
callback(data);
});
}
这个心跳方案在 iOS 小程序切后台的场景下需要额外注意。小程序进入后台后,JavaScript 定时器会被冻结,`setInterval` 暂停。等小程序回到前台,定时器会继续执行,这时候会一次性发送之前堆积的所有心跳包,服务端可能会误判。解决办法是在 App.vue 的 `onShow` 里重置心跳状态,而不是依赖定时器直接恢复。
四、重连策略:退避和消息暂存
重连不能无脑尝试,需要用退避算法避免在服务端已经压力大的时候继续冲击。我实现了一个简单的指数退避,初始间隔 2 秒,每次重连失败后翻倍,最大间隔 30 秒,最多尝试 10 次。重连成功后重置所有状态。
_tryReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('重连次数已达上限,停止重连');
return;
}
if (this.reconnectTimer) return;
this.reconnectAttempts++;
const delay = Math.min(this.reconnectInterval * Math.pow(2, this.reconnectAttempts - 1), 30000);
console.log(`将在 ${delay}ms 后进行第 ${this.reconnectAttempts} 次重连`);
this.reconnectTimer = setTimeout(() => {
this.reconnectTimer = null;
this._doConnect();
}, delay);
}
消息队列的实现比较直接,在 `send` 方法里判断当前连接状态,如果 socket 未连接就把消息 push 到队列里:
send(data) {
const message = typeof data === 'string' ? data : JSON.stringify(data);
if (this.socketTask && this.socketTask.readyState === WebSocket.OPEN) {
this.socketTask.send({ data: message });
} else {
this.messageQueue.push(message);
}
}
_flushMessageQueue() {
if (this.messageQueue.length === 0) return;
const queue = [...this.messageQueue];
this.messageQueue = [];
queue.forEach(msg => {
this.socketTask.send({ data: msg });
});
}
五、多页面消息订阅:让每个页面各取所需
为了解决不同页面需要不同消息的问题,我设计了一个基于唯一标识的订阅机制。每个页面在 `onLoad` 时用页面路径作为 key 注册一个回调函数,在 `onUnload` 时取消注册。管理器收到消息后遍历所有注册的回调逐个调用。
// 注册监听
addListener(key, callback) {
this.listeners.set(key, callback);
}
// 移除监听
removeListener(key) {
this.listeners.delete(key);
}
页面中的使用方式非常简洁:
// 聊天页面 chat.vue
import SocketManager from '@/utils/socketManager';
export default {
onLoad() {
this.socket = SocketManager.getInstance();
this.socket.addListener('chat', this.handleMessage);
},
onUnload() {
this.socket.removeListener('chat');
},
methods: {
handleMessage(data) {
// 根据消息类型更新聊天列表
}
}
}
这种设计的一个额外好处是,当需要从外部推送消息到特定页面时,管理器不用关心页面实例是否存活,只负责调回调,页面自己处理生命周期。
六、跨端差异的处理
UniApp 封装了各平台的 WebSocket API,但实际跑起来还是有一些细微差异需要处理。
小程序端的限制最明显:WebSocket 连接最多同时存在 5 个,这就是为什么全局要维持单例。此外,小程序的 WebSocket 在切后台一段时间后会被强制断开,`onClose` 事件可能延迟触发。我的处理是配合 App.vue 的生命周期,在 `onShow` 里主动检查连接状态,如果 socket 的 `readyState` 不为 OPEN,就触发一次重连。
App 端相对稳定,但在某些 Android 设备上,WebSocket 的 `onError` 不会提供详细的错误信息,调试起来比较麻烦。我把所有 `onError` 事件都额外加了一层日志,方便排查。
H5 端用的是浏览器原生的 WebSocket,`readyState` 的枚举值和小程序不同。UniApp 在 H5 层做了适配,但我在管理器里统一用 `WebSocket.OPEN` 来做状态判断,避免硬编码数字。
七、完整使用流程
把管理器挂到 Vue 原型上或者作为全局模块引入后,真实项目里的使用顺序是:
- 在 `App.vue` 的 `onLaunch` 里调用 `SocketManager.getInstance().connect(‘wss://your-server.com/ws’)` 建立连接。
- 在需要实时消息的页面用 `addListener` 注册自己的消息处理函数,同时在 `onUnload` 中移除。
- 业务代码中直接调用 `socket.send(data)` 发送消息,管理器自动处理连接状态和消息队列。
- 退出登录或不再需要连接时,调用 `socket.close(true)` 断开并阻止自动重连。
整个流程下来,页面开发者几乎不需要关心 WebSocket 的底层细节,只需要关注消息内容本身。
八、总结
这套 WebSocket 管理器在客服小程序里稳定运行了大半年,中间只因为服务端协议变更调整过一次心跳格式。回想最开始用原生 API 直接写的那个版本,现在的方案在可维护性和健壮性上都有质的提升。如果你的 UniApp 项目也有即时通讯或实时数据推送的需求,建议不要把连接逻辑散落在各个页面里,花一点时间搭一个全局管理器,后续新增功能和排查问题的效率会高出很多。
代码里涉及的单例、退避重连、消息队列和观察者订阅,都是软件工程里经久不衰的模式,放在跨端环境里同样有效。真正落地时唯一需要的额外工作就是结合自己的业务协议填好心跳格式和消息结构,骨架可以直接复用。

