大家好,我是前端西瓜哥。
在协同编辑中,同步用户状态是必不可少的功能。通过感知到其他用户的状态,我们可以有意识地避免操作同一个对象,导致冲突和相互覆盖。
下面我们看看 Figma 是怎么进行用户状态的同步的。
需要同步的状态
Figma 同步的状态分为几种。
用户基本信息
首先是 用户基本信息。
用于顶部显示进入当前 "房间" 的用户有哪些,以及光标上显示对应的名字。

1、 name:用户名;
2、imageURL:用户头像 url;
3、userID:用户 id;
4、sessionID:会话 id。
每个客户端都有有一个唯一的 sessionID。因为一个用户可能会同时打开多个客户端,所以不能用 userID 做区分。
sessionID 是服务端提供的,确保唯一性,并在创建图形时,作为 guid 的一部分,确保图形 id 的唯一性,以确保类 CRDT 的协同编辑机制能正常工作。
5、deviceName:设备名,通常是 "editor"。
6、canWrite:是否有编辑权限。
vbnet
{
sessionID: 23,
stableSessionID: 'eccba7720656d0a2f5adeb7b7ca1700915d62b7d',
connected: true,
name: 'xigua',
imageURL: '<url>',
deviceName: 'editor',
userID: '1166195885685703702',
canWrite: true,
connectedAtTimeS: 205,
// ...
};
光标信息
光标信息可以告知其他协同者,当前客户端的操作意图。

1、cursor: 光标样式;
通常是 "DEFAULT"(默认光标)。常见的还有 "PEN"、"CROSSHAIR"、"PENCIL"、"HAND"。

2、canvasSpaceLocation:光标位置。用 x、y 表示;
3、canvasSpaceSelectionBox:选区。用 x、y、width、height 表示;
4、color:光标颜色。使用归一的 rgba 格式;
5、canvasGuid:光标所在的画布 id。
css
{
mouse: {
cursor: 'DEFAULT',
canvasSpaceLocation: {
x: -72,
y: -101,
},
canvasSpaceSelectionBox: {
x: null,
y: null,
w: null,
h: null,
},
canvasGuid: {
sessionID: 0,
localID: 1,
},
cursorHiddenReason: 0,
},
color: {
r: 1,
g: 0.1411764770746231,
b: 0.7411764860153198,
a: 1,
},
}
视口信息
1、canvasSpaceBounds:视口矩形;
在 follow 另一个用户时会用到,就是跟随另一个用户的 viewport,将其 fit 到自己的 viewport 上。
css
{
viewport: {
canvasSpaceBounds: {
x: -345.7548522949219,
y: -208.17074584960938,
w: 317.0416564941406,
h: 512.650390625,
},
pixelPreview: false,
pixelDensity: 1,
canvasGuid: {
sessionID: 0,
localID: 1,
},
},
}
被选中图形信息
会保存在 selection 数组中,里面是被选中图形的 id。
这个是全量提交的,所以如果选的图形比较多,数据量还是挺大的。
css
{
selection: [
{ sessionID: 26, localID: 2 },
{ sessionID: 67, localID: 2 },
{ sessionID: 67, localID: 3 },
{ sessionID: 68, localID: 2 },
{ sessionID: 34, localID: 2 },
{ sessionID: 67, localID: 4 },
],
}
因为协同的原因,图形数据是动态的,没法提交一个范围来替代集合。
跟随信息
observing 是当前客户端正在跟随哪个客户端,里面保存的是 sessionID。
比如下面表示当前用户正在跟随sessionID 为 83 的客户端。
css
{
observing: [83],
}
通信的各种场景
初始化
客户端初始化,会进行 websocket 连接,然后服务端会返回当前房间的所有用户信息,包括自己的。
自身信息会比较少,因为才初始化,没有光标位置等信息。其中比较重要的是服务端分配的 sessionID,后面的 CRDT 协同都需要它。
css
{
type: 'USER_CHANGES',
userChanges: [
{
sessionID: 56,
stableSessionID: '74469ea15d268d1713737bd8994b122b500ebfa0',
connected: true,
name: 'watermelon',
color: {
r: 0.4000000059604645,
g: 0.46666666865348816,
b: 0.6000000238418579,
a: 1,
},
imageURL: '<url>',
viewport: {
canvasSpaceBounds: {
x: -808,
y: -619,
w: 945,
h: 860,
},
pixelPreview: false,
pixelDensity: 1,
canvasGuid: {
sessionID: 0,
localID: 1,
},
},
mouse: {
cursor: 'DEFAULT',
canvasSpaceLocation: {
x: -806,
y: -285,
},
canvasSpaceSelectionBox: {
x: null,
y: null,
w: null,
h: null,
},
canvasGuid: {
sessionID: 0,
localID: 1,
},
cursorHiddenReason: 0,
},
selection: [],
observing: [],
deviceName: 'editor',
userID: '1157564275155718012',
canWrite: true,
connectedAtTimeS: 588,
},
{
sessionID: 58,
stableSessionID: '5a11edba43222d4fa6e14da9451ca8fff537a8e3',
connected: true,
name: 'xigua',
color: {
r: 0.0784313753247261,
g: 0.6823529601097107,
b: 0.3607843220233917,
a: 1,
},
imageURL: '<url>',
deviceName: 'editor',
userID: '1166195885685703702',
canWrite: true,
connectedAtTimeS: 618,
},
],
}
光标和选区信息同步
需要注意的是,只有当前 "房间" 有 2 个人及以上,用户的状态才会提交给客户端。
用户在画布上移动光标和进行框选时,会将光标位置和选区的信息发送给服务端,服务端会广播给其他客户端。
mousemove 的频率还是挺高的,所以发送的信息会做 节流,否则服务端压力会过大。其他所有操作都会做节流。
这里发送的 sessionID 永远是 0,因为服务端不会信任客户端的 sessionID,服务端会根据 websocket 连接,拿到绝对正确的 sessionID。
css
{
type: 'USER_CHANGES',
userChanges: [
{
sessionID: 0,
mouse: {
cursor: 'DEFAULT',
canvasSpaceLocation: {
x: -87,
y: -633,
},
canvasSpaceSelectionBox: {
x: -308,
y: -792,
w: 221,
h: 159,
},
canvasGuid: {
sessionID: 0,
localID: 1,
},
cursorHiddenReason: 0,
},
},
],
sentTimestamp: '1762581990493',
};
选中图形
选中图形的话,会发送选中图形数据。
css
{
type: 'USER_CHANGES',
userChanges: [
{
sessionID: 0,
viewport: {
canvasSpaceBounds: {
x: -372,
y: -889,
w: 356,
h: 1092,
},
pixelPreview: false,
pixelDensity: 1,
canvasGuid: {
sessionID: 0,
localID: 1,
},
},
selection: [
{ sessionID: 26, localID: 2 },
{ sessionID: 67, localID: 2 },
{ sessionID: 67, localID: 3 },
{ sessionID: 68, localID: 2 },
{ sessionID: 34, localID: 2 },
{ sessionID: 67, localID: 4 },
],
},
],
sentTimestamp: '1762582166098',
}
跟随
点击用户头像即可跟随对应用户。

