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))
- 甲插入
C在 A 后 B 前 →A - C - B - 乙插入
D在 A 后 B 前(并发) →A - D - B - 交换操作:
- 甲:
A - C - D - B - 乙:
A - C - D - B(一样的)
- 甲:
- 显示时按ID排序(比如ClientID小的在前)→ 显示
A C D B(如果甲ClientID=1,乙=2)
最终两人一模一样。
如果你愿意,我可以画一个时间轴图示,清晰展示两个用户并发插入后,各自链表如何一步步合并。或者用一个具体的代码片段(Yjs + 浏览器控制台)模拟这个过程,你一看运行结果就彻底明白了。