run-example-cixu2
你举的这个例子非常精准,正好触及了 Yjs 基于 ID 和链表的插入逻辑。让我帮你逐步拆解,你会发现这并没有问题,反而是 Yjs 能正确工作的原因。
你的例子
假设用户 1(clientID=1)已经产生了三个字符:
(1,1)→ 字符a(1,2)→ 字符b(1,3)→ 字符c
链表顺序:(1,1):a → (1,2):b → (1,3):c
现在用户 1 想在 (1,1) 和 (1,2) 之间 插入一个新字符 d。
- 左邻居 =
(1,1) - 右邻居 =
(1,2) - 新节点的 ID =
(1,4)(因为计数器已用到 3,下一个是 4)
插入后,用户 1 本地的链表变成:
(1,1):a → (1,4):d → (1,2):b → (1,3):c
你写的顺序 (1,1) (1,4) (1,2) (1,3) 完全正确。
你说的“问题”可能是什么?
我猜你担心的是:
原本
(1,2)的右邻居是(1,3),现在在(1,2)前面插入了(1,4),这不会破坏(1,3)的相对位置吗?
或者:如果别的用户也在同一位置插入,不会乱吗?
答案是:完全不会破坏,因为 Yjs 的链表操作只修改相邻指针,不会移动已有节点。
具体修改:
(1,1)的next从(1,2)改为(1,4)(1,4)的prev=(1,1),next=(1,2)(1,2)的prev从(1,1)改为(1,4)
(1,3) 的指针完全没有变化:它的 prev 仍然是 (1,2),next 仍然是 null(或后续节点)。
所以顺序仍然是 (1,2) → (1,3),只是 (1,2) 不再直接跟在 (1,1) 后面而已。
所有节点的 ID 和内容都不会被修改,只改变指针方向。这就是链表的优势。
并发情况(这才是 Yjs 真正要解决的问题)
如果此时用户 2 也在完全相同的锚点(左 (1,1),右 (1,2))插入了自己的字符 e,ID 为 (2,1)。那么当用户 1 收到用户 2 的操作时:
用户 1 的当前链表:(1,1) → (1,4) → (1,2) → (1,3)
用户 2 的操作说:在 (1,1) 和 (1,2) 之间插入 (2,1)。
用户 1 会找到 (1,1) 和 (1,2),然后把 (2,1) 插入它们之间。
注意:此时 (1,1) 和 (1,2) 之间已经存在 (1,4) 了。Yjs 如何处理?
答案是:Yjs 允许在同一个锚点区间内插入多个节点。
插入后,(1,1) 和 (1,2) 之间会形成一个小序列,顺序由 ID 的全序 决定。
假设全序规则是 clientID 小的在前(1 < 2)且计数器小的在前。
那么 (1,4) 和 (2,1) 比较:clientID 1 < 2,所以 (1,4) 排在 (2,1) 前面。
最终用户 1 的链表变为:
(1,1) → (1,4) → (2,1) → (1,2) → (1,3)
用户 2 那边会经过对称的合并,最终也得到完全相同的链表顺序。
为什么你觉得“有问题”?
可能因为你下意识地认为插入操作应该维护一个严格的“位置编号”(比如数组下标)。在数组里,如果在位置 1 后插入一个元素,后面的所有元素下标都会 +1,这会导致并发插入时出现“谁先谁后”的竞态。
Yjs 的链表 + ID 方案完全放弃了下标,所以不会有下标移动带来的冲突。每个节点只关心它的直接邻居,无论中间插入了多少个其他节点,原来的邻居关系可以通过指针正确遍历。
一句话总结
你举的例子
(1,1)(1,4)(1,2)(1,3)完全正确,这不是问题,恰恰是 Yjs 链式存储的正常表现。并发插入时,Yjs 通过 ID 全序解决多个节点在同一锚点区间的顺序,不会破坏任何已有节点的 ID 或内容。
如果你还有困惑,比如“那如何实现按字符位置跳转(比如光标移动)?”——这确实需要额外维护一个索引或平衡树来加速,Yjs 内部用了类似的结构(例如基于 B+ 树的索引)。我可以进一步解释,或者画一个并发插入的全过程图。