核心挑战: 微信云托管提供的测试 WSS 地址无法用于正式发布,且小程序端 weapp.socketio库同样仅限测试。在缺乏备案域名的情况下,如何实现正式环境的 WebSocket 连接?
解决方案: 利用微信云托管提供的 wx.cloud.connectContainerAPI 建立与后端容器的 WebSocket 连接。由于该 API 提供的是标准的 WebSocket 连接,我们需要在客户端手动实现 Engine.IO 和 Socket.IO 协议。
一、协议基础
在实现代码前,需要理解两个关键协议:
-
Engine.IO (v4): 底层传输协议,负责建立连接、心跳、消息分帧等。其数据包类型使用单个数字标识:
0: OPEN (连接初始化)1: CLOSE (关闭连接)2: PING (心跳请求)3: PONG (心跳响应)4: MESSAGE (上层消息)5: UPGRADE (协议升级 - WebSocket 场景通常不需要)6: NOOP (空操作 - 用于轮询传输)
-
Socket.IO (v5): 构建在 Engine.IO 之上的高层协议,提供命名空间、房间、事件广播等功能。其数据包类型在 Engine.IO 的
MESSAGE(4) 包内,使用第二个数字标识:0: CONNECT (连接到命名空间)1: DISCONNECT (从命名空间断开)2: EVENT (事件)3: ACK (事件确认)4: CONNECT_ERROR (命名空间连接错误)5: BINARY_EVENT (二进制事件)6: BINARY_ACK (二进制确认)
关键理解: 一个 Socket.IO 通信包通常由两个数字开头。第一个数字是 Engine.IO 类型,第二个数字是 Socket.IO 类型。例如,40表示 Engine.IO 的 MESSAGE包,其内容是 Socket.IO 的 CONNECT包。
二、代码实现
1. 连接云托管容器 & 基本事件处理
// 连接云托管容器
const { socketTask } = await wx.cloud.connectContainer({
service: this.service, // 微信云托管服务列表中的服务名称
path: '/socket.io/?transport=websocket&namespace=%2F&EIO=4', // Socket.IO 服务的标准连接路径
});
this.container = socketTask;
console.log('云托管容器 WebSocket 连接成功');
// 监听 WebSocket 打开事件
this.container.onOpen(() => {
this._isConnected = true;
console.log('WebSocket 连接已建立,发送 Socket.IO CONNECT 包 (40)');
// 发送 Socket.IO CONNECT 包: Engine.IO 类型 4 (MESSAGE) + Socket.IO 类型 0 (CONNECT)
const connectPacket = '40'; // 连接根命名空间 '/'
this._sendSocketIOPacketRaw(connectPacket);
});
// 监听 WebSocket 消息事件
this.container.onMessage((res) => {
if (this._debug) {
console.log('收到原始 WebSocket 消息:', res);
}
this._handleSocketIOMessage(res); // 处理 Engine.IO/Socket.IO 协议
});
// 监听 WebSocket 关闭事件
this.container.onClose(() => {
console.log('WebSocket 连接已关闭');
this._isConnected = false;
this._emitInternal('ws_close', {}); // 触发内部关闭事件
// 非主动断开,尝试自动重连
if (!this._manuallyClosed) {
setTimeout(() => {
if (this._manuallyClosed) return;
console.log('尝试重新连接...');
this.connect(this.service);
}, this._reconnectDelay);
}
});
// 监听 WebSocket 错误事件
this.container.onError((err) => {
console.error('WebSocket 连接发生错误:', err);
this._emitInternal('ws_error', err); // 触发内部错误事件
});
2. 加入/离开房间 & 发送事件
// 加入房间
join(roomId) {
if (!roomId) return;
const id = Number(roomId);
this._joinedRooms.add(id);
// 发送 'room:join' 事件到服务器
// 格式: Engine.IO 类型 4 (MESSAGE) + Socket.IO 类型 2 (EVENT) + 事件数据
this.emitEvent('room:join', { roomId: id });
}
// 离开房间
leave(roomId) {
if (!roomId) return;
const id = Number(roomId);
this._joinedRooms.delete(id);
// 发送 'room:leave' 事件到服务器
this.emitEvent('room:leave', { roomId: id });
}
// 发送 Socket.IO 事件 (核心方法)
emitEvent(eventName, data) {
// 构造 Socket.IO EVENT 包结构
const packet = {
type: 4, // Engine.IO MESSAGE
subType: 2, // Socket.IO EVENT
action: eventName,
data: data
};
this._sendSocketIOPacket(packet);
}
// 构造并发送符合 Socket.IO 协议的包
_sendSocketIOPacket(packet) {
if (!this.container) return;
let packetString = '';
if (packet.type !== undefined) {
packetString = `${packet.type}`; // Engine.IO 类型
if (packet.subType !== undefined) {
packetString += `${packet.subType}`; // Socket.IO 类型
}
// 构造事件数据数组: [eventName, eventData]
const sendData = [];
if (packet.action !== undefined) {
sendData.push(packet.action);
}
if (packet.data !== undefined) {
sendData.push(packet.data);
}
// 如果有数据,序列化并附加到包字符串
if (sendData.length > 0) {
packetString += JSON.stringify(sendData);
}
}
// 发送最终的协议字符串
this._sendSocketIOPacketRaw(packetString);
}
// 发送原始协议字符串 (处理连接状态和队列)
_sendSocketIOPacketRaw(packetString) {
if (!this.container) return;
if (this._isConnected) {
this.container.send({
data: packetString,
success: () => {
if (this._debug) {
console.log('Socket.IO 包发送成功:', packetString.substring(0, 50) + (packetString.length > 50 ? '...' : ''));
}
},
fail: (err) => {
console.error('Socket.IO 包发送失败:', err);
this._messageQueue.push(packetString); // 加入重发队列
}
});
} else {
this._messageQueue.push(packetString); // 连接未建立,加入队列等待
}
}
3. 解析服务器消息 (核心逻辑)
// 处理从 WebSocket 收到的原始消息
_handleSocketIOMessage(res) {
try {
let data = res.data || res;
if (typeof data === 'string') {
// 解析协议字符串: [EngineIOType][SocketIOType?][JSONData]
const packetType = parseInt(data.charAt(0)); // Engine.IO 类型
let subType = -1; // Socket.IO 类型 (默认为 -1 表示可能没有)
let jsonDataStart = 1; // JSON 数据起始位置
// 检查第二个字符是否是数字 (即是否有 Socket.IO 类型)
if (data.length > 1 && !isNaN(parseInt(data.charAt(1))) {
subType = parseInt(data.charAt(1));
jsonDataStart = 2;
}
// 提取 JSON 数据部分 (如果有)
const packetData = (jsonDataStart < data.length) ? data.substring(jsonDataStart) : null;
let parsedData = null;
// 尝试解析 JSON 数据
if (packetData && (packetData.startsWith('[') || packetData.startsWith('{'))) {
try {
parsedData = JSON.parse(packetData);
} catch (e) {
console.warn('解析 JSON 数据失败:', packetData, e);
}
}
if (this._debug) {
console.log('解析协议包:', `EIO:${packetType}`, `SIO:${subType}`, 'Data:', parsedData || packetData);
}
// 交给包处理器
this._handleSocketIOPacket({
type: packetType,
subType: subType,
data: parsedData || packetData // 优先使用解析后的对象,否则用原始字符串
});
} else if (typeof data === 'object') {
// 处理非协议格式的普通对象消息 (根据业务需要)
this._handleNormalMessage(data);
}
} catch (error) {
console.error('消息处理过程中发生错误:', error, '原始消息:', res);
}
}
// 处理解析后的协议包
_handleSocketIOPacket(packet) {
const { type, subType, data } = packet;
switch (type) {
case 0: // Engine.IO OPEN (握手响应)
console.log('收到 Engine.IO OPEN 包:', data);
if (data && data.sid) {
console.log('Engine.IO 连接成功,SID:', data.sid);
this._sid = data.sid; // 保存会话 ID
}
break;
case 1: // Engine.IO CLOSE
console.log('收到 Engine.IO CLOSE 包');
break;
case 2: // Engine.IO PING
console.log('收到 Engine.IO PING,发送 PONG');
this._sendPong(); // 必须响应 PONG
break;
case 3: // Engine.IO PONG
console.log('收到 Engine.IO PONG');
break;
case 4: // Engine.IO MESSAGE (包含 Socket.IO 包)
// 根据 Socket.IO 子类型 (subType) 处理
switch (subType) {
case 0: // Socket.IO CONNECT
console.log('Socket.IO 连接成功!');
this._reconnectAttempts = 0;
this._flushQueue(); // 发送积压的消息
this._restoreRooms(); // 重新加入之前的房间
this._emitInternal('ws_open', {}); // 通知应用层连接就绪
break;
case 1: // Socket.IO DISCONNECT
console.log('收到 Socket.IO DISCONNECT');
this._emitInternal('ws_close', {});
break;
case 2: // Socket.IO EVENT
try {
this._handleSocketIOEvent(data); // 处理服务器发来的事件
} catch (error) {
console.error('处理 Socket.IO 事件失败:', error, data);
}
break;
case 3: // Socket.IO ACK (事件确认)
console.log('收到 ACK 包');
// 处理确认逻辑 (如有需要)
break;
case 4: // Socket.IO CONNECT_ERROR
console.error('Socket.IO 连接错误:', data);
this._emitInternal('ws_error', data);
break;
default:
console.log('收到未知的 Socket.IO 子类型包:', subType, data);
}
break;
default:
console.log('收到未知的 Engine.IO 类型包:', type, data);
}
}
4. 心跳处理
// 响应 Engine.IO PING
_sendPong() {
// Engine.IO PONG 包: 类型 `3`
this._sendSocketIOPacketRaw('3');
}
三、总结与注意事项
- 适用场景: 此方案是解决微信小程序正式环境无法直接使用测试 WSS 地址和
weapp.socketio库的替代方案,特别适用于没有备案域名的情况。 - 核心原理: 利用
wx.cloud.connectContainer建立标准 WebSocket 连接,客户端手动实现 Engine.IO (v4) 和 Socket.IO (v5) 协议。 - 协议关键: 深刻理解 Engine.IO 包类型和 Socket.IO 包类型(作为 Engine.IO MESSAGE 的子类型)的格式与含义是成功实现的基础。
- 连接流程: WebSocket
onOpen-> 发送40(Socket.IO CONNECT) -> 收到40(Socket.IO CONNECT 成功) -> 连接就绪。 - 心跳: 客户端被动响应 PING (
2) 为 PONG (3)。 - 调试: 强烈建议开启
_debug模式,详细打印发送和接收的协议包,是定位问题的关键。 - 重连: 实现了基本的自动重连逻辑和消息队列重发。
- 小程序限制: 注意微信小程序对 WebSocket 并发数和后台运行的限制。
体验: 基于此方案实现的微信小程序 WebSocket 应用已上线,欢迎体验