实战:构建 P2P 白板协作应用
画布数据同步、操作序列化、冲突处理——这三个词听起来有点吓人,对吧?
别担心。今天我们就一步步把它拆开,揉碎了讲清楚。
我个人习惯把这类问题分成三层来看:数据怎么传、数据怎么存、冲突怎么解。说白了,就是通信层、序列化层、一致性层。
核心逻辑:一张图看懂白板同步
先给你看一张我画的流程图。这张图是我在项目初期贴在墙上的,后来几乎没改过。
你看,整个流程其实不复杂。用户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应用最有意思的地方就在这里——你写的每一行代码,都在让两个远隔千里的人「感觉」坐在同一张桌子前。这种成就感,是传统客户端开发给不了的。