会发送消息:
css
{
type: 'USER_CHANGES',
userChanges: [
{
sessionID: 0,
viewport: {
canvasSpaceBounds: {
x: -1016.8021240234375,
y: -1148.079345703125,
w: 1004.8677978515625,
h: 1484.62646484375,
},
pixelPreview: false,
pixelDensity: 1,
canvasGuid: {
sessionID: 0,
localID: 1,
},
},
observing: [71],
},
],
sentTimestamp: '1762585440201',
}
视口发生改变
viewport 发生变化是要同步的。
除了视口信息,还额外传了鼠标信息。
css
{
type: 'USER_CHANGES',
userChanges: [
{
sessionID: 0,
viewport: {
canvasSpaceBounds: {
x: -1032,
y: -803,
w: 842,
h: 1244,
},
pixelPreview: false,
pixelDensity: 1,
canvasGuid: {
sessionID: 0,
localID: 1,
},
},
mouse: {
cursor: 'DEFAULT',
canvasSpaceLocation: {
x: -187,
y: -570,
},
canvasSpaceSelectionBox: {
x: null,
y: null,
w: null,
h: null,
},
canvasGuid: {
sessionID: 0,
localID: 1,
},
cursorHiddenReason: 0,
},
},
],
sentTimestamp: '1762586450176',
}
进入房间
服务端检测到 WebSocket 连接,其他客户端会接收到这个新用户的信息。
css
{
type: 'USER_CHANGES',
userChanges: [
{
sessionID: 95,
stableSessionID: '19a3dcd3fc347d0fa1289243f69c3a7340a55922',
connected: true,
name: 'watermelon',
color: {
r: 0.0784313753247261,
g: 0.6823529601097107,
b: 0.3607843220233917,
a: 1,
},
imageURL: '<url>',
deviceName: 'editor',
userID: '1157564275155718012',
canWrite: true,
connectedAtTimeS: 8084,
},
],
}
退出房间
当有用户离开房间,WebSocket 断开,服务端广播。
其他客户度会收到这个消息。
arduino
{
type: 'USER_CHANGES',
userChanges: [
{
sessionID: 91,
connected: false,
},
],
}
结尾
我是前端西瓜哥,关注我,学习更多协同编辑知识。
相关阅读,