news 2026/9/18 10:43:49

从CRDT到Canvas:MiroFish多人实时协作画布实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
从CRDT到Canvas:MiroFish多人实时协作画布实践

做多人实时协作画布这件事,我从两年前就开始琢磨了。市面上的协作白板工具确实好用,但当团队需求变得"奇怪"一点——比如要把画布和我们自己的任务系统打通、要私有化部署、要接入内部的权限体系——现成产品就开始处处别扭。MiroFish 就是在这样的背景下被我一点点搭出来的:一个以"鱼群"为隐喻的多人实时协作画布项目,核心目标是让一群人像鱼群一样,不用指挥也能同步游动。这篇文章我会把整个项目的设计思路、技术选型、实时协同底座、画布渲染优化、数据持久化和踩过的坑,尽可能完整地摊开讲。如果你也在做实时协作类的前端项目,或者单纯想搞清楚"多人同时编辑一块画布"背后的门道,这篇内容应该能让你少走不少弯路。

1. 为什么我要做 MiroFish:需求拆解与技术选型

1.1 从一个真实的协作痛点说起

最开始的需求其实特别朴素。我们团队做产品评审时习惯用白板画流程,但用的工具是外部服务,画完的内容要截图贴回内部文档,评审意见散落在各个聊天窗口里。更要命的是,涉及一些内部业务架构的内容,我们并不希望它们离开自己的服务器。于是我给自己定了一个小目标:做一个能在内网跑起来、支持多人同时在线编辑、数据结构可控的协作画布。

"MiroFish"这个名字是我自己起的。Miro 取自协作画布的意象,Fish 取的是鱼群的意象——一群鱼在没有任何中央指挥的情况下,依然能保持队形、同步转向、规避障碍。这恰好就是多人实时协作最难也最迷人的地方:每个客户端都是独立的个体,却要在几百毫秒内达成视觉上的一致。理解了这个隐喻,你就能理解这个项目所有技术决策的出发点——一致性、低延迟、去中心化的局部自治

这个项目适合几类人参考:一是想自己搭协作白板的前端工程师;二是对 CRDT、实时同步机制好奇但没机会上手的人;三是需要在产品里嵌入"多人同时操作同一视图"能力的团队。哪怕你只是想知道"两个人同时拖动同一个方块会发生什么",往下看也值。

1.2 技术栈选型的取舍逻辑

选型这件事,我的原则是先用最熟悉的轮子把核心链路跑通,再针对性替换瓶颈。所以第一版我没有一上来就追求极致性能,而是选了上手快、生态好的组合。

模块选型选择理由备选与放弃原因
前端框架React + TypeScript组件化清晰,类型系统能兜住复杂数据结构Vue 也合适,团队更熟 React
状态管理Zustand轻量,适合高频更新的画布状态Redux 样板代码太多,高频更新时性能吃紧
画布渲染Canvas 2D 起步API 简单,性能远好于 DOM 方案SVG 元素一多就卡,WebGL 学习成本高
协同同步Yjs(CRDT)自动处理冲突,无需中心化仲裁自研 OT 复杂度高,容易写出隐蔽 bug
传输层原生 WebSocket双向、低开销,消息可控Socket.IO 封装重,长轮询延迟高
服务端Node.js + ws与前端同语言,I/O 密集场景合适换 Go 性能更好,但迭代速度优先
存储PostgreSQL + Redis关系型存文档,Redis 做房间状态缓存纯内存方案持久化风险高

这里我想重点说两个决策背后的思考。为什么不用自研 OT(Operational Transformation)?OT 的核心是把并发操作做变换后应用,理论上很优雅,但实现起来对操作顺序、上下文极其敏感,稍微漏掉一个边界情况就会导致不同客户端状态分叉,而且这种 bug 极难复现。CRDT(Conflict-free Replicated Data Type,无冲突复制数据类型)走的是另一条路:它把数据结构本身设计成"怎么合并都不冲突",用空间换正确性。对个人项目来说,正确性比极致省流量重要得多。

