第一次看到 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 + Canvas
2.1 为什么选 WebSocket 而不是轮询
实时协作的第一问题,是数据怎么从一个人手里传到另一个人手里。最直觉的方案是 HTTP 轮询:前端每隔 1 秒拉一次新数据。但这么做有两个硬伤,一是延迟高,一个人画一笔,另一个人最多要等 1 秒才看到;二是无效请求多,没人在画的时候,每个客户端还在不停问服务器“有新数据吗”。
WebSocket 解决了这两个问题:连接建立后,服务器可以主动把数据推给所有客户端,延迟能压到几十毫秒。局域网场景下不需要穿透 NAT,WebSocket 是最简单直接的全双工通道。我也考虑过 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 到 1
3.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/top:
function 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/clear |
| boardId | 画布 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。
处理方法是在画布容器上加上这样一行 CSS:
canvas { touch-action: none; }touch-action: none的意思是禁止浏览器对手势的默认处理,让 pointer 事件可以完整地交给 JavaScript。同时,事件监听使用 Pointer Events 而不是传统 touch/mouse 分开处理,能统一桌面和移动端的交互逻辑。
5.4 排查消息乱序:给日志加三个字段
最后分享一个非常实用的排错习惯。rea 在开发阶段只要出现画面不一致,我都会把日志里每条消息的三个字段打出来:seq、clientId、shapeId。这三个字段足以还原一条消息的完整生命周期。
2025-04-03 14:12:01 seq=1024 clientId=c_9f2a shapeId=sha_172 2025-04-03 14:12:01 seq=1025 clientId=c_7d3b shapeId=p_221 2025-04-03 14:12:02 seq=1024 clientId=c_9f2a shapeId=sha_172 (重放)如果同一个 seq 出现两次,说明服务端重复发送;如果某个客户端的图形在本地没有渲染,但日志里已经有广播记录,问题就出在前端渲染层。有了日志,排查范围能瞬间缩小。
平时我还会用一个支持 WebSocket 的命令行客户端,手工连接ws://主机:8080/ws,发一条测试数据看服务端是否广播。这样可以快速排除前端代码问题,把怀疑范围锁定在协议层。
6. 如果让我重做 rea,我会改这三件事
rea 最后没有产品化,但我们自己内部用得很顺手,每次开会都会开着它。做这个项目最大的收获不是代码量,而是我理解了“把需求切小到刚好能做完”有多重要。如果现在重写一遍,我会优先改三件事。
第一,文本输入逻辑要重做。当前文本是一次性写入画布,不能二次编辑,开会时写错字只能删除重来。如果做双链式图数据库,文本块可以拆成可编辑图层,体验会好很多。
第二,存储结构会换成“快照 + 增量日志”而不是纯 JSON。现在的 JSON 是全量覆盖,虽然简单,但数据量大了之后容易因为写入频繁出问题。改成快照加增量日志后,启动恢复速度会更快,排查历史问题也更方便。
第三,我会加上一个“会话快照按钮”。开会时手动保存当前画布状态,后续可以随时回滚到某个节点。这个功能不复杂,但对会议场景非常实用,相当于给白板开了个存档功能。
最后想提醒一句:这类局域网工具一定要明确使用边界,不要把它直接暴露到公网。rea 的内部定位是办公室临时协作,所以无鉴权没问题;如果部署方式变化,第一件事就是加一层访问控制。工具虽小,边界还是要划清楚。