构建像 Google Docs、Figma 或 Notion 这样的实时协同应用,曾经是 Web 开发中极具挑战性的技术难题。处理网络延迟、断网重连以及在多人同时在同一位置输入时解决冲突,都需要编写极其复杂的算法逻辑。
而如今,像 Yjs 这样的库让这一切变得轻而易举。本文将带你深入了解 Yjs 是什么、它的底层运行机制,以及为什么它是现代协同应用开发的首选。
什么是 Yjs? (ELI5)#
想象一下,你和朋友正在同时在一本笔记本上修改故事。
在传统的协同系统(例如 Google Docs 采用的 OT:操作转换 技术)中,你们之间需要一位 “老师”(中央服务器) 来进行协调。当你写下一个字时,你必须先让老师看。老师决定谁先写,调整你们字的位置,并把最终结果告诉你们俩。如果老师走开了(服务器离线),你们就无法继续协作了。
Yjs 采用的 CRDT(无冲突复制数据类型) 技术,即使没有老师也能正常工作:
- 你写下的每个字母都会贴上一个 “隐形标签”(唯一 ID),并记下它的 “邻居”(例如:“这个字母紧跟在字母 X 后面”)。
- 你和朋友可以随意书写,即使你们在不同的房间(离线状态)也没关系。
- 稍后你们见面合上笔记时,只需比对标签和邻居信息就能直接合并。因为合并规则是数学上确定性的,所以最终你们的笔记本内容会自动变得完全一致,且绝不会产生冲突!
核心架构与工作流#
Yjs 扮演着一个与网络无关的数据模型的角色。它维护本地文档状态(Y.Doc),并将更新转化为二进制数据包,以便通过任何协议(WebSocket、WebRTC 甚至是邮件)传输。
sequenceDiagram
participant A as 客户端 A
participant M as 网络 WebSocket
participant B as 客户端 B
Note over A: ydoc.getMap().set('x', 1)
A->>M: encodeStateAsUpdate (二进制)
Note over B: ydoc.getMap().set('y', 2)
B->>M: encodeStateAsUpdate (二进制)
M->>B: 分发客户端 A 的更新
Note over B: Y.applyUpdate() - 自动合并状态
M->>A: 分发客户端 B 的更新
Note over A: Y.applyUpdate() - 自动合并状态
1. 共享类型 (Shared Types)#
Yjs 提供了与普通 JavaScript 结构行为类似,但能自动同步的共享数据类型:
Y.Text:用于协同文本编辑器。Y.Array:协同列表。Y.Map:协同键值对存储。Y.XmlFragment:用于富文本结构树。
2. 连接提供者 (Connection Providers)#
Yjs 与底层网络技术解耦,你只需引入一个 Provider(提供者)来处理通信:
y-websocket:标准的客户端-服务器同步。y-webrtc:浏览器端无后端的点对点(P2P)直接同步。y-p2p/y-matrix:其他去中心化协议。
3. 数据库存储提供者 (Database Providers)#
为了防止用户关闭标签页时丢失进度,持久化提供者用于保存历史更新:
y-indexeddb:浏览器本地存储,用以支持离线编辑。y-leveldb/y-redis:服务器端的持久化数据库适配器。
Yjs 运行原理(YATA 算法)#
Yjs 的底层是基于 YATA (Yet Another Transformation Approach) 算法。它通过以下机制确保强最终一致性(Convergence)并保留用户的编辑意图:
- 唯一且不可变的 ID: 每一个被插入的元素(如字符)都会被分配一个由 Client ID 和本地事务计数器组成的唯一 ID。
- 位置锚定(Origin Anchors): 每个字符都会记录其插入瞬间的左邻居(
origin)和右邻居(originRight)。即使周围的文本发生变化,它依然能够被锚定到正确的上下文中。 - 游程编码(Run-Length Encoding): 为了防止由于存储元数据导致内存膨胀,Yjs 会将来自同一客户端的连续插入操作合并为单个“结构体(struct)”。这使得 Yjs 拥有惊人的运行速度和超低的内存消耗。
快速代码示例#
下面是一个如何使用 Yjs 同步 Map 和 Array 的极简示例:
import * as Y from 'yjs';
// 1. 创建本地和远程文档
const ydocLocal = new Y.Doc();
const ydocRemote = new Y.Doc();
// 2. 定义共享类型
const ymapLocal = ydocLocal.getMap('shared-meta');
const ymapRemote = ydocRemote.getMap('shared-meta');
// 3. 同时在两端做出修改
ymapLocal.set('theme', 'dark');
ymapRemote.set('fontSize', '16px');
// 4. 模拟网络数据同步
const updateFromLocal = Y.encodeStateAsUpdate(ydocLocal);
const updateFromRemote = Y.encodeStateAsUpdate(ydocRemote);
Y.applyUpdate(ydocRemote, updateFromLocal);
Y.applyUpdate(ydocLocal, updateFromRemote);
// 现在,两个文档的数据已完美合并且完全一致
console.log(ymapLocal.toJSON());
// 输出: { theme: 'dark', fontSize: '16px' }javascript