在一个外卖商家端App里,新订单进来时需要立刻在屏幕上弹出提示,厨房打印机同时收到指令,整个流程延迟不能超过一两秒。这个场景用轮询显然不合适——即使把间隔设到两三秒,在高峰期也会给服务器带来不小压力,而且实时性依然不够。WebSocket长连接是最直接的解法,但在uniapp里做长连接有几个细节需要自己处理:连接断了怎么自动重连、App切到后台再回来怎么恢复、多个页面都需要接收消息时怎么统一管理连接状态。
把这些逻辑散落在各个页面里显然不行——每个页面都得写一遍连接、断线、心跳的代码,改一个参数要翻好几个文件。正确的做法是把WebSocket连接抽象成一个全局单例管理器,负责建立连接、心跳维持、断线重连和消息分发,业务页面只需要注册监听自己关心的消息类型,完全不用管底层的连接状态。这篇文章就用一个商家端实时订单通知的案例,把整套方案从管理器封装到页面使用完整走一遍。
需求拆解:一个可靠的WebSocket管理器要做什么
在动手写代码之前,先把需求列清楚,避免后面反复改设计:
- 全局唯一连接。 整个App只建立一个WebSocket连接,所有页面共享。
- 自动重连。 网络断开或服务器重启导致连接中断后,能按一定策略自动重新连接。
- 心跳检测。 定时发送心跳包,并在长时间收不到服务器回应时主动断开并重连,防止“假活”连接。
- 消息分发。 服务器推送的消息按类型分发给不同的监听者,页面注册自己关心的消息类型,不用处理无关数据。
- 连接状态同步。 连接状态(连接中、已连接、已断开)对页面透明,页面可以通过全局状态知道当前是否在线。
技术选型上,用uni.connectSocket及其相关API来建立连接,状态管理用Pinia(Vue2项目可用Vuex,模式一致),消息分发用自定义事件或回调注册表。
第一步:搭建WebSocket管理器
创建一个utils/websocket.js文件,用ES6的class封装一个WebSocketManager。这个类负责连接的生命周期管理,并在内部维护一个消息监听器列表。
class WebSocketManager {
constructor() {
this.socketTask = null; // uni.connectSocket返回的任务对象
this.url = ''; // WebSocket地址
this.isConnecting = false; // 是否正在连接中
this.heartbeatTimer = null; // 心跳定时器
this.reconnectTimer = null; // 重连定时器
this.reconnectCount = 0; // 当前重连次数
this.maxReconnectCount = 10; // 最大重连次数
this.reconnectInterval = 2000; // 重连间隔(毫秒)
this.heartbeatInterval = 30000; // 心跳间隔(毫秒)
this.listeners = new Map(); // 消息类型 -> 回调函数数组
}
/**
* 初始化并建立连接
* @param {string} url WebSocket地址
*/
connect(url) {
// 如果已有连接且地址相同,不再重复创建
if (this.socketTask && this.url === url && this.isConnected()) {
return;
}
this.url = url;
this._createConnection();
}
/**
* 创建连接
*/
_createConnection() {
if (this.isConnecting) return;
this.isConnecting = true;
// 关闭旧连接(如果有)
if (this.socketTask) {
this.socketTask.close();
this.socketTask = null;
}
// 清理旧的心跳和重连定时器
clearInterval(this.heartbeatTimer);
clearTimeout(this.reconnectTimer);
// 建立新连接
this.socketTask = uni.connectSocket({
url: this.url,
success: () => {
console.log('WebSocket 连接请求已发送');
},
fail: (err) => {
console.error('WebSocket 连接失败', err);
this.isConnecting = false;
this._scheduleReconnect();
}
});
// 监听连接打开
this.socketTask.onOpen(() => {
console.log('WebSocket 已连接');
this.isConnecting = false;
this.reconnectCount = 0; // 重置重连计数
this._startHeartbeat();
this._broadcastStatus('connected');
});
// 监听消息
this.socketTask.onMessage((res) => {
this._handleMessage(res.data);
});
// 监听连接关闭
this.socketTask.onClose(() => {
console.log('WebSocket 连接关闭');
this.isConnecting = false;
this._broadcastStatus('disconnected');
this._clearHeartbeat();
// 非主动关闭的话尝试重连
this._scheduleReconnect();
});
// 监听错误
this.socketTask.onError((err) => {
console.error('WebSocket 错误', err);
this.isConnecting = false;
this._broadcastStatus('error');
this._scheduleReconnect();
});
}
/**
* 判断当前是否处于连接状态
*/
isConnected() {
// uni.connectSocket返回的socketTask没有直接暴露readyState,
// 需要通过状态管理来跟踪。这里用属性标记。
return this._status === 'connected';
}
/**
* 发送消息
*/
send(data) {
if (this.socketTask && this.isConnected()) {
this.socketTask.send({
data: typeof data === 'string' ? data : JSON.stringify(data)
});
} else {
console.warn('WebSocket 未连接,无法发送消息');
}
}
/**
* 主动关闭连接
*/
close() {
clearInterval(this.heartbeatTimer);
clearTimeout(this.reconnectTimer);
this.reconnectCount = this.maxReconnectCount; // 阻止重连
if (this.socketTask) {
this.socketTask.close();
this.socketTask = null;
}
}
/**
* 注册消息监听器
* @param {string} type 消息类型(根据业务协议定义)
* @param {function} callback 回调函数
*/
on(type, callback) {
if (!this.listeners.has(type)) {
this.listeners.set(type, []);
}
this.listeners.get(type).push(callback);
}
/**
* 移除消息监听器
*/
off(type, callback) {
if (!this.listeners.has(type)) return;
const callbacks = this.listeners.get(type);
const index = callbacks.indexOf(callback);
if (index > -1) {
callbacks.splice(index, 1);
}
}
/**
* 处理接收到的消息
*/
_handleMessage(rawData) {
try {
const msg = JSON.parse(rawData);
// 心跳响应包不需要分发给业务层
if (msg.type === 'pong') {
this._lastPongTime = Date.now();
return;
}
const type = msg.type || 'default';
const callbacks = this.listeners.get(type) || [];
callbacks.forEach(cb => cb(msg));
} catch (e) {
console.error('消息解析失败', e, rawData);
}
}
/**
* 启动心跳
*/
_startHeartbeat() {
this._lastPongTime = Date.now();
this.heartbeatTimer = setInterval(() => {
// 检查最后一次收到pong的时间,如果超过两倍心跳间隔则判定为超时
const now = Date.now();
if (now - this._lastPongTime > this.heartbeatInterval * 2) {
console.warn('心跳超时,主动断开重连');
this._clearHeartbeat();
if (this.socketTask) {
this.socketTask.close();
}
return;
}
// 发送心跳包(根据业务协议,这里发送一个ping对象)
this.send({ type: 'ping' });
}, this.heartbeatInterval);
}
/**
* 清除心跳定时器
*/
_clearHeartbeat() {
if (this.heartbeatTimer) {
clearInterval(this.heartbeatTimer);
this.heartbeatTimer = null;
}
}
/**
* 安排重连
*/
_scheduleReconnect() {
if (this.reconnectCount >= this.maxReconnectCount) {
console.log('已达到最大重连次数,停止重连');
return;
}
// 指数退避:重连间隔 = 基础间隔 * 2^(重连次数)
const delay = Math.min(this.reconnectInterval * Math.pow(2, this.reconnectCount), 60000);
this.reconnectTimer = setTimeout(() => {
console.log(`尝试第 ${this.reconnectCount + 1} 次重连`);
this.reconnectCount++;
this._createConnection();
}, delay);
}
/**
* 广播连接状态变更(可通过回调或全局状态实现)
*/
_broadcastStatus(status) {
this._status = status;
// 这里通过一个内置事件分发,页面可以监听status_change
const callbacks = this.listeners.get('_status_change') || [];
callbacks.forEach(cb => cb(status));
}
/**
* 监听连接状态变更
*/
onStatusChange(callback) {
this.on('_status_change', callback);
}
}
// 导出全局单例
export default new WebSocketManager();
这个管理器有几个关键设计。重连策略用了指数退避算法——第一次断线等2秒重试,第二次4秒,第三次8秒,最多等60秒,这样在服务器短暂重启时不会因为同时涌入大量重连请求而雪崩。心跳机制采用双向检测:客户端定时发ping,服务器回应pong,如果超过两倍心跳间隔还没收到pong,就主动断开连接触发重连流程,防止连接“半死不活”地假在线。
第二步:连接状态全局化
为了让各个页面都能感知到当前连接状态(比如在导航栏显示一个绿色或灰色的圆点),我们把连接状态注入到Pinia store中。创建stores/socket.js:
import { defineStore } from 'pinia';
import { ref } from 'vue';
import socketManager from '@/utils/websocket.js';
export const useSocketStore = defineStore('socket', () => {
const status = ref('disconnected'); // 'connected' | 'disconnected' | 'error'
// 初始化时从管理器同步状态,并注册状态监听
socketManager.onStatusChange((newStatus) => {
status.value = newStatus;
});
// 暴露给页面使用的连接方法
function connect(url) {
socketManager.connect(url);
}
function close() {
socketManager.close();
}
return {
status,
connect,
close
};
});
在App.vue的onLaunch里发起连接,确保整个应用启动时WebSocket就建立起来:
import { useSocketStore } from '@/stores/socket.js';
export default {
onLaunch() {
const socketStore = useSocketStore();
const token = uni.getStorageSync('access_token');
// WebSocket地址带token做身份认证
const wsUrl = `wss://api.example.com/ws?token=${token}`;
socketStore.connect(wsUrl);
}
};
第三步:业务页面使用——实时订单推送
商家端有一个订单列表页面,需要实时接收新订单通知。页面加载时注册new_order类型的消息监听,页面卸载时移除监听,避免内存泄漏。
import socketManager from '@/utils/websocket.js';
import { useSocketStore } from '@/stores/socket.js';
export default {
data() {
return {
orders: [],
newOrderTips: []
};
},
computed: {
onlineStatus() {
return useSocketStore().status;
}
},
onLoad() {
// 注册新订单监听
this.onNewOrder = (msg) => {
// msg 结构示例: { type: 'new_order', orderId: 'xxx', amount: 99.9, customer: '张三' }
this.newOrderTips.push(msg);
// 这里可以弹窗或播放提示音
uni.showToast({ title: '新订单来了!', icon: 'none' });
// 同时将新订单加入列表(实际可能需要重新拉取)
this.orders.unshift(msg);
};
socketManager.on('new_order', this.onNewOrder);
// 注册订单状态变更监听
this.onOrderStatus = (msg) => {
const order = this.orders.find(o => o.orderId === msg.orderId);
if (order) {
order.status = msg.newStatus;
}
};
socketManager.on('order_status', this.onOrderStatus);
},
onUnload() {
// 移除监听,防止离开页面后依然触发回调
socketManager.off('new_order', this.onNewOrder);
socketManager.off('order_status', this.onOrderStatus);
},
methods: {
// 手动发送消息(比如接单按钮)
acceptOrder(orderId) {
socketManager.send({
type: 'accept_order',
orderId: orderId
});
}
}
};
页面顶部的连接状态指示器可以直接绑定onlineStatus,当状态为'connected'时显示绿色圆点,断开时显示红色,用户一眼就能看到当前连接是否正常。
几个容易踩到的坑
App切换后台时的处理。 在App端,当用户切换到其他应用时,uniapp的WebSocket连接可能在操作系统层面被中断。回到前台时应检查连接状态,如果断开了则触发重连。在App.vue可以通过onShow生命周期来处理:
onShow() {
const socketStore = useSocketStore();
if (socketStore.status !== 'connected') {
const token = uni.getStorageSync('access_token');
socketStore.connect(`wss://api.example.com/ws?token=${token}`);
}
}
WebSocket地址的Token过期。 Token通常有有效期,如果WebSocket在Token过期后断线重连,需要使用新Token。可以在connect方法内部动态获取最新Token,或者在全局添加一个Token刷新回调。
多页面监听导致的回调重复。 如果两个页面都注册了同一个消息类型,它们都会收到消息,这是设计预期的。但如果同一个页面在onLoad和onShow里都注册了而没有在对应的生命周期里移除,就会出现重复监听。一个稳妥的做法是只在onLoad注册、在onUnload移除,确保生命周期对称。
内存和性能。 WebSocket管理器以单例形式存在,生命周期等同于App。注册的监听回调会持有页面实例的引用,如果页面关闭时没有移除监听,回调依然存在,但页面实例已经销毁,既浪费内存又可能报错。所以onUnload里的off调用不是可选的,必须写。
总结
把WebSocket连接封装成全局单例管理器,看起来只是把uni.connectSocket包了一层,但这层包装带来的收益很实在:页面上不再出现各种连接状态的判断代码,开发者只需要声明“我关心什么消息类型”和“收到消息做什么”,连接的重建、心跳和异常恢复全由管理器自动处理。订单实时推送这个场景把管理器从连接到分发到状态同步的整个链路都跑通了,其他场景比如实时聊天、数据看板、设备监控本质上也是一样的模式,换个消息类型即可复用。
如果你的uniapp项目目前还在用轮询模拟实时推送,或者每个页面自己管一个WebSocket连接,这套方案值得引入。改造成本就是把连接逻辑收进管理器、在需要接收消息的页面注册监听,原来的业务逻辑基本不用动。

