在线白板是实时协作里最考验工程能力的场景之一:图形元素需要一致性保证,光标位置需要高频同步,笔迹需要低延迟渲染,撤销重做还要在多用户环境下保持语义正确。把这几件事分开做都不难,合在一起就处处是坑。
本文从数据模型讲起,覆盖光标同步、渲染优化与撤销重做,给出一套可以直接落地的实现思路。读完你应该能搭出一个流畅的多用户白板,而不是一个一画就卡、一撤销就乱的半成品。
一、白板的数据模型
白板上的内容可以抽象为若干元素,每类元素的属性不同,但都共享一套标识与生命周期:
| 元素 | 关键属性 | 更新频率 |
|---|---|---|
| 路径(笔迹) | points、color、width | 绘制中极高,完成后不变 |
| 矩形/椭圆 | x、y、w、h、fill | 拖拽中高 |
| 文本 | x、y、content、fontSize | 输入中高 |
| 图片 | src、x、y、w、h | 极低 |
| 光标 | x、y、userId | 极高,不持久化 |
关键区分是「持久元素」与「临时状态」。图形元素需要持久化与一致性保证,光标位置不需要——它只需要实时性,断开后应当消失。把这两类数据分开管理,是白板架构的第一原则。
二、图形元素的 CRDT 化
图形元素适合用 Map 结构表达:每个元素有唯一 id,属性用 LWW(最后写入者胜)解决冲突。删除用墓碑标记,避免并发删除与更新的冲突。
import * as Y from "yjs";
const doc = new Y.Doc();
const shapes = doc.getMap("shapes"); // id -> Y.Map
// 创建矩形
function createRect(id, props) {
const rect = new Y.Map();
rect.set("type", "rect");
rect.set("x", props.x);
rect.set("y", props.y);
rect.set("w", props.w);
rect.set("h", props.h);
rect.set("deleted", false);
shapes.set(id, rect);
}
// 更新属性(并发时后写覆盖)
function updateRect(id, patch) {
const rect = shapes.get(id);
if (!rect) return;
for (const [k, v] of Object.entries(patch)) {
rect.set(k, v);
}
}
// 删除用墓碑
function removeRect(id) {
const rect = shapes.get(id);
if (rect) rect.set("deleted", true);
}
// 观察变化并重绘
shapes.observeDeep(() => {
requestRedraw();
});
observeDeep 能捕获嵌套 Map 的变化,是驱动重绘的入口。但要注意它会频繁触发,必须配合渲染节流,否则每次属性变化都重绘会拖垮性能。
三、光标与在线状态同步
3.1 Awareness 协议
光标、选中状态、用户昵称这类「非持久状态」用 Awareness 协议同步。它的特点是:状态有超时,用户离线后自动清除,不进入文档历史。
import { Awareness } from "y-protocols/awareness";
const awareness = new Awareness(doc);
const localUser = { id: "user-1", name: "Alice", color: "#e91e63" };
// 设置本地光标
awareness.setLocalState({
user: localUser,
cursor: { x: 100, y: 200 },
});
// 监听所有用户状态变化
awareness.on("change", ({ added, updated, removed }) => {
const states = awareness.getStates();
states.forEach((state, clientId) => {
console.log(`客户端 ${clientId}:`, state.user?.name, state.cursor);
});
renderRemoteCursors(states);
});
Awareness 内部会定期发送心跳,超时未收到心跳的客户端会被标记为离线并触发 removed。这解决了「用户异常断开后光标永久残留」的问题。
3.2 光标的节流与插值
鼠标移动事件每秒可能触发上百次,直接同步会瞬间打满带宽。正确做法是节流发送、插值渲染:
function throttle(fn, interval) {
let last = 0;
let timer = null;
return (...args) => {
const now = Date.now();
const remaining = interval - (now - last);
if (remaining <= 0) {
last = now;
fn(...args);
} else if (!timer) {
timer = setTimeout(() => {
last = Date.now();
timer = null;
fn(...args);
}, remaining);
}
};
}
// 每 50ms 最多发送一次光标位置
const sendCursor = throttle((x, y) => {
awareness.setLocalStateField("cursor", { x, y });
}, 50);
canvas.addEventListener("mousemove", (e) => {
sendCursor(e.offsetX, e.offsetY);
});
渲染远端光标时,如果直接跳到新位置会显得生硬。更平滑的做法是在两帧之间做线性插值:
const remoteCursors = new Map(); // clientId -> { current, target }
function animateCursors() {
for (const [, cursor] of remoteCursors) {
cursor.current.x += (cursor.target.x - cursor.current.x) * 0.3;
cursor.current.y += (cursor.target.y - cursor.current.y) * 0.3;
}
requestAnimationFrame(animateCursors);
}
四、渲染优化
4.1 Canvas 与 SVG 的取舍
| 维度 | Canvas | SVG |
|---|---|---|
| 元素数量 | 上千不卡 | 数百后变慢 |
| 事件处理 | 需自行命中检测 | DOM 事件天然支持 |
| 样式 | 手动画 | CSS 直接控制 |
| 导出 | 位图 | 矢量,可无损缩放 |
| 适用 | 高密度笔迹 | 少量交互元素 |
白板通常用 Canvas 渲染笔迹(数量大),用 SVG 或 DOM 渲染可交互的图形与文本。混合渲染能兼顾性能与交互。
4.2 分层渲染
把画布分成若干层,只在必要时重绘对应层:
// 三层:静态图形、笔迹、光标
const layers = {
shapes: document.querySelector("#layer-shapes"),
strokes: document.querySelector("#layer-strokes"),
cursors: document.querySelector("#layer-cursors"),
};
function requestRedraw(layer) {
if (!layer || layer === "cursors") {
drawCursors(layers.cursors); // 光标层每帧重绘
}
if (!layer || layer === "strokes") {
drawStrokes(layers.strokes);
}
if (!layer || layer === "shapes") {
drawShapes(layers.shapes);
}
}
光标层每帧重绘但内容极少,笔迹层只在新增笔迹时重绘,图形层只在属性变化时重绘。分层让每帧的开销可控。
4.3 笔迹的增量绘制
绘制中的笔迹不应每来一个点就重建整个路径,而应增量绘制:
let currentStroke = null;
function onPointerDown(e) {
currentStroke = {
id: `stroke-${Date.now()}`,
points: [[e.offsetX, e.offsetY]],
color: "#333",
width: 2,
};
}
function onPointerMove(e) {
if (!currentStroke) return;
const last = currentStroke.points[currentStroke.points.length - 1];
const dx = e.offsetX - last[0];
const dy = e.offsetY - last[1];
if (dx * dx + dy * dy < 4) return; // 过滤微小移动
currentStroke.points.push([e.offsetX, e.offsetY]);
drawSegment(last, [e.offsetX, e.offsetY], currentStroke);
}
function onPointerUp() {
if (currentStroke) {
strokes.set(currentStroke.id, currentStroke); // 完成后才同步
currentStroke = null;
}
}
关键点是绘制中不同步:笔迹在本地实时绘制,只在 pointerup 时才写入 CRDT 并广播。这既保证了本地手感,又避免同步海量中间点。
五、撤销重做
多用户环境下的撤销是最容易出错的部分。核心原则是:撤销只回退自己的操作,不能撤销别人的修改。
const undoManager = new Y.UndoManager(shapes, {
trackedOrigins: new Set(["local-user"]), // 只跟踪本地来源的操作
});
undoManager.on("stack-item-added", (event) => {
console.log("新增撤销项:", event.stackItem.meta);
});
function undo() {
undoManager.undo();
}
function redo() {
undoManager.redo();
}
trackedOrigins 是这里的关键:只有标记为本地来源的操作才会进入撤销栈。若不加区分,撤销会回退掉其他用户的修改,造成数据错乱。
六、持久化与回放
白板内容应定期持久化到服务端,避免所有客户端离线后数据丢失。Yjs 提供了高效的二进制编码:
import * as Y from "yjs";
// 序列化为二进制
function save(doc) {
const update = Y.encodeStateAsUpdate(doc);
return Buffer.from(update);
}
// 从二进制恢复
function load(binary) {
const doc = new Y.Doc();
Y.applyUpdate(doc, new Uint8Array(binary));
return doc;
}
// 定期快照,避免历史无限增长
setInterval(() => persist(save(doc)), 30000);
若需要「回放绘制过程」,可以记录带时间戳的操作流,按时间重放。这与 CRDT 的状态快照不同,回放需要操作级别的历史。
6.1 增量快照与压缩
长期运行的白板如果每次都保存完整二进制状态,存储会持续增长。更高效的做法是保存基线快照加增量更新:
class BoardStore {
constructor() {
this.base = null; // 基线快照
this.increments = []; // 基线之后的增量
this.count = 0;
}
append(update) {
this.increments.push(update);
this.count++;
if (this.count >= 200) this.compact();
}
compact() {
// 把基线加全部增量合并成新的基线
const doc = new Y.Doc();
if (this.base) Y.applyUpdate(doc, this.base);
for (const inc of this.increments) Y.applyUpdate(doc, inc);
this.base = Y.encodeStateAsUpdate(doc);
this.increments = [];
this.count = 0;
}
load() {
const doc = new Y.Doc();
if (this.base) Y.applyUpdate(doc, this.base);
for (const inc of this.increments) Y.applyUpdate(doc, inc);
return doc;
}
}
压缩间隔决定加载时需要重放的增量数量。间隔越小加载越快,但压缩时的 CPU 开销越大。
七、常见坑清单
- 绘制中的每个点都同步,带宽爆炸且延迟升高。
- 光标不设超时,用户异常断开后光标永久残留。
- 撤销未限定来源,回退掉其他用户的修改。
- 每次属性变化都全量重绘,元素多时帧率崩溃。
- 删除元素直接移除而非标记墓碑,并发更新时元素「复活」。
- 光标位置直接跳变,没有插值,观感生硬。
- 用 SVG 渲染大量笔迹,DOM 节点过多导致卡顿。
- 只做内存存储,服务端重启后白板内容全部丢失。
小结
白板与光标同步的难点不在单个技术,而在几类数据的协同:图形元素要一致性,光标要实时性,笔迹要低延迟,撤销要语义正确。把持久元素交给 CRDT、把临时状态交给 Awareness 协议、把绘制中的笔迹留在本地,是这套架构的三条主线。渲染上用分层与增量绘制控制每帧开销,撤销上限定来源避免误伤他人。CRDT 的数据结构细节见 实时协作 CRDT 原理 ,若涉及文本编辑则参考 协同编辑与 OT 算法 。当白板需要配合音视频通话时,媒体链路的优化可参考 弱网对抗与自适应码率 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。