Skip to main content

run

明白了,之前讲得可能太抽象。让我换一种方式,从最核心的问题出发,一步步解释 Yjs 到底怎么工作的。

假设两个人(甲、乙)同时在编辑一个文本,甲插入一个字母 'a',乙插入一个字母 'b',他们都不知道对方干了什么。最后文档应该是 'ab' 还是 'ba'?谁决定?
如果只是普通程序,甲的插入会覆盖乙的,或者直接乱掉。Yjs 保证最终两人看到的完全一样,而且不用中心服务器指挥


1. 核心思想:给每个字符一个“永不重复的ID”

Yjs 不是按“第几个位置”来存储文本,而是给每个字符绑定一个唯一标识。这个标识像一个身份证:

  • ID = (客户端的唯一编号, 计数器)
    例如:甲(ClientID=1) 第一次插入 → ID = (1, 1);第二次插入 → (1, 2)
    乙(ClientID=2) 第一次插入 → (2, 1)

这样,全世界没有任何两个字符有相同的ID。


2. 数据组织:双向链表(不是数组)

传统文本编辑器用数组 ['h','e','l','l','o']
Yjs 内部是一个双向链表,每个节点(叫做 Item)里存着:

  • 字符内容 'h'
  • 自己的ID (1,1)
  • 前一个字符的ID(指针)
  • 后一个字符的ID
  • 一个“是否被删除”的标记(墓碑)

![思想图:链表形式,每个节点有前后指针和ID]

好处是:插入时不需要移动后面的所有字符,只改几个指针就行。


3. 插入操作:永远基于“前后ID”

假设文档初始是 "ab",这两个字符的ID假设是 (1,1)(1,2)

  • 甲想在第1个字符后插入 'x'
    甲说:我要在字符 (1,1) 后面、字符 (1,2) 前面插入一个新字符 'x',新ID是 (1,3)
  • 乙想在第1个字符后插入 'y'
    乙说:我要在字符 (1,1) 后面、字符 (1,2) 前面插入 'y',新ID是 (2,1)

这两个操作是并发的。关键来了:Yjs 如何合并?


4. 合并规则(CRDT的核心):不冲突,直接插

Yjs 收到对方的更新时,不会像 OT 那样去“转换坐标”,而是直接根据前后ID插入

  • 甲本地:(1,1)'x'(1,3)(1,2) → 文档呈现 a x b
  • 乙本地:(1,1)'y'(2,1)(1,2) → 文档呈现 a y b

现在两人互相交换操作:

  • 甲收到乙的操作 插入(前后ID=(1,1),(1,2), 内容='y', ID=(2,1))
    甲在自己的链表里找到 (1,1)(1,2),把 (2,1) 插在中间。
    现在甲的链表:(1,1)(1,3)(2,1)(1,2)
    按照ID的某种总序(例如按ClientID再按计数器)排序显示,呈现 a x y b

  • 乙收到甲的操作后同样插入,最终乙的链表也是 (1,1)(1,3)(2,1)(1,2),显示 a x y b

结果一致,没有冲突,不需要转换函数。


5. 删除:“墓碑”机制

如果甲想删除 'x'(ID=(1,3)),他不会真的从链表移除节点,而是把那个节点的删除标记设为 true(立一个墓碑)。
乙收到删除操作后,也把 (1,3) 的删除标记设为 true
显示时,遇到墓碑就跳过。

为什么不用真删除?
因为如果直接删除,万一乙之前有一个并发操作是基于 (1,3) 的位置(比如在它后面插入),那个操作就找不到锚点了。墓碑保证位置参考永远有效。


6. 总结 Yjs 的实现原理(一句话版)

Yjs 把文档建模成一个基于“带全局唯一ID的节点”的双向链表,插入时通过前后ID定位,删除时只做标记,所有操作直接合并,利用ID的总序确定最终顺序,因此天然无冲突。


7. 为什么这比 OT 简单?

  • OT:必须把所有操作发到一个中心或全员排序,然后逐个转换,转换逻辑必须小心处理每一个边界情况。
  • Yjs:每个操作自带上下文(前一个节点是谁,后一个节点是谁),直接拼接,不需要转换。就像拼图,每块拼图已经告诉了它左边和右边是什么形状。

8. 一个帮你彻底理解的例子(用脑图模拟)

初始:[A]--[B](ID分别是(1,1),(1,2))

  1. 甲插入 C 在 A 后 B 前 → A - C - B
  2. 乙插入 D 在 A 后 B 前(并发) → A - D - B
  3. 交换操作:
    • 甲:A - C - D - B
    • 乙:A - C - D - B(一样的)
  4. 显示时按ID排序(比如ClientID小的在前)→ 显示 A C D B(如果甲ClientID=1,乙=2)

最终两人一模一样。


如果你愿意,我可以画一个时间轴图示,清晰展示两个用户并发插入后,各自链表如何一步步合并。或者用一个具体的代码片段(Yjs + 浏览器控制台)模拟这个过程,你一看运行结果就彻底明白了。