为什么服务端选择 Node.js?协作画布的服务端大部分时间在做一件事——把 A 的消息转发给同房间的 B、C、D。这是典型的 I/O 密集型任务,不是计算密集型,Node 的事件循环模型在这里表现很好。真正重的计算(比如合并、冲突检测)都下放到了客户端的 CRDT 里,服务端本质上只是个"消息中转站 + 状态看门狗"。

1.3 架构分层与模块划分

整个项目的架构我分成了五层,从下往上看:传输层负责 WebSocket 连接、心跳、断线重连;协同层封装 Yjs 文档和房间的绑定;状态层负责把协同数据映射成渲染需要的视图状态;渲染层负责画布绘制;交互层负责鼠标、键盘、触摸事件的采集与转换。分层的核心目的只有一个——把"数据一致性"和"视图渲染"彻底解耦。协同数据变了就通知渲染层重绘,渲染层完全不用关心这个变化是本地用户产生的还是远端同步过来的。

这种解耦带来的直接好处是调试变得简单:我可以在协同层打日志,看到每一条进出的消息;也可以在渲染层单独 mock 数据,不启动服务端就能调样式。很多协作项目写到最后变成一团乱麻,就是因为数据流动和视图渲染缠在一起,改一处崩三处。

2. 实时协同的底座:同步机制怎么选、怎么落地

2.1 OT 与 CRDT 的对比与选择

要把"多人同时编辑"做对,绕不开一个根本问题:两个人同时改了同一块内容,听谁的?这个问题在单机时代根本不存在,一旦多端并发就变成了核心难题。业界有两条主流路线,我把它们的关键差异整理如下。

维度OTCRDT
核心思想变换操作使其可交换数据结构天然可合并
是否需要中心服务器通常需要不强制,可去中心
实现难度高,边界情况多中,但概念门槛高
网络开销低,只传操作略高,需要传元数据
冲突处理运行时变换结构上避免
适合场景文本编辑(Google Docs 系)富图形、离线优先场景

我最终选 CRDT,是因为 MiroFish 是图形画布而不是纯文本。图形对象的操作五花八门——移动、缩放、旋转、改色、删除、分组,如果每一种都要写变换规则,OT 的工作量会爆炸。而 CRDT 的思路是给每个图形对象一个全局唯一 ID,每个属性用可合并的数据类型表达,移动操作本质上就是"把某个 ID 的 x/y 属性设成新值",后来的操作自然覆盖先前的,合并逻辑统一且简单。

提示:CRDT 的空间开销是真实存在的。每个属性位都带着版本元数据,文档越大元数据越多。如果画布上元素超过几千个,务必配合快照压缩,否则一个房间同步下来能吃掉几十 MB 内存。

2.2 WebSocket 连接层设计

传输层看起来最简单,实际上坑最多。我的实现里有三件事必须处理到位:心跳保活断线重连消息协议

心跳是为了防止中间的网络设备把长时间没数据的连接悄悄掐掉。我的做法是客户端每 30 秒发一个 ping,服务端回一个 pong,超过 60 秒没收到 pong 就主动重连。别小看这 30 秒,很多"用着用着就连不上了"的投诉,根源都是连接被中间设备回收而客户端毫不知情。

断线重连的关键是状态续传。重连不能简单地"重新连上就完事",因为断线期间房间里的其他人可能已经改了很多东西。我的做法是客户端记录本地的 Yjs 文档版本向量(state vector),重连后把它发给服务端,服务端只回传这个版本之后缺失的增量更新(Yjs 的 update 机制天然支持这个能力)。这样重连几秒钟断线的代价,只是补传那几秒的操作,而不是拉全量文档。

消息协议我用的是二进制格式(Yjs 的 update 本身就是 Uint8Array)。第一版我图省事用了 JSON,结果发现光标移动这种高频消息一秒钟能产生上百条,JSON 序列化的开销肉眼可见。换成二进制后,同样的操作消息体积下降了大概六成,CPU 占用也降了下来。

