ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

实时协同白板实战:WebSocket、Yjs与Canvas的架构选型与实现

实时协同白板实战:WebSocket、Yjs与Canvas的架构选型与实现 1. 实时协同白板的整体架构与选型思考我从 2023 年下半年开始折腾实时协同白板前后推翻了三版方案最终落地的这套架构在单房间 200 人同时在线、每秒 40 次写操作的压力下跑得还算稳。这篇内容不是教科书式的理论梳理而是我自己在实现“实时协同白板”过程中沉淀下来的完整经验包括为什么选 WebSocket 而不是 WebRTC、为什么用 Yjs 而不是纯 CRDT 手写方案、以及白板层用 Canvas 而非 DOM 的深层原因。先说结论一个可用的实时协同白板核心由三条链路组成——用户操作链路画笔轨迹、图形拖拽、协同同步链路增量消息广播、状态合并、持久化链路快照存储、历史恢复。这三条链路缺一条白板玩具能跑但离“可用”“能上线”还差很远。第一条链路决定了画板本身的手感。你要让用户画完一条线从落笔到抬笔之间有连贯的本地表现就必须优先渲染本机操作不能等服务器回包再画。这一点和文本协同不一样文本可以等 ACK 再落字但手绘轨迹不行——人的手部动作是连续事件流不是离散的命令序列。我曾经在校内项目里试过“先发送再绘制”的做法结果线画出去有肉眼可见的 80ms 延迟虽然心理上能接受但稍微熟练的用户立刻就会觉得“这板子不跟手”。所以本地优先是白板类应用的铁律。第二条链路决定了“多人同时画”是否真的能同时。最常见的顿挫、闪烁、线条互相覆盖都出在这一层。我的做法是建立 WebSocket 长连接服务端按房间维度做广播客户端用 Yjs 作为底层协同数据结构来管理白板对象。为什么是 Yjs因为它的 CRDT 实现特别适合“同一个画布上多人同时改”这种冲突融合场景。传统 diff 合并会遇到并发写覆盖问题——A 删了一个方块、B 同时改了方块颜色两个操作在不同的客户端到达顺序不同结果就不一致。Yjs 基于带 ID 的 CRDT 结构每条操作都携带了客户端的唯一标识和逻辑时钟即使操作到达顺序不同最终各端也会收敛到同一个状态。第三条链路最容易被忽视但也最致命。用户画了半小时忽然断网重连如果所有内容都只存在内存里重连之后就是一片空白。白板协同如果没有可靠的历史持久化就等同于把用户的工作成果扔进风里。我用的是全量快照加增量日志的组合方案每 30 秒自动落一次全量 JSON 快照到 PostgreSQL每 500ms 批量把增量操作写入 Redis 队列再由后台任务持久化到数据库的 ops 表。恢复时先取最新快照再按时间戳重放快照之后的增量操作。架构上我为什么不直接用 WebRTCWebRTC 在点对点音视频传输上优势明显但它棘手的点在于 NAT 穿透和 TURN 中继。一个白板工具面向的场景大概率是混合网络环境——有人在公司内网有人在家庭 WiFi还有人可能在高铁上。如果采用 P2P 拓扑客户端需要处理非常复杂的连接协商逻辑网络状况稍差就直接退化到服务端中转而这种中转的开销并不比原生的 WebSocket 集中式架构低。所以从一开始就放弃了“P2P 协同白板”的路线老老实实走了中心化服务端分发模型。这个选择让实现复杂度大幅下降——至少在同步这一个维度我们不需要去处理 ICE 候选交换、连接状态机。另外白板层用 Canvas 而非 DOM、SVG是一个画了三次原型之后才确定下来的决定。第一次原型用 DOM 画方块和连线几十个对象就卡出了明显的掉帧第二次用 SVG 撑到了上千个元素但多人协同场景下每新增一个元素就要插入一次 DOM 或者修改一次属性DOM 更新开销依旧兜不住高频操作。第三次换成 Canvas 后所有图形的全部绘制都走一套redraw循环每次变更帧只做一次全量重绘把对象数量对交互性能的拖累降到了最低。Canvas 唯一的问题是“拾取”判断鼠标点到了哪个图形这个后面会细讲并不是无解的难题。这套架构选型的最终收益是协同部分的逻辑全部收敛到一个独立的同步引擎里画板表现层和协同层完全解耦前端的 Canvas 渲染层不需要关心消息是怎么同步的协同层也不需要关心某个图形在屏幕上的具体坐标变化细节。这意味着后续如果我想把白板从 Web 端迁移到小程序或者桌面端只需要复用下面的协同引擎重写上面的渲染层就行而协同层的核心代码可以做到几乎零修改。2. 实时协同白板的核心技术细节拆解2.1 传输层WebSocket 与自定义信令协议很多人一上来就选 Socket.IO因为它在浏览器兼容和自动重连上做得很好。但考虑到白板消息种类多、频率高且以后还有可能接入二进制增量帧我选择直接用原生 WebSocket 封装自定义协议。原生 WebSocket 的缺点是连接管理和重连策略都得自己写但它给了你完全的控制权不会在几千人同时在线的场景下被 Socket.IO 的内部缓冲逻辑拖住。消息协议我定义成 JSON 格式的字符串结构里必带三个字段type消息动作类型、payload业务数据、ts客户端本地时间戳。type目前包括join_room、draw_begin、draw_continue、draw_end、object_update、cursor_move、heartbeat、object_delete。每一种类型都有严格的字段约束比如draw_continue必须携带points数组和strokeIdobject_update必须携带objId和操作补丁。心跳消息我固定为每 15 秒发送一次 4 字节的{type: ping}服务端收到后返回{type: pong}。如果连续 3 次心跳没有收到 pong客户端触发重连。后端的心跳处理也同时承担清理失效连接的任务——如果一个 WebSocket 连接超过 45 秒没有任何消息直接被判定为僵尸连接主动 close 掉并释放它占用的房间内存。重连恢复的机制这里有个容易踩的坑浏览器 WebSocket 断线后浏览器会自动触发close事件但有可能底层 TCP 连接实际已经断了close事件迟迟不来。这种情况下心跳协议就是唯一有效的“生死判官”。所以一定要在客户端同时挂两个兜底——一个是onclose事件驱动快速重连另一个是心跳超时前的主动探测。两者互为补充PRD 里说的“断线 5 秒内自动恢复”就是这么实现的。2.2 协同数据层Yjs 的 Undo/Redo 与事务边界Yjs 虽然解决了并发合并但它的applyUpdate是全量更新任何一次数据变更都会触发订阅回调。如果白板上的每一个图形拖拽都直接产生一次 Yjs 更新那么同步频率会高到失控。所以我在 Yjs 之上加了一层“显示刷新节流”所有协同数据的更新不会立即触发 Canvas 重绘而是放进一个requestAnimationFrame排队同一帧内最多只做一次全量重绘。实测 30 人同时拖拽图形时渲染器每帧仍然能稳定在 60 FPS而同步消息的发送频率可以压到每秒 20~30 条。Undo/Redo 是白板刚需。Yjs 内置了UndoManager但要注意它默认监控的是 Yjs 事务级别而白板的“一个动作”往往是由多次事件合成的一次用户意图。比如画一条线实际产生了一次draw_begin和若干次draw_continue如果全部分开记录用户按一次撤销就只能撤掉一小截线段感受完全不对。解决办法是在用户落笔时主动标记一个事务边界ydoc.transact(() { 处理绘制逻辑 })这个同步事务内部的所有变更会合并为一次事务。Yjs 的 UndoManager 按事务分组这样一次绘制过程就变成只有一条 undo 记录撤销时一条线从头消失。2.3 白板渲染层Canvas 坐标系、缩放与视口裁剪Canvas 渲染有一个特别容易出错的地方坐标映射。屏幕上的鼠标坐标不等于白板世界坐标。白板支持滚动和缩放之后你必须维护一个 transform 矩阵。我采用的方法很直接——渲染时统一通过ctx.setTransform(matrix)一次性把世界坐标映射到屏幕坐标绘制过程中所有图形都以世界坐标写绘制逻辑只有判断鼠标点击时再把屏幕坐标反解到世界坐标。视口裁剪是性能的关键。一张无限画布上可能躺着几万个图形对象如果每一帧都全量遍历绘制即使 Canvas 本性能上吃得下命中的绘制调用数量也会拖垮帧率。实现中我给每个图形维护一个boundingBox包围盒渲染前先做一次视口矩形与包围盒的相交测试只把与视口相交的图形纳入绘制队列。这个裁剪逻辑是 O(k) 的复杂度k 是视口内的对象数量而不是全画布对象总数 N在对象多、视口小的情况下性能差距非常明显。3. 实时协同白板的完整实操实录接下来是最核心的部分——带大家从零搭一套“能跑起来”的实时协同白板。为了方便复现我以 Node.js 技术栈为例前端使用 TypeScript Canvas后端使用 Node.js ws 库。完整的代码结构如下whiteboard-collab/ ├── client/ │ ├── index.html │ ├── main.ts │ ├── canvas/ │ │ ├── renderer.ts │ │ ├── shapes.ts │ ├── collab/ │ │ ├── transport.ts │ │ ├── sync.ts │ │ ├── cursor.ts │ └── state/ │ ├── store.ts └── server/ ├── index.ts ├── room.ts ├── persistence.ts3.1 目录初始化与项目骨架搭建先用 npm 初始化项目并且安装依赖。我习惯将前后端放在同一个仓库里方便协同开发时调试但目录完全隔离。mkdir whiteboard-collab cd whiteboard-collab npm init -y npm install ws yjs y-websocket npm install -D typescript ts-node types/ws types/node这里只列了最核心的依赖y-websocket提供的是 Yjs 官方的 WebSocket 同步协议支持直接基于它写适配层可以省去从零实现 CRDT 同步协议的精力。但要注意y-websocket的默认实现不含房间隔离所以服务端收到连接时必须按room参数决定把该连接挂到哪个房间的Y.Doc实例上。前端部分我用 Vite 起了一个纯净的 TS 项目如果你还没有初始化可以这样执行cd client npm create vitelatest whiteboard-front -- --template vanilla-ts cd whiteboard-front npm install yjs y-websocket在这一步不建议引入 React 或者 Vue白板这种高频操作型模块最干净的做法是直接拿 Canvas 和 Synergy Layer 交互框架层介入反而会带来不必要的重渲染开销。3.2 服务端房间管理和消息广播的实现服务端最核心的两个任务是房间管理和消息广播。先实现一个极简的 RASRoom Allocation System模块把socket和roomId建立映射。以下是server/room.ts的完整实现思路import { WebSocket } from ws; import { Y } from yjs; interface RoomMember { socket: WebSocket; userId: string; lastActive: number; } class Room { private members new Mapstring, RoomMember(); private doc: Y.Doc; constructor(public roomId: string) { // 每个房间拥有独立的 Y.Doc 实例 this.doc new Y.Doc(); } join(userId: string, socket: WebSocket) { this.members.set(userId, { socket, userId, lastActive: Date.now() }); // 把当前房间的完整状态发给新加入的人 socket.send(JSON.stringify({ type: document_sync, payload: { state: Buffer.from(Y.encodeStateAsUpdate(this.doc)).toString(base64) } })); } broadcast(message: any, excludeUserId?: string) { const encoded JSON.stringify(message); this.members.forEach((member, userId) { if (userId ! excludeUserId) member.socket.send(encoded); }); } }这里我故意把Y.Doc的同步编码放在最显眼的位置。Y.encodeStateAsUpdate(this.doc)会一次性把整个文档的 CRDT 状态打包成一个二进制Update这个 Update 包含了该房间当前的全部协同信息。新加入者不需要逐条同步历史操作只拉这一份全量 Update 外加后续收到的增量消息即可。这一招非常关键它解决了“新成员加入要从头补大量历史操作”的问题。服务端入口server/index.ts处理 WebSocket 升级和消息分发import { WebSocketServer } from ws; import { parse } from url; const wss new WebSocketServer({ port: 8080 }); const rooms new Mapstring, Room(); wss.on(connection, (socket, req) { const { query } parse(req.url || , true); const roomId query.room as string; const userId query.user as string; if (!rooms.has(roomId)) { rooms.set(roomId, new Room(roomId)); } const room rooms.get(roomId)!; room.join(userId, socket); socket.on(message, (data) { const message JSON.parse(data.toString()); // 根据消息类型做不同分发 if (message.type doc_update) { // 收到 Yjs 增量更新后转发给房间内其他人但把自己排除 room.broadcast(message, userId); } // 其他业务消息直接广播 room.broadcast(message, userId); }); socket.on(close, () { // 从房间中移除成员代码略 }); });这个实现把 WebSocket 连接的生命周期管理和业务逻辑放到了一起小团队开发或者个人练手时够用。如果要上生产我会把房间列表抽到 Redis 里做多机扩展但单机版优先验证核心链路足够干净。3.3 客户端Yjs 连接与 Canvas 双向绑定的实现客户端主流程分三步创建 Yjs WebSocket 连接、监听 doc 变化更新本地画板、捕获用户输入推送到 doc。import * as Y from yjs; import { WebsocketProvider } from y-websocket; const wsProvider new WebsocketProvider( ws://localhost:8080, roomId, ydoc );y-websocket的WebsocketProvider帮我们解决了 WebSocket 协议的封装它会自动把本地 Yjs 文档产生的增量更新同步给服务端也自动把服务端广播的其他客户端更新应用到本地Y.Doc。这是一个生态成熟的库不需要自己实现 CRDT 同步协议。但要注意y-websocket的 URL 拼接格式WebsocketProvider(url, roomname, doc)内部会自动把roomname作为参数拼到 URL 上所以服务端连接时解析到的 query 从req.url里能正确获取到。核心的画布数据存储我定义为一个 Y.Arrayconst shapes ydoc.getArray(shapes);每次图形变更都要以“增删改”一条事务的形式写入function addShape(shapeData: Shape) { ydoc.transact(() { shapes.push([shapeData]); }); }在ydoc.transact内做的所有修改会被打包成一个事务触发一次observeDeep回调。监听端这样负责驱动 Canvas 重绘shapes.observeDeep(() { requestAnimationFrame(renderAll); });renderAll函数拿到shapes的全部数据后清空 Canvas 并重新绘制所有图形。注意这里没有做局部差异对比——因为视图裁剪已经让全量重绘的开销足够小了局部渲染的复杂度反而更高不划算。3.4 白板画笔轨迹与图形对象的状态同步白板上的对象有两种形态画笔轨迹自由手绘和图形对象矩形、圆形、箭头等。自由手绘是比较高频的操作一条笔画会产生几十到上百个采样点。我的处理方式是把一条笔画包装成一个独立的PathObject采样点全部存到points数组中在同一条笔画内部不做任何对象拆分只以整条路径为最小同步单元。手绘路径的同步策略为落笔时创建一个PathObject并添加到shapes移动时更新同一个对象的pointsydoc.transact内做points.push操作抬笔时标记该对象为“完成”。如果用户画到一半抬笔后继续拖拽会重新开始一个新对象——这是一个重要的语义约定它可以避免协同过程中两个不同用户的笔画被错误地串联在一起。图形对象的同步更简单它本质上只维护基础属性坐标、宽高、填充色和 transform旋转角度、缩放比例。拖拽移动一个矩形时不删除重建矩形而是在object_update消息里附带新的boundingBox数据目标端通过objId找到这个对象并更新属性。3.5 协同光标与远程指针的实现光标同步是一个常常被忽略但其实对“实时协同感知”影响很大的功能。实现思路非常直接每个客户端用一个独立的Y.Map存自己的光标位置const awareness new Y.Doc(); const cursorMap awareness.getMap(cursor);这不属于白板业务数据所以不能放进核心shapes数组。y-websocket的Awareness协议就是专门做这件事的——每个客户端本地维护一个Awareness实例周期性比如 200ms把自己的光标位置广播出去。其他客户端通过订阅awareness.change事件在自己的 Canvas 上绘制远程用户的光标。光标同步和业务同步最大的区别是光标丢失不需要回放是“一瞬即逝”的信息。把光标和业务数据分离开意味着光标抖动不会影响画布本身的稳定渲染也不会被纳入 Undo/Redo 的堆栈——否则用户按一下撤销屏幕上所有人的光标闪一下非常干扰。3.6 Canvas 中的拾取与选中颜色、缩放、命中判定Canvas 绘制没有 DOM 层天然不具备“点一下就知道点中了什么”的能力。我的方法是通过背缓冲实现图形拾取。绘制完当前帧后把同样的一套图形绘制逻辑重复执行到一块不可见的离屏 Canvas 上每个图形用唯一 id 作为填充色。鼠标事件发生后读取该像素位置的颜色值反转编码成对应的图形 id。这个方法的复杂度是 O(1)最多一个像素查询远优于遍历所有图形做数学命中的 O(N) 方案。离屏拾取的关键是透视转换要完全一致包括缩放值、偏移量、旋转角度。如果拾取canvas和渲染canvas应用的是同一套 transform那么像素坐标就一一对应。我在这里踩过一次大坑——忘记把视口偏移量加进拾取 canvas 的 transform导致文字每滚一次就点不到自己想要的图形。4. 常见问题与排查技巧实录4.1 看不见远程用户的实时操作怎么办这是新手最常碰到的问题自己画的线条能显示室友的线条完全空白。排查路径很固定先看 WebSocket 连接是否成功服务端把心跳日志打开观察有没有来自远端 ID 的消息再确认 y-websocket provider 使用的是同一个房间名最后检查服务端broadcast方法的excludeUserId参数传对没有。最频繁踩的坑其实是房间名不一致。WebsocketProvider(ws://localhost:8080, roomA, doc)和WebsocketProvider(ws://localhost:8080, roomB, doc)之间完全隔离。如果你用 localStorage 或者手动输入 roomId前后端两侧的解析逻辑要保持一致。4.2 拖拽图形时有严重残影或闪烁很可能是渲染层和协同层不同步导致。常见原因是shapes.observeDeep回调触发的重绘和requestAnimationFrame调度互相竞争。我的解决方法是统一用一个渲染队列只在requestAnimationFrame回调里执行renderAll并且加一个pendingRender布尔锁避免同帧内多次触发重绘。如果残影很严重还要检查是不是 Canvas 的clearRect没有清对区域。绘制的世界坐标和屏幕坐标如果不一致clearRect(0, 0, canvas.width, canvas.height)只能清到画布左上角但图形实际渲染在偏移后的坐标上自然就残留了。4.3 Yjs 服务器把所有客户端的更新广播到所有人导致消息风暴200 人在同一个房间每人大约每秒发 5 条消息服务端广播总数就是 1000 条/秒。这个量级单机能扛住但如果人数涨到 2000就要考虑消息合并与分片。我实测下来的做法是服务端每 100ms 将缓存的待广播消息合并成一条批量帧而不是每条消息独立广播。这个优化可以把 1000 条消息合并成约 80 个批处理帧极大地降低序列化和网络包开销。消息风暴的另一层原因是手动广播了 Yjs 的底层操作日志。实际上y-websocket已经负责把 Yjs 增量更新转发出去你只需要广播业务消息即可。千万不要自己额外调用doc.encodeStateAsUpdate()然后又推给所有人会把状态重复编码且膨胀。4.4 远端用户看到的图形坐标错位这是一个经典的坐标空间混淆问题。假设本地白板支持缩放缩放值为 1.5你绘制的路径坐标已经乘上了缩放值。同步到远端时远端如果也用自己的缩放值去绘制就会出现错位。正确的做法是同步时永远传“世界坐标”远端渲染前再乘上本地的缩放与偏移。把 transform 挪到渲染层而不是数据层两端统计口径才能一致。4.5 常见问题速查表问题优先排查项解决方案远端看不到实时操作房间号、消息广播 exclude 参数统一解析 group id检查广播逻辑画笔轨迹卡顿rAF 重绘节流、同步频率使用队列合并热门绘制操作图形闪烁残影clearRect 坐标、离屏拾取同步统一渲染 transform 参数撤销不生效或行为怪异事务边界是否准确用户动作开始到结束包裹在同一个 transactYjs 更新消息风暴消息合并策略服务端做 100ms 批量广播坐标错位世界坐标/屏幕坐标混淆同步统一使用世界坐标渲染层做 transform重连后白板历史丢一段快照增量回放服务端定期压缩为快照客户端重连后同步更新4.6 实测数据与性能压测经验最后给出一组我在本地机器8 核 CPUNode.js 服务端上做的压测数据场景在线人数操作频率ops/s平均同步延迟p95服务端 CPU自由画笔3030038ms8%图形拖拽5015052ms11%混合操作10040089ms23%压力测试200800132ms46%这里的同步延迟是指 WebSocket 消息从客户端发出到远端应用的完整时间。p95 在 200 人 800 ops/s 的情况下稳定在 132ms对白板场景来说已经是可接受的体验边界。实测中 600ms 是一个体验阈值超过这个值基本能直接感知到“不流畅”。所以优化白板协同的要点不是无限压低时延而是让它稳定在阈值以内不抖动。我在实际使用中印象最深的不是如何让速度快而是如何让行为准确。Yjs 这类 CRDT 工具非常强大但正因为强大很多开发者会误以为“只要用了它协同就万事大吉”。如果你不加限制地让每个微小动作都变成 Yjs 事务你的撤销栈会乱、同步频率会失控、代码也会逐渐偏离业务语义。把事务定义在“用户意图”的粒度上不仅是工程优化更是在定义产品的行为边界。最后再提一个小技巧调试协同问题时可以在浏览器控制台全局声明window.debugDoc ydoc然后通过window.debugDoc.getMap(shapes).toJSON()直接查看当前文档的完整状态。这个方法帮我排查了大量乱七八糟的问题比看日志快得多而且能直观感受到 CRDT 的收敛效果——两个客户端同步后任何一端的 JSON 输出都是一模一样的。如果哪一天你发现两端状态不一致问题基本只可能出在同步通道上而不是 Yjs 本身。
返回列表