// 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;