Skip to main content

Yjs

Yjs 是一个基于 CRDT 的高性能协作编辑框架,它将用户对文档的修改转化为增量更新操作,并通过内部的数据结构进行冲突处理与合并。

Yjs 可以通过 WebSocket、WebRTC 等通信机制实现多方数据同步。开发者只需使用其提供的 API 即可轻松实现文本、数组等结构的实时协作,复杂的合并逻辑与光标状态同步等均由 Yjs 自动处理。

架构设计

Yjs

从上到下分别是:编辑器集成层、Yjs 核心层、通信与存储层。

编辑器适配层

这一层是用户直接交互的前端编辑器,例如:

  • ProseMirror、Tiptap(富文本编辑器)
  • Slate(React 编辑器)
  • Monaco(VS Code 内核的代码编辑器)

Yjs 为这些编辑器提供了专用的绑定模块(类似于驱动程序),用于监听用户在编辑器中的操作(如文本插入、删除),并将其同步到 Yjs 的共享文档中。这样,用户的本地编辑行为会实时转换为可传播的 CRDT 更新操作,进而同步给其他协作者。

Yjs 支持大多数 主流编辑器的接入

因此,为了实现协同能力,每种编辑器都需要开发一个绑定层,用于在编辑器的数据结构与 Yjs 的文档结构之间进行双向转换(同步变更与应用变更)。这个绑定层正是实现协作功能的桥梁(起到一个连接编辑器和 Yjs 的桥梁作用)。

  • y-prosemirror:用于 ProseMirror 编辑器。
  • y-monaco:用于 Monaco 编辑器。
  • y-quill:用于 Quill 编辑器。
  • y-codemirror:用于 CodeMirror 编辑器。

Yjs 核心

这一层是整个系统的核心,Yjs 负责以下任务:

  • 管理文档的状态副本(即 CRDT 文档)。
  • 将用户操作转化为 CRDT 操作。
  • 自动合并并发编辑,解决冲突。
  • 管理数据结构,如 Y.Text, Y.Map, Y.Array, Y.Xml

所有编辑器的变更都会同步到这里,然后统一进行处理和广播,是整个协作系统的中枢神经。

通信与存储模块

Yjs 本身不包含网络通信逻辑,而是通过插件方式提供以下通信与持久化能力:

  • y-protocols:定义同步协议与协作状态(如光标位置、用户状态)的传输格式。
  • y-websocket:使用 WebSocket 实现基于服务器的实时通信。
  • y-webrtc:使用 WebRTC 实现点对点通信,无需中间服务器。
  • y-indexeddb:在浏览器中进行本地持久化,支持离线编辑与重连恢复。
  • y-redis:在多个 WebSocket 服务器之间同步更新(适用于负载均衡场景)。

开发者可以根据应用需求灵活选择这些模块进行组合,构建出具备离线能力、实时同步能力、跨设备同步能力的协作系统。

数据流

Yjs 的整个数据流向如下所示:

用户操作

前端编辑器:ProseMirror、Tiptap、Slate、Monaco

Yjs 核心:CRDT 数据结构

通信模块:y-websocket、y-webrtc、y-redis

其他协作者的 Yjs 实例

其他用户的前端编辑器

本地存储模块:y-indexeddb

Yjs优点

冲突处理可靠

Yjs 基于 CRDT 数据结构 实现分布式冲突自动合并机制,具备以下特性:

  • 强一致性:即使并发编辑也能最终达成一致状态。
  • 无需中央服务器协调:天然支持去中心化。
  • 天然处理网络延迟/离线重连:变更按因果顺序合并,无需额外冲突解析。

与传统的 OT(Operational Transformation)算法相比,CRDT 在复杂网络环境下更稳健,适用于异步协作和离线编辑。

协作者状态同步

Yjs 内置 Awareness 模块,用于同步协作者的界面状态信息:

  • 光标位置、文本选区。
  • 用户名、用户颜色标识。
  • 在线/离线状态。

这一机制让协作者能够感知他人的存在,提升协作体验,有效避免操作冲突。

离线编辑支持

Yjs 是天然支持离线编辑的框架:

  • 所有变更都通过本地 CRDT 数据结构记录。
  • 在恢复联网后可无缝同步给其他协作者。
  • 通常配合 y-indexeddb 实现 浏览器端持久化

离线编辑 + 自动同步 是构建 Local-First 应用的基础能力。

版本快照与历史恢复

Yjs 提供轻量级的文档快照机制:

  • 快照是结构性的,不需要保存全量内容。
  • 可用于实现版本回溯、撤销恢复等功能。
  • 可将快照序列化保存到数据库,便于持久化。

高并发协作能力

Yjs 针对性能优化良好,适用于高并发场景:

  • 文档数据结构增量压缩。
  • 支持垃圾回收(减少内存占用)。
  • 实测可支持 几十到上百人 实时编辑(具体上限与文档规模、GC 策略有关)。

