Skip to main content

run-example-abc

你问得很准。Yjs 中每个字符/节点的 ID 确实包含客户端的唯一标识(clientID)。它不是一个“坐标”,而是一个全局唯一的标签

之前例子中的 (1,1) 就表示:客户端 1(比如甲)产生的第 1 个字符。
这个 ID 永远不会改变,也永远不会跟别人重复。

当有 A、B、C 三个人 时,原理完全一样,只是多了一个来源的 ID。


一、ID 的完整结构

每个 ID 是一个 二元组 (clientID, 计数器)

  • clientID:每个客户端(用户/设备)一个唯一编号(比如 1、2、3…)。
  • 计数器:该客户端本地单调递增的整数,从 1 开始。

举例:

  • A(clientID=1)产生:(1,1), (1,2), (1,3)
  • B(clientID=2)产生:(2,1), (2,2)
  • C(clientID=3)产生:(3,1), (3,2)

这样全世界任何两个 ID 都不同(因为 clientID 不同或计数器不同)。


二、三个人并发插入,如何合并?

假设文档初始有两个字符 a (1,1)b (1,2)

三个用户 同时ab 之间插入一个新字符:

  • A 插入 'x' → ID (1,3)
  • B 插入 'y' → ID (2,1)
  • C 插入 'z' → ID (3,1)

本地各自修改后:

  • A 的链表:(1,1):a(1,3):x(1,2):b
  • B 的链表:(1,1):a(2,1):y(1,2):b
  • C 的链表:(1,1):a(3,1):z(1,2):b

交换更新:

每个用户收到其他两人的插入操作后,都会根据 前后节点 ID(左邻居 (1,1),右邻居 (1,2))把新节点插入到自己的链表中。

最终所有人链表都变成:

(1,1):a → (1,3):x → (2,1):y → (3,1):z → (1,2):b

三、显示的排序规则(全序)

链表只决定了节点之间的“前后邻接关系”,但如果有多个节点插在同一位置(如上面的 x, y, z),它们之间的相对顺序ID 的比较函数 决定。

Yjs 使用一种确定性的全序(total order),例如:

  • 先比较 clientID(小的在前)
  • 如果 clientID 相同,再比较 计数器

按照这个规则:
(1,3) < (2,1) < (3,1)
所以顺序是 xyz

最终文档显示:a x y z b

注意:这个顺序不是随机的,也不是由时间决定的。它是基于 clientID 的固定规则,保证所有客户端算出同样的结果。


四、为什么这样设计?

  • 不需要中央服务器排序:每个客户端独立计算,结果一致。
  • 不依赖网络延迟:哪怕 B 的操作比 A 早到达 C,最终顺序仍按 ID 规则,而不是到达时间。
  • 可扩展:加更多人只要分配新 clientID 即可。

五、常见疑问澄清

1. “坐标是由 client 组成的?”
严格说,Yjs 不用“坐标”(即字符位置索引)。它用 ID 对 + 前后指针。ID 中的 client 部分只是用来保证唯一性和确定全序。

2. 三个人如果同时删除同一个字符呢?
删除只是打墓碑标记。多个删除操作是幂等的:重复删除同一节点无副作用。第一个删除生效,后续的忽略。

3. clientID 怎么分配?
需要一个小型协调服务(比如信令服务器)来分配唯一 clientID,或者用 UUID 碰撞概率极低的方式。Yjs 在实际使用时,通常由后端(如 WebSocket 服务器)在连接时分配一个整数 ID。


如果你愿意,我可以画一张 三个人并发插入的 ASCII 时序图,清晰地展示每个人的链表如何一步步合并成最终一致状态。这样会比文字更直观。