// 心跳与重连的核心逻辑示意 class CollabSocket { constructor(url, roomId) { this.url = url; this.roomId = roomId; this.retry = 0; this.connect(); } connect() { this.ws = new WebSocket(`${this.url}?room=${this.roomId}`); this.ws.binaryType = 'arraybuffer'; this.ws.onopen = () => { this.retry = 0; this.startHeartbeat(); this.syncMissingUpdates(); // 补传断线期间的增量 }; this.ws.onmessage = (e) => { if (e.data.byteLength === 0) return this.handlePong(); this.applyRemoteUpdate(new Uint8Array(e.data)); }; this.ws.onclose = () => { this.stopHeartbeat(); // 指数退避,避免疯狂重连打垮服务端 const delay = Math.min(1000 * 2 ** this.retry++, 30000); setTimeout(() => this.connect(), delay); }; } }

2.3 光标与在线状态的广播

"看到别人在动"这件事对协作体验的提升是巨大的,它让人确信对面真的有人。但光标广播也是最容易把带宽吃光的地方。我的处理是节流 + 只广播给同房间 + 独立于文档

光标位置我用节流控制在每秒 20 次左右(约 50ms 一次),因为人眼对更细的移动也分辨不出来。光标数据走的是临时消息通道,不写进 CRDT 文档——它不需要持久化,也不需要一致性保证,丢一两条完全无所谓。如果把它塞进文档同步,一来会污染历史记录,二来会让撤销栈里全是光标移动,这显然不合理。

在线用户列表我用的是服务端广播机制,而不是 CRDT。谁进来了、谁离开了,这本质上是"房间成员"的状态,交给服务端维护更直接。每个用户分配一个稳定的用户 ID、随机但固定的颜色和昵称,光标和头像都复用同一套身份信息。

一个容易被忽略的细节是光标淡出。当某个用户一段时间没有移动鼠标,或者直接关闭了页面,他的光标不应该突然消失——会让人感觉像是被吓到。我的做法是光标在静止 5 秒后开始用 CSS 动画渐隐,同时服务端检测到连接断开时广播一个"用户离开"事件,客户端在 2 秒内平滑移除对应的光标和头像。这种小动画的投入产出比极高,是那种"用户说不出哪里好但就是舒服"的细节。

3. 画布渲染:从 SVG 到 Canvas 的性能演进

3.1 渲染方案对比

第一版我用的是 SVG,因为它太直观了——每个图形就是一个<rect><path>,改属性就是改 DOM,配合 React 的声明式更新,两天就出了原型。但当我把一个包含 300 个节点的流程图导入测试时,拖动变得肉眼可见地卡。原因很简单:SVG 里每个元素都是真实 DOM 节点,每次重绘浏览器都要走一遍样式计算、布局、合成,几百个节点就顶不住了。

方案优点缺点适用场景
SVG元素可交互、样式好写、分辨率无损元素多了性能骤降元素少、需要精确点击
Canvas 2D绘制快、控制粒度细需要手写命中检测中等规模、频繁重绘
WebGL极高性能、可跑十万级图元学习曲线陡、调试难超大规模数据可视化

第二版我砍掉 SVG,改用 Canvas 2D 全量重绘。核心思路是:把画布上所有内容当成一个"状态快照",每一帧根据当前状态把所有可见元素画一遍。听起来很浪费,但 Canvas 的绘制速度足够快,几百个简单图形重绘一帧在 16ms 以内毫无压力。真正的性能杀手不是"画得不够快",而是"画了太多看不见的东西"。

3.2 视口与坐标系统设计

画布类项目最容易埋雷的地方是坐标系。我一开始图省事,直接把屏幕坐标当数据坐标存进文档,结果发现:不同窗口大小的用户看到的图形位置完全对不上——因为她那边窗口小,世界坐标系的原点位置和我这边不一样。

正确的做法是维护两套坐标:世界坐标(world)和数据绑定,是图形的真实归属;屏幕坐标(screen)只用于渲染和鼠标命中的临时计算。两者之间通过一个变换矩阵相互转换,这个矩阵由平移(pan)和缩放(zoom)两个量决定。

// 世界坐标 <-> 屏幕坐标互转 function worldToScreen(world, viewport) { return { x: (world.x - viewport.panX) * viewport.zoom, y: (world.y - viewport.panY) * viewport.zoom }; } function screenToWorld(screen, viewport) { return { x: screen.x / viewport.zoom + viewport.panX, y: screen.y / viewport.zoom + viewport.panY }; }