在配合合适的通信层(如 WebSocket 广播服务)时,Yjs 表现出极高的扩展性。

插件化生态

Yjs 拥有丰富的绑定与适配模块:

编辑器对应绑定模块
ProseMirror / Tiptapy-prosemirror
Quilly-quill
Monaco(代码编辑器)y-monaco
Slatey-slate
CodeMirrory-codemirror.next

同时也有配套的通信模块(y-websockety-webrtc)和存储模块(y-indexeddby-leveldb 等),可以灵活组合使用。

Yjs 用工程化方式实现了 CRDT 协作能力,是构建现代多人协作应用(富文本、代码、图形)的强大基石。借助 Yjs,开发者可以快速实现支持离线、实时同步和冲突自动合并的多人协作功能。

对比

Automerge 和 Yjs 两者简单对比:

对比点AutomergeYjs
性能较慢(v2 有改进)非常快(结构优化+GC)
内存使用较大(因保留完整历史)较小(支持垃圾回收)
社区活跃度中等
插件生态较少非常丰富(尤其是编辑器方向)
合并冲突处理清晰但占空间高效但实现复杂

Yjs官网

最后是关于 Yjs 的官网:

第一个是 Yjs 的正式官网,由作者 Kevin Jahns 维护,特点如下:

  • 权威文档:API、使用方式、通信模块等都有说明
  • 代码示例:含 TypeScript 支持、协作代码演示
  • 插件介绍:如 y-websocket、y-indexeddb、y-prosemirror 等
  • 持续更新:是社区开发者首选参考站点
  • 适合:技术人员、框架集成、深入研究者

第二个是社区作品,这个页面是一个设计美观的 介绍性落地页(Landing Page),特点如下:

  • 主要用于视觉展示,像一个营销型首页。
  • 页面简洁,有动画效果,展示 Yjs 优点。
  • 由社区用户或爱好者构建(非官方维护)。
  • 并不包含完整的文档或深入的技术细节。

核心API

基本使用

安装相关依赖:

pnpm add quill quill-cursors yjs y-quill y-websocket
  • quill:Quill 是一个开源、模块化的 WYSIWYG 富文本编辑器,支持格式化文字、插入图片、列表、代码块等。
  • quill-cursors:这个库扩展了 Quill,使其支持在协同编辑中显示多个用户的“光标位置”和“选中区域”,通常会以彩色的虚线框或用户名标签表示。
  • yjs:Yjs 是一个高性能的 CRDT 库,可以让多个用户在没有冲突的情况下协同编辑共享数据。
  • y-quill:这个库是 Yjs 提供的绑定器(binding),可以把 Quill 的编辑器内容和 Yjs 的共享文档(Y.Text)同步起来。从而实现实时协同富文本编辑。
  • y-websocket:y-websocket 提供了一个 WebSocket 客户端和服务器端实现,用于在多用户之间同步 Yjs 文档数据,从而保持文档在所有客户端之间的实时一致性。
import Quill from 'quill'
import QuillCursors from 'quill-cursors'
import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'
import { QuillBinding } from 'y-quill'

import 'quill/dist/quill.snow.css'

Quill.register('modules/cursors', QuillCursors)

const quill = new Quill(document.querySelector('#app') as HTMLDivElement, {
modules: {
cursors: true,
toolbar: [
[{ header: [1, 2, 3, 4, 5, 6, false] }],
['bold', 'italic', 'underline', 'strike'],
[{ list: 'ordered' }, { list: 'bullet' }],
['link', 'image', 'video']
],
history: {
userOnly: true
}
},
placeholder: 'Please input content here.',
theme: 'snow'
})

const ydoc = new Y.Doc()

const ytext = ydoc.getText('quill')

const provider = new WebsocketProvider('ws://localhost:1234', 'quill-demo-room', ydoc)

new QuillBinding(ytext, quill, provider.awareness)

Y.doc

在 Yjs 中,Y.Doc 是一切的起点。

可以把它理解成一份协同文档的载体,就像是前端里的一个全局 store,每个 Yjs 的数据结构都挂载在它之上。你可以创建多个 Y.Doc,Y.Doc 可以实例化多个,每实例化一个就开了一个副本,类似于新加入了一个客户端。只要你通过网络把它们连起来,这些副本就能自动合并。

在 Yjs 中,如果要协同编辑字符串,应该用 Y.Text,这个结构在底层会自动记录每一次插入和删除,并转化为一种可合并的 CRDT 操作。

import * as Y from "yjs";

// 创建第一个副本,相当于一个客户端A
const doc1 = new Y.Doc();
// 创建第二个副本,相当于一个客户端B
const doc2 = new Y.Doc();

