年后接到一个在线客服的需求,要在App、H5和微信小程序三端同时上线,用户和客服之间能实时收发消息。技术栈定的是uni-app,后端用网关统一管理WebSocket连接。原本以为直接拿uni.connectSocket就能搞定,结果在心跳维持、断线重连、多端消息格式兼容上连踩了几个坑。最后花了两天时间封装了一个通用的WebSocket管理器,跑了一个月没出过连接泄漏或消息丢失的情况。这篇就把整个实现过程和踩过的点完整写出来。
一、为什么不用现成的插件
社区里能找到不少uni-app的WebSocket封装,但大部分要么只支持单端,要么没处理心跳和重连,要么把状态管理写死在组件里没法复用。我们这个需求有几个特殊点:
- 需要同时在App原生渲染、H5浏览器和小程序环境运行
- 连接断掉后要自动重连,并且有递增的等待间隔
- 消息要全局分发,多个页面都能监听,不是只在一个聊天页面里用
- 后端返回的消息格式不统一,需要前端做一层规范化
这些条件综合下来,自己封装一个轻量的管理器反而更可控。下面就是整个模块的完整代码和设计思路。
二、核心封装:WebSocketManager类
思路是把所有与连接有关的逻辑(创建、心跳、重连、消息收发)放进一个类,对外只暴露connect、send、close和事件监听接口。这样无论哪个页面需要用到实时消息,引入同一个实例即可。
2.1 基础结构和连接方法
// utils/websocket-manager.js
class WebSocketManager {
constructor() {
this.socketTask = null; // uni-app的SocketTask对象
this.url = '';
this.heartbeatTimer = null; // 心跳定时器
this.reconnectTimer = null; // 重连定时器
this.reconnectCount = 0;
this.maxReconnectCount = 8; // 最多重连8次
this.reconnectInterval = 1000; // 初始重连间隔1秒
this.isManualClose = false; // 是否为手动关闭(手动关闭不自动重连)
this.listeners = new Map(); // 事件监听器 Map
}
// 初始化连接
connect(url, options = {}) {
this.url = url;
this.isManualClose = false;
this.reconnectCount = 0;
this.createSocketTask(url, options);
}
createSocketTask(url, options) {
this.socketTask = uni.connectSocket({
url,
...options,
success: () => {
console.log('WebSocket 连接创建成功');
},
fail: (err) => {
console.error('WebSocket 连接创建失败', err);
this.tryReconnect();
}
});
// 监听连接打开
this.socketTask.onOpen(() => {
console.log('WebSocket 已连接');
this.reconnectCount = 0;
this.reconnectInterval = 1000;
this.startHeartbeat();
this.emit('open');
});
// 监听收到消息
this.socketTask.onMessage((res) => {
this.handleMessage(res.data);
});
// 监听连接关闭
this.socketTask.onClose((res) => {
console.log('WebSocket 已关闭', res);
this.stopHeartbeat();
if (!this.isManualClose) {
this.tryReconnect();
}
this.emit('close', res);
});
// 监听错误
this.socketTask.onError((err) => {
console.error('WebSocket 错误', err);
this.emit('error', err);
});
}
}
这里有几个关键点。第一,uni.connectSocket返回的socketTask对象提供了onOpen、onMessage等回调,这些回调在App端和H5端行为基本一致,但在小程序里要注意onError并不总是能触发,所以重连逻辑主要依赖onClose来驱动。
第二,isManualClose用来区分是用户主动关闭还是网络原因断开。我们只希望在意外断开时自动重连,用户如果手动点了“断开连接”,就不应该再自动重试。
三、心跳机制的实现
WebSocket如果长时间没有数据交互,中间的网络设备可能会断开连接。通用的做法是客户端每隔一段时间发送一个“ping”帧,服务端回复“pong”,以此保持连接活跃。
在WebSocketManager里加上心跳逻辑:
startHeartbeat() {
this.stopHeartbeat(); // 先清除已有的,避免重复
this.heartbeatTimer = setInterval(() => {
if (this.socketTask) {
this.socketTask.send({
data: JSON.stringify({ type: 'ping' }),
fail: () => {
// 发送失败说明连接可能已经异常,关闭当前连接触发重连
console.warn('心跳发送失败,准备重连');
this.closeSocket(false); // 不标记为手动关闭
}
});
}
}, 30000); // 30秒发一次
}
stopHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
这里心跳间隔设了30秒,大多数服务端的空闲超时是60秒左右,30秒发一次足够保险。发送失败时直接调用closeSocket(false)来触发重连,避免了长时间无响应的僵死连接。
另外需要注意,App端在应用退到后台时,WebSocket连接可能会被系统暂停。如果需要后台持续在线,必须使用原生插件处理后台任务,这超出了本文范围。对于大多数客服场景来说,退到后台断开连接、前台回来再重连是完全可以接受的。
四、递增间隔的断线重连
重连不能无脑每秒重试一次,那样会拖垮客户端的网络和服务器资源。我们用指数退避的方式,间隔从1秒开始逐次倍增,最大到30秒。
tryReconnect() {
if (this.reconnectCount >= this.maxReconnectCount) {
console.warn('已达最大重连次数,停止重连');
this.emit('reconnect-failed');
return;
}
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
}
this.reconnectTimer = setTimeout(() => {
this.reconnectCount++;
console.log(`尝试第${this.reconnectCount}次重连,间隔${this.reconnectInterval}ms`);
this.createSocketTask(this.url);
// 递增重连间隔,最大30秒
this.reconnectInterval = Math.min(this.reconnectInterval * 2, 30000);
}, this.reconnectInterval);
}
每次连接成功时(onOpen),把reconnectCount和reconnectInterval重置为初始值。这样断线后第一次重连快(1秒),后续逐步放缓,8次之后彻底放弃。在实际使用中,8次重连覆盖的时间窗口已经超过2分钟,足以应对大部分网络抖动。
五、消息分发与全局监听
为了在不同页面都能收到实时消息,我们模仿事件总线的方式,给WebSocketManager加上on、off、emit方法:
// 注册事件监听
on(eventName, callback) {
if (!this.listeners.has(eventName)) {
this.listeners.set(eventName, []);
}
this.listeners.get(eventName).push(callback);
}
// 移除事件监听
off(eventName, callback) {
const callbacks = this.listeners.get(eventName);
if (callbacks) {
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
}
// 触发事件
emit(eventName, data) {
const callbacks = this.listeners.get(eventName);
if (callbacks) {
callbacks.forEach(cb => cb(data));
}
}
// 处理收到的原始消息,格式化为统一结构后分发
handleMessage(rawData) {
let parsed;
try {
parsed = JSON.parse(rawData);
} catch {
parsed = { type: 'raw', content: rawData };
}
// 根据消息类型分发到具体事件
this.emit('message', parsed);
if (parsed.type) {
this.emit(parsed.type, parsed);
}
}
// 发送消息
send(data) {
if (this.socketTask) {
this.socketTask.send({
data: JSON.stringify(data),
fail: (err) => {
console.error('发送消息失败', err);
this.emit('send-error', err);
}
});
}
}
// 主动关闭连接
close() {
this.isManualClose = true;
this.stopHeartbeat();
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.closeSocket(true);
}
closeSocket(keepManualFlag) {
if (this.socketTask) {
this.socketTask.close({
success: () => {
this.socketTask = null;
}
});
}
}
这样在聊天页面里,只需要websocketManager.on('message', handler)就能收到所有消息,在客服列表页可以监听new-customer这类自定义事件,架构很清爽。
六、单例模式:整个App共享一个连接
WebSocket连接是稀缺资源,整个应用应该只保持一条连接。我们把这个管理器做成单例:
// 最终导出单例
const websocketManager = new WebSocketManager();
export default websocketManager;
然后在App.vue的onLaunch中初始化连接:
import websocketManager from '@/utils/websocket-manager.js';
export default {
onLaunch() {
const token = uni.getStorageSync('token');
websocketManager.connect(`wss://api.example.com/ws?token=${token}`);
}
};
这里把登录token作为查询参数传给服务端做身份校验,是最简单可靠的做法。如果token会过期,可以在收到服务端的“token过期”通知后,先通过HTTP刷新token,再调用websocketManager.close()然后重新connect。这套流程可以封装在WebSocketManager内部,但不在这篇文章展开。
七、完整聊天页面示例
下面是一个使用上面封装的管理器实现的聊天页面关键部分,展示了如何在Vue3组合式API里集成:
<template>
<view class="chat-page">
<scroll-view scroll-y class="message-list" ref="msgList">
<view v-for="(msg, idx) in messages" :key="idx"
:class="msg.from === 'me' ? 'msg-self' : 'msg-other'">
<text>{{ msg.content }}</text>
</view>
</scroll-view>
<view class="input-area">
<input v-model="inputText" placeholder="输入消息..." />
<button @click="sendMsg">发送</button>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted, nextTick } from 'vue';
import websocketManager from '@/utils/websocket-manager.js';
const messages = ref([]);
const inputText = ref('');
const handleMessage = (msg) => {
// 只关注聊天消息(假设type为chat)
if (msg.type === 'chat') {
messages.value.push({
from: msg.from,
content: msg.content,
time: Date.now()
});
// 滚动到底部
nextTick(() => {
// 实际项目中需要获取scroll-view的scrollTop并设置
});
}
};
const sendMsg = () => {
if (!inputText.value.trim()) return;
const msg = {
type: 'chat',
to: 'customer-service',
content: inputText.value.trim()
};
websocketManager.send(msg);
// 也在本地消息列表里加一条
messages.value.push({
from: 'me',
content: msg.content,
time: Date.now()
});
inputText.value = '';
};
onMounted(() => {
websocketManager.on('message', handleMessage);
});
onUnmounted(() => {
// 组件卸载时移除监听,避免内存泄漏
websocketManager.off('message', handleMessage);
});
</script>
实际项目中还需要处理键盘弹起、消息时间格式化、发送中状态等,但核心的WebSocket交互已经完整了。注意到在onUnmounted里移除了监听,这是有必要的——如果不清理,多个页面来回切换会导致同一个事件被重复触发,出现消息重复显示的问题。
八、多端兼容的几个实测差异
在App、H5和小程序三端测试时,遇到了几个值得记录的行为差异:
- 小程序端最大连接数限制:微信小程序同时只允许维护一条WebSocket连接。如果你的应用里还需要与其他服务建立WebSocket连接(比如实时日志),就会冲突。解决方法是只使用我们封装的管理器连接主服务,其他数据需求通过HTTP轮询或复用同一条WebSocket传输。
- H5端跨域和安全策略:H5跑在浏览器里,连接
wss://时如果服务端没有正确配置SSL证书,连接会静默失败。另外浏览器对localhost使用WebSocket时也可能遇到混合内容拦截。建议生产环境全部走wss,开发期只在真机或模拟器上测试。 - App端后台运行:iOS和Android对后台WebSocket连接的处理不同。Android一般能维持几秒到十几秒,iOS进入后台后很快断开。如果需求要求后台持续接收消息,必须配合原生推送(如APNs、厂商通道)。我们的处理方式是在
onShow生命周期里检查连接状态,如果断开就手动调一次websocketManager.connect。
九、容易漏掉的内存释放
WebSocket和定时器是两种容易造成内存泄漏的东西。在我们的管理器里,heartbeatTimer和reconnectTimer在连接关闭时都有清理逻辑,listeners也在off方法中移除。但有一种情况容易被忽略:如果页面没有调用off就销毁了,导致对已卸载组件实例的引用一直存在。
这个问题在当前示例中是安全的,因为我们在组件里显式调用了onUnmounted。但为了更健壮,可以在管理器上加一个destroy方法,清空所有监听器,在应用全局销毁时调用一次:
destroy() {
this.listeners.clear();
this.stopHeartbeat();
this.close();
}
在实际项目里一般不需要,但如果是在测试环境反复热重载,没有彻底清理的话会看到多个心跳同时跑的现象。
十、总结
这套封装在目前的项目里跑得很稳,最直观的收益是:所有需要实时消息的页面都不再关心连接状态,只需要监听事件;重连逻辑完全透明,用户几乎感觉不到网络中断。代码量压缩在200行以内,没有引入任何第三方依赖。
如果你也面临类似的多端实时通信需求,可以直接把上面的代码拿去用,根据业务调整消息格式和重连策略即可。下一步可以考虑的增强方向包括:与服务端约定的心跳超时协商、断线期间未发送消息的本地队列缓存、以及消息已读回执的Ack机制。不过这些都属于锦上添花,核心框架已经足够支撑大多数场景了。

