hldy_app_mini/common/websocket.js

382 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// common/websocket.js
// 带中文日志与消息体打印选项的 WsRequest替换你当前文件即可
class WsRequest {
// 静态:防止重复全局绑定
static _globalBound = false;
constructor(url = '', options = {}) {
this.url = url || '';
this.options = Object.assign({
header: {},
protocols: [],
debug: true,
lang: 'zh', // 'zh' 或 'en'
showMessageBody: true,
filterPing: 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();
// 是否允许自动重连(主动 close 时设为 false
this._shouldReconnect = true;
this._manualClose = false;
// 如果要求全局绑定且还没绑定过,则绑定一次(静态控制)
if (this.options.bindGlobal && !WsRequest._globalBound) {
this._bindGlobalSocketEvents();
WsRequest._globalBound = true;
} else if (this.options.bindGlobal && WsRequest._globalBound) {
// 已由其他实例绑定,跳过
}
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;
}
// 简单统一日志(恢复 console 输出,便于调试)
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);
// 主动 open允许后续自动重连如果被之前的手动关闭禁止过open 表示想要恢复)
this._shouldReconnect = true;
this._manualClose = false;
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'));
// 依赖全局回调,已在构造时绑定
}
} catch (e) {
console.error(e);
}
}
/**
* close(code, reason, manual)
* manual: 是否为手动/意图性关闭(默认 true
* - manual === true 时将禁止自动重连(用于用户主动关闭)
* - manual === false 时视为异常或内部触发的关闭(允许重连)
*/
close(code = 1000, reason = 'client close', manual = true) {
// 标记手动关闭以禁止后续自动重连
if (manual) {
this._shouldReconnect = false;
this._manualClose = true;
} else {
// 非手动关闭(例如心跳超时)则保持 _shouldReconnect 原有值(通常为 true
this._manualClose = false;
}
this._stopHeartbeat();
try {
if (this.socketTask && typeof this.socketTask.close === 'function') {
try {
this.socketTask.close({ code, reason });
} catch (e) {
// 某些平台 socketTask.close 可能会抛错
this.log(this._label('closeError'), e);
// 尝试全局 close
if (typeof uni.closeSocket === 'function') uni.closeSocket();
}
} else if (typeof uni.closeSocket === 'function') {
uni.closeSocket();
}
} catch (e) {
this.log(this._label('closeError'), e);
}
this.connected = false;
// 清理 socketTask 引用,释放资源,防止重复使用导致 FD 泄漏
this.socketTask = null;
}
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);
// 打开时允许后续重连(如果连接成功)
this._shouldReconnect = true;
this._manualClose = false;
});
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
const isPing = data && (data.type === 'ping' || data.type === 'pong' || (typeof data === 'string' && (data === 'ping' || data === 'pong')));
if (isPing && this.options.filterPing) {
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);
// 同上,打开时允许重连
this._shouldReconnect = true;
this._manualClose = false;
});
} 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);
// 由心跳触发的关闭视为“异常/内部”关闭 — 传 manual = false 以允许重连
try { this.close(1000, 'hb timeout', false); } 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._shouldReconnect) {
this.log(this._label('reconnectScheduled'), 'reconnect disabled (manual close)');
return;
}
if (this.reconnectAttempts >= this.options.maxReconnectAttempts) { this.log(this._label('reconnectScheduled'), 'exhausted'); return; }
this.reconnectAttempts++;
// 指数退避 + jitter避免同时大量重连
const base = this.options.reconnectDelayBase || 1000;
const delay = Math.min(base * Math.pow(1.5, this.reconnectAttempts - 1), 30000);
const jitter = Math.floor(Math.random() * 401) - 200; // -200 .. +200 ms
const finalDelay = Math.max(0, Math.floor(delay) + jitter);
this.log(this._label('reconnectScheduled'), finalDelay + 'ms', 'attempt', this.reconnectAttempts);
setTimeout(() => {
this.socketTask = null;
this.connectIfForeground();
}, finalDelay);
}
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;