// 创建一个文本类型的共享数据
const text1 = doc1.getText("text");
// 会和第一个副本的文本类型共享数据进行同步
const text2 = doc2.getText("text");

// 监听第二个副本的变化
text2.observe(() => {
console.log(`doc2收到更新:${text2.toString()}`);
});

// 在第一个副本插入数据
text1.insert(0, "Hello ");
console.log(`doc1插入数据:${text1.toString()}`);
console.log(`doc2当前的数据:${text2.toString()}`);

// 该方法用于创建一个更新
const update = Y.encodeStateAsUpdate(doc1);
// 应用更新
Y.applyUpdate(doc2, update);

结构化数据

在实际应用中,面对的数据远不止文本,更多的是结构化内容:

  • 一个 JSON 数据对象(如表单数据、配置项)。
  • 一个动态数组(如评论列表、任务列表)。
  • 一个嵌套结构(如树状文件、用户配置)。

如果说 Y.Text 是“字符串的协同容器”,那接下来的 Y.MapY.Array,就是协同世界里的对象和数组。

Y.Map

Yjs 中的 Y.Map 是动态对象协同结构,就像 JavaScript 中的对象 {},它支持动态增删键、嵌套结构、事件监听,而且具备协同特性:

  • 多人同时 set 键值,不会冲突。
  • 可以监听某个 key 的变化。
  • 支持嵌套结构(嵌套 Y.MapY.TextY.Array)。
import * as Y from "yjs";

const doc = new Y.Doc();
// 共享Map类型的数据
const profile = doc.getMap("profile");

// 设置一个属性
profile.set("name", "Alice");
// 设置另一个属性
profile.set("age", 30);
// 设置第三个属性
profile.set("address", "123 Main St");
// 输出当前的profile对象
console.log("profile:", profile.toJSON());

const doc2 = new Y.Doc();
// 共享Map类型的数据
const profile2 = doc2.getMap("profile");
// 输出当前的profile对象
console.log("同步之前的profile2:", profile2.toJSON());

// 数据同步
const update = Y.encodeStateAsUpdate(doc);
Y.applyUpdate(doc2, update);
console.log("同步之后的profile2:", profile2.toJSON());

从语法上看,几乎和普通对象没差,但它是 可共享可监听可同步的。

Y.Array

Y.Array 是一个动态协同数组结构,可以把它理解成带有版本合并机制的 JavaScript 数组。

  • 可以 pushinsertdelete
  • 每一个元素都是可追踪的,修改具有可合并性。
  • 元素可以是任意结构(如字符串、对象、Y.TextY.Map)。
import * as Y from "yjs";

const doc = new Y.Doc();
const list = doc.getArray("list");

// []
console.log(list.toArray());
// 向列表中添加元素
list.push(["hello", "world"]);
// ["hello", "world"]
console.log(list.toArray());

可以使用 Y.Array 做评论区、任务列表、协同表格,甚至 JSON 树结构。Y.MapY.Array 可以嵌套使用。

import * as Y from "yjs";

const doc = new Y.Doc();
const users = doc.getMap("users");

const user1 = new Y.Map();
const tag = new Y.Array();
tag.push(["tag1", "tag2"]);
user1.set("tags", tag);

users.set("user1", user1);

users.set("name", "Lucy");

console.log(users.toJSON());

事件监听

在 Yjs 中,可以使用 observe()observeDeep() 来监听协同文档中数据结构的更新。

observe()observeDeep() 的区别如下:

方法监听范围典型用途
observe(cb)只监听当前结构(Map / Array)精准字段监听
observeDeep(cb)监听当前结构及其所有子结构嵌套结构复杂时统一处理
import * as Y from "yjs";

const doc = new Y.Doc(); // 创建一个副本
const text = doc.getText("text"); // 创建一个文本类型的共享数据

text.observe((event) => {
console.log("文本内容发生变化");

event.changes.delta.forEach((change) => {
if (change.insert) {
console.log("插入的文本内容:", change.insert.toString());
} else if (change.delete) {
console.log("删除了", change.delete, "个字符");
} else if (change.retain) {
console.log("保留了", change.retain, "个字符");
}
});
});

// 插入文本,共享的文本内容发生了变化,因此会触发事件
text.insert(0, "Hello Yjs!");
// 删除文本,共享的文本内容发生了变化,因此会触发事件
text.delete(0, 6);

observeDeep 可以监听一整个嵌套的子结构的变化

root.observeDeep((event) => {
// ...
})

event.path 用于描述发生变化的共享数据相对于被监听的根对象的路径,会从监听的根对象一直到触发事件的具体的数据。

rootMap (Y.Map)
├── title (Y.Text)
└── content (Y.Map)
├── section1 (Y.Text)
└── metadata (Y.Map)
└── author (Y.Text)

假设修改一个深层次的数据 author,那么 event.path 则为:

path: content,metadata,author