所有存进 CRDT 的都是世界坐标,这样无论谁用什么尺寸的屏幕,看到的都是同一张图。鼠标点击时先把屏幕坐标转成世界坐标再去命中检测,就不会出现"放大后点不中元素"的经典问题。这个设计看起来只是加了两行转换,但它决定了你的画布能不能支持"不同设备看到同一内容",是必须一开始就做对的底层假设。

注意:缩放要注意精度。如果允许无限放大,坐标会越乘越大最后溢出精度。我给 zoom 设了 [0.1, 8] 的合理区间,超出就不再响应,既防溢出也更符合真实使用场景。

3.3 分层渲染与脏矩形优化

全量重绘有个前提——画的内容要足够少。当画布内容超过一千个元素,或者引入了背景网格、缩略图、选中框等附加绘制,全量重绘的开始掉帧。我用了两手优化。

第一手是分层 Canvas。我把画布拆成三层叠加的<canvas>:最底下是静态层(背景网格、已确认的图形),中间是动态层(正在拖动、正在绘制、选中高亮),最上面是交互层(光标、工具栏)。静态层只有在文档真正变化时才重绘,动态层每一帧都重绘但内容很少,交互层几乎不变。这样拖动一个图形时,成百上千个静态元素根本不需要重新绘制,只重绘那一层里被拖动的那个。

第二手是脏矩形(dirty rectangle)。当只有局部区域变化时,只清除和重绘那一块区域的像素,而不是整个画布。Canvas 2D 提供clearRect和裁剪区域,配合记录每个元素的包围盒,就能算出"这一帧到底哪些区域变了"。这个优化在元素稀疏的画布上收益最大,但实现起来需要维护每个元素的脏区域标记,有一定复杂度,我建议先做分层,脏矩形作为第二阶段优化。

4. 数据模型与持久化:让协作数据可存、可查、可回滚

4.1 图形对象的数据结构

图形对象的数据结构设计决定了后面几乎所有事情的难度。我的原则是扁平化 + 强类型 + 属性可合并。不要用嵌套的树形结构表达图形关系,因为嵌套结构在 CRDT 里合并时会非常麻烦。我把每个图形做成一个扁平对象,用parentId表达分组关系,用Map<id, node>存所有图形。

