WebSocket消息收发机制:联机卡牌对战客户端状态同步与断线重连实战
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 的体积会上升可以引入轻量级的二进制协议或者字段裁剪。二是观战模式观战者只需要接收状态同步不需要发送操作可以复用同一套消息分发机制只是把发送队列禁用掉。最后分享一个小技巧在开发阶段我会写一个模拟服务端可以手动构造各种消息推给客户端包括异常消息、乱序消息、延迟消息。这样不用等真实服务端就能测试客户端的健壮性效率高很多。这个模拟器后来成了我们排查问题的标配工具。

相关新闻

双臂具身VLA模型从零到落地:架构、数据与真机部署实战解析

双臂具身VLA模型从零到落地:架构、数据与真机部署实战解析

从零开始的双臂具身VLA起源及现阶段发展综述开门见山说个结论:具身智能最近两年能火出圈,VLA(Vision-Language-Action,视觉-语言-动作)模型功不可没,而双臂具身VLA又是其中最有工程挑战、也最能直接兑现商业…

2026/10/19 14:30:23 阅读更多 →
MATLAB真Sankey图:流量守恒校验与自动布局实现

MATLAB真Sankey图:流量守恒校验与自动布局实现

简介:本资源是面向MATLAB中高级用户与科研可视化需求者的Sankey图绘制工具包,专为弥补MATLAB原生库缺乏单向Sankey图支持的空白而设计,适用于能源流分析、物质循环建模、信息传递路径展示等跨学科场景。压缩包共2个文件(1个核心函…

2026/10/19 14:29:22 阅读更多 →
基于NSGA-Ⅲ的梯级水电火电联合多目标优化调度实践

基于NSGA-Ⅲ的梯级水电火电联合多目标优化调度实践

最近在给一个研究生团队做电力系统调度优化方面的技术指导,接触到一个很有代表性的课题:梯级水电和火电机组的联合多目标调度。这个课题最让人头疼的地方不是模型有多复杂,而是目标太多、约束太杂——又要省煤,又要少排污染物&…

2026/10/19 14:29:22 阅读更多 →

最新新闻

遥感+AI农业落地:破解看不清、测不准、来不及的工程实战

遥感+AI农业落地:破解看不清、测不准、来不及的工程实战

我前几年接过一个挺典型的活儿:一家农服公司花了不少钱找人开发了一套遥感监测系统,界面做得挺漂亮,点开地图能看到各种五颜六色的长势图层、面积统计、风险分片。但客户那边负责落地的老哥跟我说了一句大实话:“这图拿给领导看&a…

2026/10/19 15:15:22 阅读更多 →
STM32在线IAP升级实战:双Bank+自定义协议+Flash安全擦写

STM32在线IAP升级实战:双Bank+自定义协议+Flash安全擦写

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 15:15:22 阅读更多 →
ROS主控与STM32串口通信实战:从硬件接线到协议解析

ROS主控与STM32串口通信实战:从硬件接线到协议解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 15:15:22 阅读更多 →
芯片应用赛道赛题汇总深度解读与备赛全攻略

芯片应用赛道赛题汇总深度解读与备赛全攻略

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 15:15:22 阅读更多 →
四足机器人控制协议设计:从帧格式到步态控制详解

四足机器人控制协议设计:从帧格式到步态控制详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 15:15:22 阅读更多 →
实时决策中的Storm应用:从拓扑设计到生产调优实践

实时决策中的Storm应用:从拓扑设计到生产调优实践

1. 为什么"实时决策"绕不开Storm这样的流式计算引擎先从我实际接手过的一个项目说起。某业务方的运营团队每天早晨第一件事,是打开前一天产出的离线报表,看到某类风险交易在昨天下午三点集中爆发,然后才开始排查。数据确实有了&…

2026/10/19 15:14:14 阅读更多 →

日新闻

频率跟踪型PWM电源设计:臭氧发生器谐振点漂移与软开关实现

频率跟踪型PWM电源设计:臭氧发生器谐振点漂移与软开关实现

简介:本资源为电源技术方向的学术研究文档,聚焦介质阻挡放电型臭氧发生器电源的频率跟踪型PWM控制策略,面向电力电子、高压电源及臭氧应用领域的科研人员与工程技术人员。针对负载呈容性、等效电容随外加电压升高而增大、谐振频率随之漂移的难…

2026/10/19 0:00:47 阅读更多 →
Vibe Coding 遇上 TaoToken:把开发者思维圣殿的灵感流接入统一 Key 通道

Vibe Coding 遇上 TaoToken:把开发者思维圣殿的灵感流接入统一 Key 通道

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 0:00:47 阅读更多 →
HundredCV-Chat话题建模实战教程:利用4,871个内置话题标签构建主题分析系统

HundredCV-Chat话题建模实战教程:利用4,871个内置话题标签构建主题分析系统

【免费下载链接】HundredCV-Chat 项目地址: https://ai.gitcode.com/hf_mirrors/Jax-dan/HundredCV-Chat 点击查看 免费下载 HundredCV-Chat 是一个由 100 位青年简历数据开发而来的中文多轮对话数据集,共包含 24,750 组日常闲聊对话和 4,871 个话题标签…

2026/10/19 0:00:47 阅读更多 →

周新闻

基于GMM的语音识别系统原理与MATLAB实现详解

基于GMM的语音识别系统原理与MATLAB实现详解

说到基于高斯混合模型(GMM)的语音识别系统,很多人第一反应就是一堆数学公式,觉得门槛很高。但如果你真的在MATLAB里把它落地跑通,会发现核心其实就是三件事:把声音变成特征向量,用GMM给每个类别…

2026/10/19 10:02:04 阅读更多 →
数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理入门:反白、灰度化、调色板修改与导出txt实践

数字图像处理这四个字听起来挺唬人,但落到具体操作上,无非就是让计算机按你的想法“摆弄”像素。我在一个模拟项目X里用了一套特别经典的基础处理流程:对图像做反白、改调色板、彩色转灰度,最后把像素数据导出成txt文件。单看每一…

2026/10/18 0:00:55 阅读更多 →
机器人大脑技术盘点:具身智能的感知、决策与数据闭环

机器人大脑技术盘点:具身智能的感知、决策与数据闭环

如果你的信息流最近也天天刷到“机器人大脑”这个词,你会发现一个很有意思的现象:每个人说的“大脑”根本不是同一个东西。有人指的是大语言模型,有人指的是运动控制系统,还有人指的是能理解物理世界的端到端模型。在2026年这个时…

2026/10/19 1:30:52 阅读更多 →

月新闻

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

我发现了一个新思路:用 Remotion + Claude Code 像写代码一样自动化生成短视频

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 2:54:50 阅读更多 →
Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

Windows下 Codex 中 Chrome 和 Computer Use 插件不可用问题排查及解决参考方式:TaoToken 统一 Key 配置与验证

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/19 9:10:22 阅读更多 →
黑夜航拍船只数据集训练YOLOV5模型全流程解析

黑夜航拍船只数据集训练YOLOV5模型全流程解析

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

2026/10/18 12:16:16 阅读更多 →