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)。
三个用户 同时 在 a 和 b 之间插入一个新字符:
- 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)
所以顺序是 x → y → z。
最终文档显示: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 时序图,清晰地展示每个人的链表如何一步步合并成最终一致状态。这样会比文字更直观。