在线白板与光标同步实战

从数据模型到渲染优化,完整实现协作白板与光标同步:讲清图形元素的 CRDT 化、Awareness 协议同步在线状态、高频更新的节流与插值、Canvas 与 SVG 的渲染取舍、撤销重做的正确实现,并附持久化回放、增量快照与常见坑,给出可落地的工程方案。

在线白板是实时协作里最考验工程能力的场景之一:图形元素需要一致性保证,光标位置需要高频同步,笔迹需要低延迟渲染,撤销重做还要在多用户环境下保持语义正确。把这几件事分开做都不难,合在一起就处处是坑。

本文从数据模型讲起,覆盖光标同步、渲染优化与撤销重做,给出一套可以直接落地的实现思路。读完你应该能搭出一个流畅的多用户白板,而不是一个一画就卡、一撤销就乱的半成品。

一、白板的数据模型

白板上的内容可以抽象为若干元素,每类元素的属性不同,但都共享一套标识与生命周期:

元素关键属性更新频率
路径(笔迹)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 的取舍

维度CanvasSVG
元素数量上千不卡数百后变慢
事件处理需自行命中检测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 算法 。当白板需要配合音视频通话时,媒体链路的优化可参考 弱网对抗与自适应码率 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「实时通信」更多文章

  1. 实时通信的端到端测试与压测
  2. 信令服务与房间水平扩展
  3. 低延迟直播:LL-HLS 与 WebRTC 直播