
1. 联机对战客户端消息收发的整体设计思路做联机卡牌对战最核心也最容易翻车的地方不是渲染也不是卡牌逻辑而是消息的收发机制。我见过太多项目单机跑得飞起一联机就各种状态不同步、消息乱序、断线重连后数据全丢。这一篇就专门聊客户端这一侧怎么把 WebSocket 的消息收发做扎实。先明确一下这个模块要解决什么问题。在“游戏王”这类回合制卡牌对战里客户端需要做三件事第一把玩家的操作召唤、发动、盖牌、攻击、结束回合准确发给服务端第二接收服务端广播回来的对战状态并驱动界面更新第三处理各种异常情况比如网络抖动、消息延迟、对手掉线。这三件事听起来简单但真正落地的时候消息的顺序性、幂等性、可恢复性是三个绕不开的坎。我选择 WebSocket 而不是轮询或者长连接 HTTP理由很直接卡牌对战是典型的低频双向通信场景玩家操作间隔可能几秒到几十秒但一旦有操作双方都要立刻感知。轮询的延迟和资源浪费都不划算而 WebSocket 天然全双工服务端可以主动推送客户端也能随时上报握手之后就是一条持久通道非常适合这种“静默期长、爆发期短”的交互模式。整体架构上我采用的是单连接 消息队列 状态机的组合。单连接是指整个对战过程只维持一条 WebSocket避免多连接带来的状态同步复杂度消息队列是为了解决发送频率和网络抖动的问题防止短时间内大量消息把连接打爆状态机则是用来管理对战阶段等待、抽卡、主要阶段、战斗阶段、结束阶段确保收到的消息在当前阶段是合法的非法消息直接丢弃并记录日志。这里有个关键决策点消息是直接发对象还是先序列化。我的做法是统一走 JSON 序列化并且在序列化之前做一层“消息包装”。包装结构大概长这样{ type: ACTION, seq: 1024, timestamp: 1710000000000, payload: { ... } }type用来区分消息类别seq是客户端自增序列号timestamp用于服务端做时序校验payload才是真正的业务数据。为什么要加seq因为 WebSocket 虽然保证消息不丢不重但在极端网络情况下服务端可能收到乱序的消息尤其是客户端短时间内连续发送多条时。有了seq服务端就能按序处理客户端也能根据服务端返回的ack判断哪些消息已经落地。另一个决策点是消息的粒度。我见过有人把整个对战状态每次都全量发一遍简单是简单但流量和解析开销都大。我的做法是增量消息 定期全量快照。平时只发变化的部分比如“某玩家召唤了某怪兽”客户端收到后局部更新每隔一定回合或者关键节点服务端推一次全量状态客户端用来做校准。这样既保证了实时性又能在出现偏差时快速恢复。提示增量消息的设计一定要保证“可重放”。也就是说客户端从任意一个快照开始按顺序应用后续增量消息最终状态必须和服务端一致。这是后面做断线重连的基础。2. 客户端消息收发的核心细节与实操要点2.1 连接建立与心跳保活WebSocket 连接不是建完就一劳永逸的。实际网络环境里中间设备可能在不通知两端的情况下断开空闲连接。我实测下来如果 60 秒没有任何数据往来部分网络环境就会悄悄掐断。所以心跳机制是必须的。我的心跳方案是客户端每 25 秒发一次PING服务端收到后回PONG。如果客户端连续两次没收到PONG也就是 50 秒就主动关闭连接并触发重连。为什么是 25 秒因为大多数中间设备的空闲超时在 60 秒左右25 秒留足了余量又不至于太频繁浪费资源。const HEARTBEAT_INTERVAL 25000; const MAX_MISSED_PONG 2; let missedPongCount 0; let heartbeatTimer null; function startHeartbeat() { heartbeatTimer setInterval(() { if (missedPongCount MAX_MISSED_PONG) { console.warn(心跳超时主动断开重连); socket.close(); return; } missedPongCount; sendMessage({ type: PING, seq: nextSeq(), timestamp: Date.now() }); }, HEARTBEAT_INTERVAL); } function onPongReceived() { missedPongCount 0; }这里有个细节PING消息也要走统一的发送队列不能直接socket.send否则可能和业务消息交错导致服务端处理顺序混乱。我一开始就是直接发的结果出现过服务端先收到PING再收到之前的操作消息虽然不影响业务但日志看起来很难受。2.2 消息发送队列与背压处理客户端的操作频率虽然不高但有些场景下会短时间爆发比如玩家快速点击多张卡牌、连续发动效果。如果每次都直接socket.send在bufferedAmount堆积的时候会导致内存上涨甚至消息延迟。我的做法是维护一个发送队列配合bufferedAmount做背压控制const sendQueue []; let isSending false; const MAX_BUFFERED_AMOUNT 64 * 1024; // 64KB function enqueueMessage(msg) { sendQueue.push(msg); processQueue(); } function processQueue() { if (isSending || sendQueue.length 0) return; if (socket.bufferedAmount MAX_BUFFERED_AMOUNT) { setTimeout(processQueue, 50); return; } isSending true; const msg sendQueue.shift(); socket.send(JSON.stringify(msg)); isSending false; if (sendQueue.length 0) { setTimeout(processQueue, 0); } }MAX_BUFFERED_AMOUNT设成 64KB 是经验值。太小会导致频繁等待太大则失去背压意义。卡牌对战的消息体一般也就几百字节到几 KB64KB 足够缓冲上百条消息同时不会让内存失控。注意bufferedAmount是只增不减的只有在数据真正发出去之后才会下降。所以判断背压的时候不能只看它是否大于阈值还要结合队列长度一起看。2.3 消息接收与分发机制接收端的核心是分发器。服务端推过来的消息类型很多有对战状态同步、有对手操作通知、有系统提示、有错误返回。如果全堆在一个onmessage里用if-else判断代码会迅速膨胀到无法维护。我的做法是注册一张消息处理器表const handlers { STATE_UPDATE: handleStateUpdate, OPPONENT_ACTION: handleOpponentAction, SYSTEM_NOTICE: handleSystemNotice, ERROR: handleError, PONG: onPongReceived, RECONNECT_SNAPSHOT: handleReconnectSnapshot, }; function onMessage(event) { let msg; try { msg JSON.parse(event.data); } catch (e) { console.error(消息解析失败, event.data); return; } const handler handlers[msg.type]; if (!handler) { console.warn(未知消息类型, msg.type); return; } handler(msg); }每个处理器只关心自己的业务逻辑互不干扰。这样新增消息类型只需要加一个键值对不用动主流程。2.4 消息顺序与幂等性保障前面提到seq的作用这里展开说。客户端发送的每条业务消息都带一个自增seq服务端处理后会返回ack带上对应的seq。客户端维护一个pendingMessages映射收到ack后删除对应项。如果某条消息超过一定时间没收到ack就触发重发或者提示用户。const pendingMessages new Map(); let currentSeq 0; function sendWithAck(type, payload) { const seq currentSeq; const msg { type, seq, timestamp: Date.now(), payload }; pendingMessages.set(seq, { msg, retries: 0, sentAt: Date.now() }); enqueueMessage(msg); return seq; } function onAck(seq) { pendingMessages.delete(seq); }重发策略上我设置最多重试 3 次每次间隔 2 秒。超过 3 次还没ack就认为连接有问题触发重连流程。这里要注意重发的时候seq不变服务端需要根据seq做去重否则同一个操作可能被执行两次。提示幂等性不只是服务端的事客户端也要配合。比如“结束回合”这种操作如果重发两次服务端必须保证只生效一次。通常的做法是服务端记录已处理的seq重复的直接忽略并返回ack。3. 完整实操流程与关键环节实现3.1 初始化连接与状态机绑定整个客户端的消息收发是和对战状态机绑定的。初始化的时候先建立连接连接成功后进入“等待对手”状态收到服务端的MATCH_START后进入“对战准备”然后根据服务端推送的阶段消息切换状态。const BattleState { IDLE: IDLE, CONNECTING: CONNECTING, WAITING_OPPONENT: WAITING_OPPONENT, PREPARING: PREPARING, MAIN_PHASE: MAIN_PHASE, BATTLE_PHASE: BATTLE_PHASE, END_PHASE: END_PHASE, FINISHED: FINISHED, }; let currentState BattleState.IDLE; function connect(url) { currentState BattleState.CONNECTING; socket new WebSocket(url); socket.onopen () { currentState BattleState.WAITING_OPPONENT; startHeartbeat(); flushPendingMessages(); }; socket.onmessage onMessage; socket.onclose onClose; socket.onerror onError; }状态机的作用是约束消息的合法性。比如在WAITING_OPPONENT状态下收到BATTLE_PHASE的消息就说明服务端状态和客户端不一致需要触发一次全量同步。这种防御性设计在实际对战里能避免很多诡异 bug。3.2 发送玩家操作的完整链路以“召唤怪兽”为例完整链路是这样的玩家点击手牌中的怪兽界面触发onSummonCard(cardId)。客户端校验当前状态是否为MAIN_PHASE以及是否轮到己方操作。校验通过后构造消息{ type: ACTION, payload: { action: SUMMON, cardId } }。调用sendWithAck消息进入发送队列。消息发出后界面进入“等待服务端确认”的临时状态按钮置灰。收到服务端ack后解除临时状态等待服务端推送正式的STATE_UPDATE。收到STATE_UPDATE后更新手牌、场上怪兽区、墓地等界面元素。这里第 5 步的“临时状态”很关键。如果不置灰玩家可能连续点击同一张卡导致重复发送。虽然服务端有幂等处理但客户端体验会很差。function onSummonCard(cardId) { if (currentState ! BattleState.MAIN_PHASE) return; if (!isMyTurn) return; if (isCardPending(cardId)) return; markCardPending(cardId); sendWithAck(ACTION, { action: SUMMON, cardId }); }3.3 接收服务端状态同步的处理服务端推送的STATE_UPDATE是客户端界面的主要数据来源。它的结构我设计成分区更新{ type: STATE_UPDATE, payload: { turn: 5, phase: MAIN_PHASE, activePlayer: player1, myHand: [...], myField: [...], opponentField: [...], myGraveyard: [...], opponentGraveyard: [...], lifePoints: { player1: 6000, player2: 4000 } } }客户端收到后不是全量重绘而是做diff 更新。比如手牌区先对比新旧手牌列表找出新增、移除、位置变化的卡牌只更新变化的部分。这样能避免界面闪烁也能减少 DOM 操作。function handleStateUpdate(msg) { const newState msg.payload; const diff computeDiff(previousState, newState); applyDiffToUI(diff); previousState newState; updateStateMachine(newState.phase, newState.activePlayer); }computeDiff的实现要根据具体数据结构来核心是比较卡牌的唯一 ID 和位置。卡牌对战里每张卡都有唯一实例 ID所以 diff 很可靠。3.4 断线重连与状态恢复断线重连是联机对战里最考验设计的一环。我的方案是客户端检测到连接断开后先进入RECONNECTING状态暂停所有用户操作然后按指数退避重试连接1 秒、2 秒、4 秒、8 秒最多 30 秒。连接成功后客户端发送RECONNECT_REQUEST带上之前的sessionId和最后收到的seq。服务端收到后返回一个RECONNECT_SNAPSHOT包含当前完整对战状态以及从客户端最后seq之后的所有增量消息。客户端先应用快照再按顺序应用增量消息最终状态就和服务端一致了。function onReconnectSuccess() { sendMessage({ type: RECONNECT_REQUEST, payload: { sessionId: storedSessionId, lastSeq: lastReceivedSeq, }, }); } function handleReconnectSnapshot(msg) { applyFullState(msg.payload.snapshot); msg.payload.incrementalMessages.forEach((m) { handleStateUpdate(m); }); currentState BattleState.MAIN_PHASE; resumeUserInteraction(); }注意重连期间服务端要保留增量消息不能因为客户端断开就丢弃。通常的做法是服务端为每个会话维护一个消息缓冲区客户端确认收到后才清理。4. 常见问题与排查技巧实录4.1 消息乱序与丢失的排查实际对战里我遇到过几次“操作发出去了但没生效”的情况。排查下来大部分是消息在发送队列里被阻塞或者ack丢失导致客户端误判。排查思路是这样的先看客户端日志里有没有send记录再看服务端日志里有没有对应的receive记录。如果客户端有send但服务端没有receive说明消息在传输层丢了这时候 WebSocket 本身应该会触发重传但如果连接已经半死就需要靠心跳检测来发现。如果服务端有receive但客户端没收到ack那就是ack消息丢了客户端会重发服务端需要幂等处理。我整理了一个速查表现象可能原因排查方法解决方式操作无响应消息未发出检查发送队列和bufferedAmount增加背压阈值或优化队列操作重复执行ack丢失导致重发对比客户端seq和服务端处理记录服务端按seq去重状态不同步增量消息丢失对比双方状态快照触发全量同步连接频繁断开心跳超时或网络抖动查看心跳日志和onclose事件调整心跳间隔增加重连退避重连后数据错乱增量消息顺序错误检查seq排序按seq排序后再应用4.2 高频操作下的性能问题有一次测试玩家在战斗阶段连续发动多张陷阱卡客户端短时间发了十几条消息结果界面卡顿明显。排查发现是每次socket.send都触发了 JSON 序列化而且onmessage里做了全量 DOM 更新。优化方案有两个第一把 JSON 序列化放到requestIdleCallback里批量处理减少主线程阻塞第二接收端做 diff 更新避免全量重绘。优化之后同样的操作序列帧率从 30 左右恢复到 60。function batchSend(messages) { requestIdleCallback(() { messages.forEach((msg) enqueueMessage(msg)); }); }4.3 跨平台兼容性踩坑不同浏览器对 WebSocket 的实现有细微差异。我遇到过某浏览器在bufferedAmount超过阈值时不会触发onerror而是静默丢弃消息。还有的浏览器在页面切到后台时会自动降低心跳频率导致服务端误判掉线。针对这些情况我的做法是第一不依赖onerror判断连接状态而是用心跳和ack超时来综合判断第二页面可见性变化时主动调整心跳策略切到后台时降低频率但不断开切回前台时立即发一次心跳确认连接。document.addEventListener(visibilitychange, () { if (document.hidden) { adjustHeartbeat(60000); } else { adjustHeartbeat(25000); sendMessage({ type: PING, seq: nextSeq(), timestamp: Date.now() }); } });4.4 日志与调试技巧联机对战调试最痛苦的是“问题难以复现”。我的经验是客户端和服务端都要打详细日志并且日志里带上seq、timestamp、sessionId方便对账。客户端可以用console.group把一次操作的完整链路串起来看起来清晰。function logMessage(direction, msg) { console.group([${direction}] ${msg.type} seq${msg.seq}); console.log(payload:, msg.payload); console.log(time:, new Date(msg.timestamp).toISOString()); console.groupEnd(); }另外我会在开发环境里加一个“消息录制”功能把所有收发的消息存到内存数组里出问题的时候一键导出配合服务端日志做时间线对齐。这个功能帮我省了无数排查时间。提示生产环境记得关掉详细日志只保留错误级别避免性能损耗和隐私泄露。5. 一些个人体会与后续扩展方向这套消息收发机制跑下来最深的体会是联机对战的核心不是“发消息”而是“对状态”。消息只是载体真正要保证的是双方对战场状态的认知一致。所以seq、ack、快照、增量、重连这些机制本质上都是在为“状态一致性”服务。后续如果要扩展我会考虑两个方向。一是消息压缩当对战状态变大时JSON 的体积会上升可以引入轻量级的二进制协议或者字段裁剪。二是观战模式观战者只需要接收状态同步不需要发送操作可以复用同一套消息分发机制只是把发送队列禁用掉。最后分享一个小技巧在开发阶段我会写一个模拟服务端可以手动构造各种消息推给客户端包括异常消息、乱序消息、延迟消息。这样不用等真实服务端就能测试客户端的健壮性效率高很多。这个模拟器后来成了我们排查问题的标配工具。