interface ShapeNode { id: string; // 全局唯一,客户端生成 type: 'rect' | 'ellipse' | 'arrow' | 'text' | 'path'; x: number; // 世界坐标 y: number; width: number; height: number; rotation: number; style: ShapeStyle; // 颜色、线宽、透明度等 parentId: string | null; zIndex: number; // 层级顺序 deleted: boolean; // 软删除标记,方便撤销 }

这里有两个设计我想强调。ID 由客户端生成,用类似nanoid的方案,而不是服务端分配。因为客户端生成 ID 才能支持离线编辑——你断网时画的图形也要有个身份,等联网了直接合并进文档就行。删除用软删除deleted置位而不是真的从 Map 里移除。因为撤销操作需要知道"被删的是什么",如果物理删除了,撤销就得靠额外的历史记录,反而不如软删除简单可靠。定期做垃圾回收时再真删。

4.2 快照 + 增量日志的存储策略

持久化这件事,协作场景和普通应用完全不一样。普通应用存的是"最新状态",协作应用还要存"怎么变成这个状态的"。我用的是快照 + 增量日志的组合拳。

增量日志就是所有 CRDT 的 update,按时间顺序存进 PostgreSQL。它是真正的数据来源,可以精确重放每一个操作。但增量日志有个问题:时间一长日志会无限膨胀,一个活跃房间一天可能产生几十万条 update。所以每隔一段时间(比如每 5 分钟,或者每积累 1000 条 update),我就把当前完整文档序列化成一份快照存下来,然后把之前的增量日志标记为可清理。

恢复房间时的流程是:先加载最近的快照,再把快照之后的增量日志按顺序应用一遍,就得到了最新状态。这种设计的好处是既能快速加载(靠快照),又能精确追溯(靠日志)。同时我做了压缩:Yjs 的 update 本身支持合并,可以把一堆小 update 合并成一个大 update,减少存储条目和加载时的应用次数。

// 快照 + 增量的落库逻辑示意 async function persistRoom(roomId, ydoc, updateBuffer) { const shouldSnapshot = updateBuffer.length >= 1000; if (shouldSnapshot) { const snapshot = Y.encodeStateAsUpdate(ydoc); await db.saveSnapshot(roomId, Buffer.from(snapshot)); await db.clearUpdatesBefore(roomId, Date.now()); updateBuffer = []; } else { await db.saveUpdates(roomId, updateBuffer); } }

4.3 历史版本与撤销重做

撤销重做是协作编辑里最反直觉的功能。单机应用的撤销是"撤销我自己的上一步",但协作场景下,画布上还混着别人的操作,你撤销时应该只撤销自己的操作,不能把别人的东西也撤了。Yjs 提供了UndoManager,可以按 origin 过滤,只追踪特定来源(也就是当前用户)的操作。这样每个人都有一个独立的撤销栈,互不干扰。

版本历史我做了两条线:一条是自动时间点,每进行一批操作或者每隔一段时间自动打一个点;另一条是手动命名版本,用户可以主动存一个"评审前版本"。回滚的原理就是把文档状态恢复到某个快照,但这里有个坑——回滚本身也会产生新的操作,如果不处理,回滚后所有人的撤销栈都会乱掉。我的做法是回滚时广播一个"重置事件",客户端清空各自的撤销栈,从回滚后的状态重新开始记录。

提示:快照别存太频繁。早期我每 30 秒打一个快照,结果数据库体积涨得飞快。后来改成"操作数阈值 + 最小时间间隔"双条件触发,体积才降下来。如果你拿不准,就先按操作数触发,简单有效。

5. 常见问题与排查实录

5.1 协作冲突类问题

问题一:幽灵图形。现象是有时候画布上会突然出现一个谁都没画过的图形,或者某个图形变成两个。排查了很久才定位到原因:客户端断线重连时,如果重连逻辑还没有完成、连接还没真正建立,本地又产生了一个操作,这个操作会被丢弃或者重复发送。解决办法是维护一个"待发送队列",连接未就绪时把操作暂存,连接建立后再按序发送,同时确保 send 前检查ws.readyState

问题二:ID 碰撞。早期我用时间戳加随机数生成 ID,理论上会碰撞。有一次两个人几乎同一毫秒创建图形,结果 ID 相同,两个图形合并成了一个,场面一度很诡异。换成包含机器随机种子的 nanoid 后就再没出现过。

问题三:消息乱序。理论上 WebSocket 保证有序,但一旦涉及到多个连接(比如用户从手机切到电脑),跨连接的消息就未必有序。我们的 CRDT 天然容忍乱序,这也是选它的收益之一——你不用为乱序特别写逻辑。

5.2 性能类问题

问题四:拖动时越来越卡。这个是内存泄漏的典型症状。原因是每渲染一帧都新建了 Canvas 的离屏缓存或者事件监听器,没有释放。排查方法是在 Chrome DevTools 里反复操作后手动触发 GC,看内存有没有回落。没回落基本就是泄漏。解决方式是把高频创建的临时对象尽量复用,事件监听器注册在组件卸载时务必移除。

问题五:大文档加载慢。一个画了几千个元素的房间打开要好几秒。除了前面说的快照优化,我还加了视口裁剪——只渲染当前视口内可见的图形。因为用户往往只关心屏幕里那一片,画布外的东西没必要画。加了裁剪后,大文档的打开时间从数秒降到几百毫秒。

问题现象根因解决
幽灵图形出现无人创建的元素重连期间操作丢失/重发待发送队列 + 就绪检查
ID 碰撞两个图形变一个时间戳随机数撞车换用 nanoid
拖动卡顿操作越久越卡内存泄漏对象复用 + 监听器清理
大文档慢打开要好几秒全量渲染快照 + 视口裁剪
连接掉线用着用着没反应中间设备回收连接心跳 + 指数退避重连

5.3 部署与稳定性问题

问题六:多实例下房间状态不一致。单机部署时一切正常,一上负载均衡就出问题。因为 WebSocket 是有状态的,同一个房间的连接必须路由到同一台服务器,否则 A 的消息发到了实例一,B 在实例二上根本收不到。解决办法是会话粘性(sticky session),按房间 ID 做一致性哈希路由。跨实例的房间广播则通过 Redis 的发布订阅来做——实例一收到消息后,除了发给本机连接,还通过 Redis 转发给持有同房间连接的其他实例。

问题七:连接数一多,服务端就顶不住。一台普通服务器能撑的并发 WebSocket 连接其实相当可观,但每个连接都在占用内存,几千个连接后 GC 压力就上来了。我的优化是把心跳间隔适当拉长、把房间状态尽量放 Redis 而不是进程内存、对不活跃房间做连接回收。真正需要水平扩展时,前面说的粘性 + Redis 广播架构就能平滑扩容。

问题八:网络抖动导致状态分叉。极少数情况下,客户端和服务端的状态会对不上。我的兜底方案是服务端定期做一次状态校验——比对客户端上报的状态向量哈希,不一致就触发全量同步。这个校验频率不用高,比如每分钟一次,但它是保证系统长期健康的"安全带"。

6. 踩坑之后的一些真心话

这套东西从原型到能勉强上线,前前后后折腾了小半年,有几个体会特别想分享给同样在做类似项目的人。

协同的核心不是"同步消息",而是"设计数据"。我一开始把大量精力花在 WebSocket 消息协议上,后来发现真正决定项目成败的是数据模型——只要数据结构本身设计成可合并的,同步就变成了水到渠成的事。反过来,数据结构一团糟,再精巧的同步协议也救不回来。所以如果你准备动手做类似项目,请把 80% 的前期设计时间花在数据模型上。

先跑通再优化,但别等到最后才优化渲染。渲染优化我建议在项目早期就埋下分层渲染的框架,哪怕一开始只有一层。因为一旦所有绘制逻辑都写在一个大循环里,后期想拆层会动到很多代码。而像脏矩形、视口裁剪这类优化,则可以等到真的卡了再做,它们相对独立,插入成本低。

离线优先是个需要提前想清楚的问题。CRDT 天然支持离线编辑,但你的产品需求真的需要离线吗?如果不需要,直接在连接断开时禁用编辑、显示"连接中"会更省事。如果确实需要离线,那么你要处理的东西会成倍增加——离线期间的 ID 生成、冲突回合并、本地持久化、恢复后的批量上传,每一样都不简单。我做了离线编辑,代价是复杂度陡增,坦白说如果重来一次,我会先问清楚产品到底要不要这个能力。

最后留个后续扩展的念想,我自己正在折腾的方向是把画布的智能对齐、自动布局和协作数据结合起来——比如一群人同时在画流程图时,系统能根据当前所有图形的状态给出布局建议。这背后的挑战是,布局算法必须基于"所有客户端都认可的一致状态"来算,否则每个人看到的建议都不一样,反而添乱。这块我还在摸索,等有稳定结果了再跟大家分享。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 10:42:48

Redis高级实战:持久化、主从哨兵、分片集群、缓存治理与分布式锁

先把话说在前面&#xff1a;如果你只是会在 Spring Boot 里写一个 RedisTemplate&#xff0c;set 一个字符串再 get 出来&#xff0c;那 Redis 对你来说还是单机玩具。真正让我意识到必须系统学一遍 Redis 高级内容&#xff0c;是第一次把服务部署到多台机器之后——session 不…

作者头像 李华
网站建设 2026/9/18 10:40:55

飞书文档进 WeKnora,TaoToken 给 Agent 问答发 Key

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

作者头像 李华
网站建设 2026/9/18 10:37:11

2026年9月国内Docker镜像源加速列表与配置排错指南

平时自己折腾 Docker&#xff0c;最烦的就是docker pull卡在进度条上半天不动。更烦的是网上一搜镜像源&#xff0c;翻出来一堆 2022、2023 年的老帖子&#xff0c;照着填进去直接给你报timeout。镜像加速这个事&#xff0c;技术本身不复杂&#xff0c;真正麻烦的是时效性&…

作者头像 李华
网站建设 2026/9/18 10:35:37

80 个 Agent 消耗 500 亿 Token?用 TaoToken 给 Emergence World 换 Key

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

作者头像 李华