实战:构建 P2P 白板协作应用

画布数据同步、操作序列化、冲突处理——这三个词听起来有点吓人,对吧?

别担心。今天我们就一步步把它拆开,揉碎了讲清楚。

我个人习惯把这类问题分成三层来看:数据怎么传数据怎么存冲突怎么解。说白了,就是通信层、序列化层、一致性层。

核心逻辑:一张图看懂白板同步

先给你看一张我画的流程图。这张图是我在项目初期贴在墙上的,后来几乎没改过。

用户A 画布 用户B 画布 操作序列化 { type, x, y, color, size } WebRTC DataChannel 冲突处理 时间戳 / 操作变换 回显确认 用户A 用户B 序列化 通道 冲突处理

你看,整个流程其实不复杂。用户A画一笔,序列化成一条消息,通过DataChannel发给用户B。B收到后反序列化,画出来。反过来也一样。

但问题来了——如果两个人同时画,谁先谁后?

画布数据同步:不只是传像素

我第一次做白板同步时,犯过一个低级错误:直接把canvas的像素数据整个传过去。结果呢?一张A4纸大小的画布,一次同步要传几MB的数据,延迟高得离谱。

后来我学乖了。我们同步的是操作,不是结果

核心原则:只传「画了什么」,不传「画成了什么样」。

举个例子。用户画了一条线,我们记录的是:

  • 起点坐标 (x1, y1)
  • 终点坐标 (x2, y2)
  • 画笔颜色、粗细
  • 操作类型(画线、擦除、矩形等)
  • 时间戳

这些数据加起来,一条线也就几十个字节。比传整张画布快太多了。

操作序列化:把动作变成消息

序列化说白了就是「翻译」。把鼠标动作翻译成一段JSON字符串。

// 我项目中用的序列化格式
const serializeDraw = (action) => {
  return JSON.stringify({
    type: 'draw',
    id: action.id,           // 唯一ID,用于去重
    userId: action.userId,
    tool: action.tool,       // 'pen', 'eraser', 'rect'
    points: action.points,   // 路径点数组
    color: action.color,
    size: action.size,
    timestamp: Date.now()
  });
};

// 反序列化
const deserializeDraw = (msg) => {
  return JSON.parse(msg);
};

小技巧:我习惯给每个操作加一个唯一ID。这样即使收到重复消息,也能轻松去重。UUID或者简单的自增ID都行。

你可能会问:为什么不用二进制?嗯,JSON虽然体积大一点,但调试方便。我在开发阶段一直用JSON,上线前再考虑要不要切到二进制协议。

冲突处理:两个人同时画怎么办?

这是白板协作里最头疼的问题。我踩过不少坑。

先说最简单的方案:最后写入者胜出(Last Write Wins)。

原理很简单:每个操作带一个时间戳。收到冲突操作时,比较时间戳,谁晚谁有效。

// 冲突处理:时间戳比较
const handleConflict = (localOp, remoteOp) => {
  if (remoteOp.timestamp > localOp.timestamp) {
    // 远程操作更新,接受
    applyOperation(remoteOp);
    return remoteOp;
  } else {
    // 本地操作更新,忽略远程
    return localOp;
  }
};

但这里有个坑——时钟不同步。用户A的电脑时间比用户B快了5秒,那B的操作永远赢不了。怎么办?

我曾经踩过的坑:直接用Date.now()做冲突裁决。结果两个用户的系统时间差了10分钟,协作完全乱套。后来我改用「逻辑时钟」——每个操作带一个递增的序列号,由发送方维护。虽然不能完全解决,但至少不受系统时间影响。

更高级的方案是操作变换(OT)。简单说就是:如果两个操作冲突,不是丢弃一个,而是把两个操作「合并」成一个合理的结果。

举个例子:

  • 用户A在位置(100,100)画了一个红点
  • 用户B在同一位置画了一个蓝点
  • OT算法会算出:两个点都保留,但稍微偏移一点位置,避免完全重叠

OT实现起来比较复杂,我建议初学者先从「最后写入者胜出」开始。等你的应用用户多了,再考虑升级到OT。

实战代码:一个最小可用的白板同步

下面是我写的一个最小实现。去掉了很多UI细节,只保留核心逻辑。

// 白板同步核心类
class WhiteboardSync {
  constructor(channel, canvas) {
    this.channel = channel;   // DataChannel
    this.canvas = canvas;     // Canvas 2D上下文
    this.localOps = [];       // 本地操作队列
    this.remoteOps = [];      // 远程操作队列
    this.seq = 0;             // 序列号
    
    // 监听远程消息
    channel.onmessage = (event) => {
      const op = JSON.parse(event.data);
      this.handleRemoteOp(op);
    };
  }
  
  // 本地画了一笔
  localDraw(points, color, size) {
    const op = {
      type: 'draw',
      id: `${this.seq++}-${Date.now()}`,
      points,
      color,
      size,
      timestamp: Date.now()
    };
    
    this.localOps.push(op);
    this.channel.send(JSON.stringify(op));
    this.applyToCanvas(op);
  }
  
  // 处理远程操作
  handleRemoteOp(op) {
    // 去重:如果已经处理过,忽略
    if (this.remoteOps.find(o => o.id === op.id)) return;
    
    // 冲突检测
    const conflict = this.localOps.find(
      lo => lo.timestamp > op.timestamp - 100 && 
            lo.timestamp < op.timestamp + 100
    );
    
    if (conflict) {
      // 最后写入者胜出
      if (op.timestamp > conflict.timestamp) {
        this.applyToCanvas(op);
      }
      // 否则忽略远程操作
    } else {
      this.applyToCanvas(op);
    }
    
    this.remoteOps.push(op);
  }
  
  // 应用到画布
  applyToCanvas(op) {
    const ctx = this.canvas.getContext('2d');
    ctx.strokeStyle = op.color;
    ctx.lineWidth = op.size;
    ctx.beginPath();
    op.points.forEach((p, i) => {
      if (i === 0) ctx.moveTo(p.x, p.y);
      else ctx.lineTo(p.x, p.y);
    });
    ctx.stroke();
  }
}

我的建议:先跑通这个最小版本。别一上来就搞OT、搞历史回放。白板协作的核心是「能画、能同步、不丢数据」。其他的都是锦上添花。

避坑指南

最后分享几个我实战中遇到的坑,你大概率也会碰到。

问题 表现 解决方案
消息乱序 后画的线先显示 每个操作带序列号,接收端按序处理
消息丢失 对方少了几笔 DataChannel使用可靠模式(ordered: true)
冲突频繁 双方画的内容不一致 缩短冲突检测窗口,或升级到OT
性能问题 画复杂图形时卡顿 限制每秒操作数,合并短线段

嗯,大概就这些。白板同步其实没有想象中那么难。你想想看,核心就是「操作序列化 + 冲突处理」这两件事。把这两件事做好了,剩下的就是UI和体验优化了。

我个人觉得,做P2P应用最有意思的地方就在这里——你写的每一行代码,都在让两个远隔千里的人「感觉」坐在同一张桌子前。这种成就感,是传统客户端开发给不了的。

公众号:蓝海资料掘金营,微信deep3321