blog.dopana

Back

构建像 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 的极简示例:

参考文献#