320 lines
11 KiB
JavaScript
320 lines
11 KiB
JavaScript
|
|
// common/websocket.js
|
|||
|
|
// 带中文日志与消息体打印选项的 WsRequest(替换你当前文件即可)
|
|||
|
|
|
|||
|
|
class WsRequest {
|
|||
|
|
constructor(url = '', options = {}) {
|
|||
|
|
this.url = url || '';
|
|||
|
|
this.options = Object.assign({
|
|||
|
|
header: {},
|
|||
|
|
protocols: [],
|
|||
|
|
debug: true,
|
|||
|
|
lang: 'zh', // 'zh' 或 'en',控制日志语言(默认中文)
|
|||
|
|
showMessageBody: true, // 是否在日志中显示收到消息的完整 JSON(默认 true)
|
|||
|
|
filterPing: true, // 是否过滤掉 ping/pong 日志(默认 true)
|
|||
|
|
heartbeatInterval: 30000,
|
|||
|
|
heartbeatTimeout: 15000,
|
|||
|
|
pingMessage: { type: 'ping' },
|
|||
|
|
parseJSON: true,
|
|||
|
|
maxReconnectAttempts: 10,
|
|||
|
|
reconnectDelayBase: 1000,
|
|||
|
|
autoConnect: false,
|
|||
|
|
bindGlobal: true
|
|||
|
|
}, options);
|
|||
|
|
|
|||
|
|
this.socketTask = null;
|
|||
|
|
this.connected = false;
|
|||
|
|
this._msgQueue = [];
|
|||
|
|
this.reconnectAttempts = 0;
|
|||
|
|
this._lastPongAt = Date.now();
|
|||
|
|
this._hbTimer = null;
|
|||
|
|
this._hbTimeoutTimer = null;
|
|||
|
|
this._subscriptions = new Map();
|
|||
|
|
|
|||
|
|
if (this.options.bindGlobal) this._bindGlobalSocketEvents();
|
|||
|
|
if (this.options.autoConnect) setTimeout(() => this.open(), 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 内部多语言 Label
|
|||
|
|
_label(key) {
|
|||
|
|
const zh = {
|
|||
|
|
open: '打开连接 ->',
|
|||
|
|
alreadyConnected: '已连接,忽略 open',
|
|||
|
|
connectNoTask: 'connectSocket 未返回 task,使用全局回调',
|
|||
|
|
onOpen: '连接已打开',
|
|||
|
|
onMessage: '收到消息',
|
|||
|
|
onClose: '连接已关闭',
|
|||
|
|
onError: '连接错误',
|
|||
|
|
globalOnOpen: '全局 onSocketOpen',
|
|||
|
|
globalOnMessage: '全局 onSocketMessage',
|
|||
|
|
hbTimeout: '心跳超时',
|
|||
|
|
reconnectScheduled: '计划重连',
|
|||
|
|
sendFailed: '发送失败',
|
|||
|
|
closeError: '关闭错误'
|
|||
|
|
};
|
|||
|
|
const en = {
|
|||
|
|
open: 'open ->',
|
|||
|
|
alreadyConnected: 'already connected, ignore open',
|
|||
|
|
connectNoTask: 'connectSocket returned no task (using global callbacks if available)',
|
|||
|
|
onOpen: 'onOpen',
|
|||
|
|
onMessage: 'onMessage',
|
|||
|
|
onClose: 'onClose',
|
|||
|
|
onError: 'onError',
|
|||
|
|
globalOnOpen: 'global onSocketOpen',
|
|||
|
|
globalOnMessage: 'global onSocketMessage',
|
|||
|
|
hbTimeout: 'hb timeout',
|
|||
|
|
reconnectScheduled: 'reconnect scheduled',
|
|||
|
|
sendFailed: 'send failed',
|
|||
|
|
closeError: 'close error'
|
|||
|
|
};
|
|||
|
|
return this.options.lang === 'zh' ? zh[key] || key : en[key] || key;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 简单统一日志
|
|||
|
|
log(...args) {
|
|||
|
|
if (!this.options.debug) return;
|
|||
|
|
// 如果是对象,格式化输出以便控制台可读
|
|||
|
|
const out = args.map(a => {
|
|||
|
|
if (typeof a === 'object') {
|
|||
|
|
try { return JSON.stringify(a, null, 2); } catch (e) { return String(a); }
|
|||
|
|
}
|
|||
|
|
return String(a);
|
|||
|
|
}).join(' ');
|
|||
|
|
console.log('[WsRequest]', out);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// ---------- 对外控制 ----------
|
|||
|
|
open() {
|
|||
|
|
if (!this.url) { this.log(this._label('open'), this._label('noUrl') || 'no url'); return; }
|
|||
|
|
if (this.connected) { this.log(this._label('alreadyConnected')); return; }
|
|||
|
|
this.log(this._label('open'), this.url);
|
|||
|
|
try {
|
|||
|
|
const task = uni.connectSocket({ url: this.url, header: this.options.header, protocols: this.options.protocols });
|
|||
|
|
if (task && typeof task.onOpen === 'function') {
|
|||
|
|
this._bindSocketTask(task);
|
|||
|
|
} else {
|
|||
|
|
this.log(this._label('connectNoTask'));
|
|||
|
|
// 依赖全局回调,_bindGlobalSocketEvents 已经绑定则会处理 onOpen/onMessage
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
console.error(e);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
close(code = 1000, reason = 'client close') {
|
|||
|
|
this._stopHeartbeat();
|
|||
|
|
try {
|
|||
|
|
if (this.socketTask && typeof this.socketTask.close === 'function') {
|
|||
|
|
this.socketTask.close({ code, reason });
|
|||
|
|
} else if (typeof uni.closeSocket === 'function') {
|
|||
|
|
uni.closeSocket();
|
|||
|
|
}
|
|||
|
|
} catch (e) {
|
|||
|
|
this.log(this._label('closeError'), e);
|
|||
|
|
}
|
|||
|
|
this.connected = false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
send(payload) {
|
|||
|
|
const data = typeof payload === 'string' ? payload : JSON.stringify(payload);
|
|||
|
|
if (this.socketTask && typeof this.socketTask.send === 'function' && this.connected) {
|
|||
|
|
try { this.socketTask.send({ data }); return; } catch (e) { this.log(this._label('sendFailed'), e); this._msgQueue.push(data); }
|
|||
|
|
} else {
|
|||
|
|
try { uni.sendSocketMessage({ data }); return; } catch (e) { this._msgQueue.push(data); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_flushQueue() {
|
|||
|
|
if (!this.connected) return;
|
|||
|
|
while (this._msgQueue.length) {
|
|||
|
|
const d = this._msgQueue.shift();
|
|||
|
|
try {
|
|||
|
|
if (this.socketTask && typeof this.socketTask.send === 'function') {
|
|||
|
|
this.socketTask.send({ data: d });
|
|||
|
|
} else {
|
|||
|
|
uni.sendSocketMessage({ data: d });
|
|||
|
|
}
|
|||
|
|
} catch (e) { this._msgQueue.unshift(d); break; }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_bindSocketTask(task) {
|
|||
|
|
this.socketTask = task;
|
|||
|
|
if (task.__ws_bound__) return;
|
|||
|
|
task.__ws_bound__ = true;
|
|||
|
|
|
|||
|
|
task.onOpen(res => {
|
|||
|
|
this.log(this._label('onOpen'), res);
|
|||
|
|
this.connected = true;
|
|||
|
|
this.reconnectAttempts = 0;
|
|||
|
|
this._lastPongAt = Date.now();
|
|||
|
|
this._startHeartbeat();
|
|||
|
|
this._flushQueue();
|
|||
|
|
this.options.onOpen && this.options.onOpen(res);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
task.onMessage(msg => {
|
|||
|
|
this._handleIncoming(msg);
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
task.onClose(res => {
|
|||
|
|
this.log(this._label('onClose'), res);
|
|||
|
|
this.connected = false;
|
|||
|
|
this._stopHeartbeat();
|
|||
|
|
this.options.onClose && this.options.onClose(res);
|
|||
|
|
this._tryReconnect();
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
task.onError(err => {
|
|||
|
|
this.log(this._label('onError'), err);
|
|||
|
|
this.connected = false;
|
|||
|
|
this._stopHeartbeat();
|
|||
|
|
this.options.onError && this.options.onError(err);
|
|||
|
|
this._tryReconnect();
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 处理收到的消息(task 或 全局都使用)
|
|||
|
|
_handleIncoming(msg) {
|
|||
|
|
this._lastPongAt = Date.now();
|
|||
|
|
let data = msg && msg.data;
|
|||
|
|
if (this.options.parseJSON) {
|
|||
|
|
try { data = JSON.parse(msg.data); } catch (e) { /* keep raw */ }
|
|||
|
|
}
|
|||
|
|
const type = (data && data.type) || '__default__';
|
|||
|
|
|
|||
|
|
// 过滤 ping/pong (若你想看 ping/pong,把 filterPing 设为 false)
|
|||
|
|
const isPing = data && (data.type === 'ping' || data.type === 'pong' || (typeof data === 'string' && (data === 'ping' || data === 'pong')));
|
|||
|
|
if (isPing && this.options.filterPing) {
|
|||
|
|
// 仍更新 lastPong,但不打印消息体,除非 showMessageBody 强制 true
|
|||
|
|
if (this.options.debug && this.options.showMessageBody === false) {
|
|||
|
|
this.log(this._label('onMessage'), type);
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
// 打印消息类型与(可选)格式化消息体
|
|||
|
|
if (this.options.debug) {
|
|||
|
|
if (this.options.showMessageBody) {
|
|||
|
|
let bodyStr;
|
|||
|
|
try { bodyStr = JSON.stringify(data, null, 2); } catch (e) { bodyStr = String(data); }
|
|||
|
|
this.log(this.options.lang === 'zh' ? `${this._label('onMessage')} (${type}):` : `${this._label('onMessage')} (${type}):`, '\n' + bodyStr);
|
|||
|
|
} else {
|
|||
|
|
this.log(this._label('onMessage'), type);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 外部回调与订阅异步派发
|
|||
|
|
if (this.options.onMessage) setTimeout(() => this.options.onMessage(data, msg), 0);
|
|||
|
|
setTimeout(() => this._dispatch(type, data, msg), 0);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_bindGlobalSocketEvents() {
|
|||
|
|
// 解绑默认全局再绑定,避免重复
|
|||
|
|
try { uni.offSocketOpen && uni.offSocketOpen(); } catch (e) {}
|
|||
|
|
try { uni.offSocketMessage && uni.offSocketMessage(); } catch (e) {}
|
|||
|
|
try { uni.offSocketClose && uni.offSocketClose(); } catch (e) {}
|
|||
|
|
try { uni.offSocketError && uni.offSocketError(); } catch (e) {}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
uni.onSocketOpen(res => {
|
|||
|
|
this.log(this._label('globalOnOpen'), res);
|
|||
|
|
this.connected = true;
|
|||
|
|
this.reconnectAttempts = 0;
|
|||
|
|
this._lastPongAt = Date.now();
|
|||
|
|
this._startHeartbeat();
|
|||
|
|
this._flushQueue();
|
|||
|
|
this.options.onOpen && this.options.onOpen(res);
|
|||
|
|
});
|
|||
|
|
} catch (e) {}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
uni.onSocketMessage(msg => {
|
|||
|
|
// 全局收到消息也走统一处理
|
|||
|
|
if (this.options.debug) this.log(this._label('globalOnMessage'));
|
|||
|
|
this._handleIncoming(msg);
|
|||
|
|
});
|
|||
|
|
} catch (e) {}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
uni.onSocketClose(res => {
|
|||
|
|
this.log(this._label('globalOnClose'), res);
|
|||
|
|
this.connected = false;
|
|||
|
|
this._stopHeartbeat();
|
|||
|
|
this.options.onClose && this.options.onClose(res);
|
|||
|
|
this._tryReconnect();
|
|||
|
|
});
|
|||
|
|
} catch (e) {}
|
|||
|
|
|
|||
|
|
try {
|
|||
|
|
uni.onSocketError(err => {
|
|||
|
|
this.log(this._label('globalOnError'), err);
|
|||
|
|
this.connected = false;
|
|||
|
|
this._stopHeartbeat();
|
|||
|
|
this.options.onError && this.options.onError(err);
|
|||
|
|
this._tryReconnect();
|
|||
|
|
});
|
|||
|
|
} catch (e) {}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_startHeartbeat() {
|
|||
|
|
this._stopHeartbeat();
|
|||
|
|
if (!this.options.heartbeatInterval) return;
|
|||
|
|
const pingData = typeof this.options.pingMessage === 'string' ? this.options.pingMessage : JSON.stringify(this.options.pingMessage);
|
|||
|
|
this._hbTimer = setInterval(() => {
|
|||
|
|
if (!this.connected) return;
|
|||
|
|
try { this.send(pingData); } catch (e) { this.log('hb send err', e); }
|
|||
|
|
clearTimeout(this._hbTimeoutTimer);
|
|||
|
|
this._hbTimeoutTimer = setTimeout(() => {
|
|||
|
|
const since = Date.now() - this._lastPongAt;
|
|||
|
|
if (since > this.options.heartbeatTimeout) {
|
|||
|
|
this.log(this._label('hbTimeout'), since);
|
|||
|
|
try { this.close(); } catch (e) { this.log('hb close fail', e); }
|
|||
|
|
}
|
|||
|
|
}, this.options.heartbeatTimeout);
|
|||
|
|
}, this.options.heartbeatInterval);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_stopHeartbeat() {
|
|||
|
|
if (this._hbTimer) { clearInterval(this._hbTimer); this._hbTimer = null; }
|
|||
|
|
if (this._hbTimeoutTimer) { clearTimeout(this._hbTimeoutTimer); this._hbTimeoutTimer = null; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_tryReconnect() {
|
|||
|
|
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) { this.log(this._label('reconnectScheduled'), 'exhausted'); return; }
|
|||
|
|
this.reconnectAttempts++;
|
|||
|
|
const delay = Math.min(this.options.reconnectDelayBase * Math.pow(1.5, this.reconnectAttempts - 1), 30000);
|
|||
|
|
this.log(this._label('reconnectScheduled'), delay + 'ms', 'attempt', this.reconnectAttempts);
|
|||
|
|
setTimeout(() => {
|
|||
|
|
this.socketTask = null;
|
|||
|
|
this.connectIfForeground();
|
|||
|
|
}, delay);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
connectIfForeground() {
|
|||
|
|
// 如果你在项目中有前台检测,这里可改为判断前台才 open()
|
|||
|
|
this.open();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
subscribe(type, handler) {
|
|||
|
|
if (!this._subscriptions) this._subscriptions = new Map();
|
|||
|
|
if (!this._subscriptions.has(type)) this._subscriptions.set(type, new Set());
|
|||
|
|
this._subscriptions.get(type).add(handler);
|
|||
|
|
return () => this.unsubscribe(type, handler);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
unsubscribe(type, handler) {
|
|||
|
|
if (!this._subscriptions) return;
|
|||
|
|
const s = this._subscriptions.get(type); if (!s) return; s.delete(handler);
|
|||
|
|
if (s.size === 0) this._subscriptions.delete(type);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
_dispatch(type, data, raw) {
|
|||
|
|
if (!this._subscriptions) return;
|
|||
|
|
const handlers = this._subscriptions.get(type);
|
|||
|
|
if (handlers) for (const h of Array.from(handlers)) try { h(data, raw); } catch (e) { this.log('handler err', e); }
|
|||
|
|
const def = this._subscriptions.get('__default__');
|
|||
|
|
if (def) for (const h of Array.from(def)) try { h(data, raw); } catch (e) { this.log('default handler err', e); }
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default WsRequest;
|