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 协同编辑:如何确定操作时序?

相关推荐
程序员码歌17 小时前
短思考第261天,浪费时间的十个低效行为,看看你中了几个?
前端·ai编程
Swift社区17 小时前
React Navigation 生命周期完整心智模型
前端·react.js·前端框架
若梦plus17 小时前
从微信公众号&小程序的SDK剖析JSBridge
前端
用泥种荷花18 小时前
Python环境安装
前端
Light6018 小时前
性能提升 60%:前端性能优化终极指南
前端·性能优化·图片压缩·渲染优化·按需拆包·边缘缓存·ai 自动化
Jimmy18 小时前
年终总结 - 2025 故事集
前端·后端·程序员
烛阴18 小时前
C# 正则表达式(2):Regex 基础语法与常用 API 全解析
前端·正则表达式·c#
roman_日积跬步-终至千里18 小时前
【人工智能导论】02-搜索-高级搜索策略探索篇:从约束满足到博弈搜索
java·前端·人工智能
GIS之路19 小时前
GIS 数据转换:使用 GDAL 将 TXT 转换为 Shp 数据
前端
多看书少吃饭19 小时前
从Vue到Nuxt.js
前端·javascript·vue.js