Figma 协同编辑是如何做用户状态同步的?

大家好,我是前端西瓜哥。

在协同编辑中,同步用户状态是必不可少的功能。通过感知到其他用户的状态,我们可以有意识地避免操作同一个对象,导致冲突和相互覆盖。

下面我们看看 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,
    },
  ],
}

结尾

我是前端西瓜哥,关注我,学习更多协同编辑知识。


相关阅读,

使用 yjs 给图形编辑器加上多人协同编辑功能

多人协同场景下,图形编辑器如何实现多人光标功能?

CRDT 协同编辑:如何确定操作时序?

相关推荐
前端老宋Running几秒前
React 的“时光胶囊”:useRef 才是那个打破“闭包陷阱”的救世主
前端·react.js·设计模式
yinuo4 分钟前
前端跨页面通讯终极指南③:LocalStorage 用法全解析
前端
隔壁的大叔12 分钟前
正则解决Markdown流式输出不完整图片、表格、数学公式
前端·javascript
胡楚昊15 分钟前
CTF SHOW逆向
java·服务器·前端
拉不动的猪31 分钟前
前端JS脚本放在head与body是如何影响加载的以及优化策略
前端·javascript·面试
shykevin32 分钟前
Actix-Web完整项目实战:博客 API
前端·数据库·oracle
lichong95143 分钟前
RelativeLayout 根布局里有一个子布局预期一直展示,但子布局RelativeLayout被 覆盖了
android·java·前端
LengineerC1 小时前
我写了一个VSCode的仿Neovide光标动画
前端·visual studio code
WangHappy1 小时前
Mongoose操作MongoDB数据库(1):项目创建与连接配置
前端·mongodb
OpenTiny社区1 小时前
低代码运行时渲染搞不懂?TinyEngine 从理论到实践全攻略,看完直接上手!
前端·vue.js·低代码