基于WebSocket+Canvas的局域网实时白板协作工具实践 第一次看到 rea 这个代号时我以为是社区里对 React 的一种简称后来写着写着它的全称变成了 Real-time Engagement Assistant简单说就是一个跑在局域网里的多端实时白板协作工具。打开浏览器输入同一台机器的 IP 和端口几个人就能一起画线、写字、拖箭头不用装客户端也不用注册账号。这篇文章会把 rea 从需求确认、技术选型到实现细节、部署落地和踩坑记录都摊开讲一遍适合那些想自己写一个协作小工具、又不想一上来就引入重量级框架的开发者参考。1. 项目由来与需求拆解从一场说不清的会议开始1.1 为什么会有 rea 这个代号很多工具类项目不是拍脑袋想出来的是在实际场景里被逼出来的。我做 rea 的起因是经常遇到一种很尴尬的会议几个人围在屏幕前讨论一段网络拓扑语音里说“第三台机器那个连接线”其他人根本跟不上。共享屏幕只能一个人画别人想圈一下重点也插不上手。截图又过期太快讨论两轮之后图片就没人看了。当时也试过现成的在线白板但要么需要注册要么免费版人数受限要么整个界面塞满图形库和模板开个画布要等好几秒。我只想要一个“打开就能画”的极简页面最好跑在办公室局域网里别人临时加入也不用下任何东西。于是 rea 这个内部代号就诞生了。全称定为 Real-time Engagement Assistant也就是“实时互动辅助工具”。它不需要微信提醒、不需要房间密码、不需要演示模式核心只有一个所有人都能在同一个画布上实时留下笔迹并且互相能看见光标在哪儿。1.2 需求收敛三个必做三个不做需求收敛是业余项目最容易翻车的一步。刚开始列功能清单时我也差点把表格、思维导图、便签、投票、音视频都放进去。后来理智了一把重新把需求分成三层必做自由画笔、矩形、箭头、文本、撤销、清空画布。必须爽多人实时看到对方的笔迹和光标断线重连后不丢数据。必须轻一条命令启动普通办公本就能扛住 10 个人同时画。三个明确不做的功能我也写进了 README不做橡皮擦因为撤销已经覆盖 95% 的误操作场景不做账号体系因为局域网内无鉴权反而是特性避免“登录”打断协作流不做权限管理因为这是内部工具不是 SaaS 产品。把范围切到这程度开发量就压缩到可控范围内了。当时的验收标准就三条能不能 30 秒内启动、能不能 10 个人同时画不卡、能不能在弱网环境下断线重连后补齐错过的笔画。后面所有技术选型都是围绕这三个验收标准来的。2. 技术选型与架构设计为什么是 WebSocket Canvas2.1 为什么选 WebSocket 而不是轮询实时协作的第一问题是数据怎么从一个人手里传到另一个人手里。最直觉的方案是 HTTP 轮询前端每隔 1 秒拉一次新数据。但这么做有两个硬伤一是延迟高一个人画一笔另一个人最多要等 1 秒才看到二是无效请求多没人在画的时候每个客户端还在不停问服务器“有新数据吗”。WebSocket 解决了这两个问题连接建立后服务器可以主动把数据推给所有客户端延迟能压到几十毫秒。局域网场景下不需要穿透 NATWebSocket 是最简单直接的全双工通道。我也考虑过 SSE但 SSE 是单向的客户端只能收、不能发最后还是需要另一条通道传数据不如 WebSocket 干脆。轮询与 WebSocket 的对比做选型时可以参考这张表维度HTTP 轮询WebSocket实时性最低 1 秒一个周期毫秒级推送服务端压力大量无效请求长连接占内存开发复杂度很低中等是否适合白板同步不适合很适合这次选择几乎没什么悬念底层通信直接定了 WebSocket。为了控制依赖数量服务端没有使用 Socket.IO 那种带自动重连和房间管理的库而是选了极简的 ws。原因是本项目没有复杂房间逻辑只有一块画布自己做一套协议反而更透明出了问题也容易排查。2.2 前端用 Vue 3 Canvas 而不是 SVG前端负责两件事渲染画布、处理本地交互。画布层我直接在 Canvas 2D 上实现没有用 fabric.js 这类库也没有用 SVG。SVG 的优点是每个图形都是 DOM 节点天生可以绑定事件但从实测体验来看当画布上积累几百个笔迹之后DOM 节点数量膨胀重绘和事件处理都会开始变慢。Canvas 是“一锅端”的位图式渲染每一帧我都能控制要画哪些元素性能上限高很多。这个项目里图形数量最多也就几千个Canvas 2D 完全能扛住。Vue 3 负责的是画布之外的 UI 层顶部的工具按钮、颜色选择器、线条粗细、清空按钮、人数显示。Canvas 内部的状态不适合放进 Vue 的响应式系统因为 mousemove 这类高频事件如果触发 setter 更新反而会拖慢帧率。所以我在实现里做了分层UI 状态走 Vue 的 ref 和 reactive画布数据走独立的普通对象数组只有需要切换工具或撤销时才通知 Vue 更新。这种“UI 归 UI画布归画布”的分层是小工具项目里性价比最高的设计。如果让 Vue 去管 Canvas 上几千个图形那就是给自己挖坑。2.3 后端用 Node.js ws 是最小可行方案后端只有一个职责接收客户端的绘制指令广播给其他人顺便落盘。Node.js 的异步事件循环很擅长处理这种 IO 密集型场景多人同时画会触发大量 WebSocket 消息Node.js 不会因为阻塞而丢消息。ws 库是 WebSocket 服务端的事实标准API 极简几行代码就能起一个服务。项目里没有引入数据库所有画布数据序列化成一个 JSON 对象定时写到磁盘。为什么不用 SQLite因为共享白板的数据模型特别简单就是一个有序的图形列表。用 JSON 反而更直观服务端挂掉之后打开文件还能看到原始数据。版本方面我用的组合是 Node.js 18、ws 8.x、Vue 3.4、Vite 5。这样的组合在 2025 年依然稳定对新手也友好社区资料多遇到问题容易搜到解法。2.4 项目目录结构长什么样整个项目是一个单仓库前端和后端放在一起部署时由 Node 服务直接托管前端的静态文件。目录结构大致如下rea/ ├── package.json ├── server/ │ ├── index.js # 入口启动 HTTP 和 WebSocket │ ├── storage.js # 数据序列化与落盘 │ └── protocol.js # 消息类型定义和校验 └── src/ ├── main.js # Vue 入口 ├── App.vue # 工具栏和画布壳子 ├── canvas/ │ ├── renderer.js # Canvas 绘制逻辑 │ ├── shapes.js # 图形数据结构 │ └── history.js # 撤销/重做栈 └── ws/ └── client.js # WebSocket 客户端封装这样拆分的好处是边界清楚协议文件只处理消息格式不碰业务渲染器只负责画不关心消息从哪里来。后面加功能时几乎不需要大改目录只需要往对应文件里填代码。3. 核心功能实现白板同步协议从 0 到 13.1 画布初始化别忽略像素比Canvas 白板最容易踩的第一个坑就是画出来的线条发虚。原因很简单没有处理设备像素比。笔记本的显示 scale 可能是 1.5 或 2如果直接用 CSS 像素设置 canvas.width实际渲染时会被拉伸线条就会模糊。正确的初始化方式是在拿到画布元素后先读一下window.devicePixelRatio再把画布的实际宽高乘上这个值function setupCanvas(canvas) { const rect canvas.getBoundingClientRect(); const dpr window.devicePixelRatio || 1; canvas.width rect.width * dpr; canvas.height rect.height * dpr; canvas.getContext(2d).scale(dpr, dpr); return { ctx: canvas.getContext(2d), width: rect.width, height: rect.height, dpr, }; }这里有一个很容易忽略的细节在移动端getBoundingClientRect返回的尺寸在旋转屏幕后会变化所以要在窗口 resize 时重新执行初始化否则画布会变形。我也在项目里加了 resize 监听每次重新初始化后需要把之前画过的所有图形重放一遍否则画面会变空。坐标换算同样重要。鼠标事件里的clientX/clientY是相对视口的画布如果位置不在页面左上角需要减掉画布自身的getBoundingClientRect().left/topfunction getPoint(canvas, e) { const rect canvas.getBoundingClientRect(); return { x: e.clientX - rect.left, y: e.clientY - rect.top, }; }这个步骤如果漏了会出现“鼠标在页面上画线条却跑偏”的奇怪现象。看起来是个小问题实际排查时能浪费半小时。3.2 数据同步协议设计让操作顺序不再打架多人协作的核心挑战不是“怎么画”而是“怎么让所有人看到一致的画布”。如果每个客户端只把本地画好的图形发给别人网络一抖动不同设备上的图形顺序就会不一致撤销的时候整个画布就会乱套。rea 的协议设计原则可以总结为三条消息面向操作、操作自带序号、列表由服务端权威排序。每一条来自客户端的消息都长这样{ type: addShape, boardId: default, shape: { id: sha_1720012345600, kind: rect, x: 100, y: 80, width: 120, height: 60, color: #ff5c5c, strokeWidth: 3, createdAt: 1720012345600 }, clientId: c_9f2a, seq: 1024 }字段说明字段含义type操作类型addShape/updateShape/deleteShape/clearboardId画布 ID目前只有一块但保留扩展性shape图形数据不同类型字段不同clientId来源客户端标识seq单调递增序号由服务端分配有一个关键点seq不是客户端生成的而是服务端收到消息后统一分配的。为什么不让客户端自己生成因为不同客户端的时钟和网络延迟不一样客户端生成的序号无法保证全局有序。服务端每收到一条写操作就把当前序号加一再把带序号的完整消息广播给所有人。这样每个客户端拿到的数据顺序一定一致。撤销的实现也需要围绕这个协议来设计。撤销不是简单地在本地删除最后一个图形而是要生成一条deleteShape消息带目标 shape 的 id 和新的 seq广播出去。这样所有人执行同一条删除指令画布状态才能保持一致。3.3 形状序列化与增量操作白板上的图形类型不多我定义了四种基础形状自由画笔、矩形、椭圆、箭头外加文本。每种形状在协议里都是一个对象统一包含 id、type、color、strokeWidth、createdAt再根据类型附带自己的几何参数。自由画笔是最特殊的因为有几十上百个采样点。如果每次移动都把整个点数组发给服务器带宽会很快被打满。rea 的做法是在笔画开始和结束时各发一条消息中间过程批量发送采样点。画笔的序列化结构如下{ id: p_123, type: freehand, color: #1e88e5, strokeWidth: 2, points: [ { x: 10, y: 20 }, { x: 11, y: 21 }, { x: 14, y: 23 } ], createdAt: 1720012345600 }客户端在画笔绘制过程中不会每移动一次就发一条 WebSocket 消息而是把点累积到一个临时数组配合节流器每 30 毫秒发一次增量点。这里有一个可以抄的作业增量数据结构只需要{ shapeId, points: [...] }服务端拿到后不是替换整个 shape而是往对应 shape 的 points 数组里追加。这样可以避免并发环境下“旧数据覆盖新点”的问题。矩形、椭圆和箭头相对简单鼠标按下是起点移动时实时更新终点松开后生成一条addShape消息就行。文本稍微复杂一点需要把输入框的坐标、字号、颜色、内容都带上但因为文本是低频消息一次发送完整数据即可。3.4 服务端广播与落盘服务端最核心的代码是广播逻辑。用 ws 库实现时要注意不要给发送者自己回放消息因为发送者本地已经画过了。要在广播时排除掉消息来源连接的 readyState否则会出现“自己画的线条闪一下”的体验问题。function broadcast(wss, sender, message) { const data JSON.stringify(message); for (const client of wss.clients) { if (client sender) continue; if (client.readyState WebSocket.OPEN) { client.send(data); } } }落盘的作用是支持刷新页面后恢复历史画布。如果每次广播都同步写磁盘IO 会被拖垮。rea 采用了防抖落盘每次画布数据变化时设置一个 500 毫秒的定时器期间如果有新变化就重置定时器。也就是说在连续操作时最多每 500 毫秒写一次磁盘。这个频率对白板场景完全够用即使服务端突然崩溃最多丢失最近半秒的笔画。function schedulePersist() { clearTimeout(persistTimer); persistTimer setTimeout(() { writeFileSync(storagePath, JSON.stringify(history)); }, 500); }这个设计很朴素但经受住了实际使用。后来有一次同事直接拔掉电源重启后白板内容恢复到关机前几秒的状态已经远远超出我对这个小工具的预期。3.5 实时光标与断线重连除了笔画同步实时光标也是协作感的重要来源。cursor 消息不需要落盘不需要持久化只要在服务端内存里保存一份当前在线用户的光标位置就行。前端监听鼠标移动事件做节流处理后发送canvas.addEventListener(pointermove, (e) { const now Date.now(); if (now - lastCursorSent 30) return; lastCursorSent now; const point getPoint(canvas, e); ws.send(JSON.stringify({ type: cursor, clientId, x: point.x, y: point.y, })); });断线重连是 rea 里最需要细心处理的部分。如果客户端在画到一半时网络闪断重新连上后必须能补齐自己错过的那些笔画否则画面会对不上。重连协议设计如下客户端每次收到带 seq 的消息时都记录下本地的lastSeq。断开后重新建立 WebSocket 连接第一条消息是resync带上lastSeq。服务端收到后从全局数据里找出所有 seq 大于这个值的操作按顺序全部发给这个客户端。客户端收到后按顺序重放画布就追平了。这个方案的复杂度不高但非常有效。只要保证服务端维护的 seq 是单调递增的重连后的数据一致性就能满足需求。4. 实操过程本地调试与局域网部署要点4.1 本地开发一个端口搞定前后端rea 的前端用了 Vite开发模式下默认跑在 5173 端口后端 Node 服务跑在 8080 端口。如果让前端直接请求后端需要处理跨域。最简单的办法是在 Vite 配置里加一层代理把/api开头的请求转发到 8080。// vite.config.js export default { server: { proxy: { /api: { target: http://localhost:8080, changeOrigin: true, }, }, }, };其实 WebSocket 的代理也可以走 Vite 的 proxy加上ws: true就能把/ws路径转发过去。这样开发时只需要打开一个 5173 端口省去很多烦恼。同时启动前后端我用了 concurrently在根目录 package.json 里写{ scripts: { dev: concurrently \npm run dev:server\ \npm run dev:client\, dev:server: node server/index.js, dev:client: vite } }这样本地开发的体验非常顺滑一条命令起服务改后端代码用 nodemon 自动重启改前端代码 Vite 热更新。做完了再跑npm run build产出静态文件交给后端托管。4.2 局域网部署一条命令让别人用到部署阶段我希望任何同事拿到代码后跑一条命令就能用。所以服务端除了 WebSocket还需要具备静态文件托管能力。生产环境不依赖 Vite直接让 Node 服务读取dist目录下的文件。const http require(http); const fs require(fs); const path require(path); const { WebSocketServer } require(ws); const server http.createServer((req, res) { const pathname req.url / ? /index.html : req.url; const file path.join(__dirname, ../dist, pathname); fs.readFile(file, (err, data) { if (err) { res.writeHead(404); res.end(Not Found); return; } res.setHeader(Content-Type, getContentType(file)); res.end(data); }); }); const wss new WebSocketServer({ server, path: /ws }); server.listen(8080, 0.0.0.0);这里必须提醒一个关键点如果不写0.0.0.0默认监听的是localhost同一局域网其他机器根本无法访问。很多新手在这个地方卡住改了半天防火墙都没用其实是绑定地址错了。部署到真实办公环境后还需要确认系统防火墙放行 8080 端口。Windows 和 macOS 的处理方式不同但测试最快的方法是在同一局域网的另一台设备上用浏览器访问http://主机IP:8080。如果打不开先 ping 主机 IP再检查防火墙再检查监听地址基本就是这三步。4.3 性能优化画得快也要画得稳白板场景的性能瓶颈主要在绘制和网络传输两块。绘制方面Canvas 的draw操作如果量大了主线程会被占满。rea 的做法是把绘制和事件处理分离鼠标高频移动时不立刻重绘而是用一个requestAnimationFrame循环合并同一帧里的多次变化。let rafId null; function requestRender() { if (rafId) return; rafId requestAnimationFrame(() { renderAll(); rafId null; }); }这样即使一秒钟触发 100 次更新浏览器也只会在每个刷新帧里重绘一次性能开销直接减少一个数量级。网络传输方面自由画笔的采样点虽然已经做了节流但手速快的时候 30 毫秒内也可能产生 10 多个点。rea 追加了一个距离阈值抽稀只有当前点距离上一个保留点的距离超过 1.5 像素才会被送入增量消息。这个阈值是我实测出来的太小会导致传输冗余太大会让曲线显得不光滑。1.5 像素在这个画布大小下肉眼基本看不出差异。5. 常见问题与排查技巧实录5.1 断线重连失败心跳才是关键rea 刚部署时经常有同事反映白板放着不动后再画就画不上了刷新页面又能恢复。排查发现是办公室的网络设备会回收长期空闲的 TCP 连接。浏览器和服务器之间没有数据流动连接被中间设备断掉但客户端并不知情。解决方式就是心跳检测。服务端每 30 秒向所有客户端发送一个 ping 帧客户端如果收到后没有回应服务端主动关闭连接客户端浏览器端也有自己的onclose事件触发后立即重连。const HEARTBEAT_INTERVAL 30000; setInterval(() { for (const client of wss.clients) { if (client.isAlive false) { client.terminate(); continue; } client.isAlive false; client.ping(); } }, HEARTBEAT_INTERVAL); client.on(pong, () { client.isAlive true; });这个改动看起来很简单但直接影响稳定性。加了心跳之后rea 再也没有出现过“白板静默掉线”的情况。5.2 多人同时画为什么互相覆盖多人同时画时出现过一种现象A 画了一笔B 立刻看到但紧接着 B 自己的画布上刚画的一笔被“顶掉”了看起来像是被覆盖。问题出在客户端本地处理逻辑和广播顺序不一致。解决办法是严格遵守服务端权威排序客户端本地绘制时先把图形存进一个“临时待确认列表”不直接进入正式历史列表。等服务端广播回来带 seq 的确认消息后再把它移入正式列表。如果有人并发操作导致 seq 顺序改变只有待确认列表需要调整正式列表永远是所有人一致的。这个设计有一个额外好处本地绘制不会让画布闪跳因为正式渲染只认确认过的数据。实现成本不高但彻底解决了并发覆盖问题。5.3 手机上画不出来一根手指的问题把 rea 部署到局域网后我第一件事是用手机访问。结果发现页面加载正常但手指在屏幕上滑动时页面会滚动笔画却画不出来。原因是浏览器把手势识别成页面滚动没有把事件交给 Canvas。处理方法是在画布容器上加上这样一行 CSScanvas { touch-action: none; }touch-action: none的意思是禁止浏览器对手势的默认处理让 pointer 事件可以完整地交给 JavaScript。同时事件监听使用 Pointer Events 而不是传统 touch/mouse 分开处理能统一桌面和移动端的交互逻辑。5.4 排查消息乱序给日志加三个字段最后分享一个非常实用的排错习惯。rea 在开发阶段只要出现画面不一致我都会把日志里每条消息的三个字段打出来seq、clientId、shapeId。这三个字段足以还原一条消息的完整生命周期。2025-04-03 14:12:01 seq1024 clientIdc_9f2a shapeIdsha_172 2025-04-03 14:12:01 seq1025 clientIdc_7d3b shapeIdp_221 2025-04-03 14:12:02 seq1024 clientIdc_9f2a shapeIdsha_172 (重放)如果同一个 seq 出现两次说明服务端重复发送如果某个客户端的图形在本地没有渲染但日志里已经有广播记录问题就出在前端渲染层。有了日志排查范围能瞬间缩小。平时我还会用一个支持 WebSocket 的命令行客户端手工连接ws://主机:8080/ws发一条测试数据看服务端是否广播。这样可以快速排除前端代码问题把怀疑范围锁定在协议层。6. 如果让我重做 rea我会改这三件事rea 最后没有产品化但我们自己内部用得很顺手每次开会都会开着它。做这个项目最大的收获不是代码量而是我理解了“把需求切小到刚好能做完”有多重要。如果现在重写一遍我会优先改三件事。第一文本输入逻辑要重做。当前文本是一次性写入画布不能二次编辑开会时写错字只能删除重来。如果做双链式图数据库文本块可以拆成可编辑图层体验会好很多。第二存储结构会换成“快照 增量日志”而不是纯 JSON。现在的 JSON 是全量覆盖虽然简单但数据量大了之后容易因为写入频繁出问题。改成快照加增量日志后启动恢复速度会更快排查历史问题也更方便。第三我会加上一个“会话快照按钮”。开会时手动保存当前画布状态后续可以随时回滚到某个节点。这个功能不复杂但对会议场景非常实用相当于给白板开了个存档功能。最后想提醒一句这类局域网工具一定要明确使用边界不要把它直接暴露到公网。rea 的内部定位是办公室临时协作所以无鉴权没问题如果部署方式变化第一件事就是加一层访问控制。工具虽小边界还是要划清楚。