Skip to main content

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+ 树的索引)。我可以进一步解释,或者画一个并发插入的全过程图。