yjs 是用于协同编辑的开源实现,可以跟任何编辑器组合在一起,实现编辑器的协同编辑。在 yjs 的应用中,包含协同数据
和应用数据
两种类型的数据。协同数据
是指 yjs 中继承自 AbstractType
的多类型数据,可以解决多副本更新时产生的冲突;应用数据
是编辑器中的数据。yjs 协同的原理是将协同数据
和应用数据
绑定起来,协同数据
负责保持多个副本数据的一致,应用数据
负责编辑器内的各类操作和展示。
本文讲述如何应用 yjs。首先讲述 yjs 的常用 API,然后通过两个案例,介绍如何绑定数据
和数据协同时的生命周期
。
yjs 常用 API
构造协同数据
yjs 内部定义了不同类型的协同数据
。包括了常用的数据结构YText
、YArray
、YMap
和 Web 相关的数据YXmlElement
、YXmlFragment
、YXmlText
YXmlHook
。yjs 实现了这些数据的常用操作,可以很容易去使用他们。下面是使用这些数据的例子。
const ymap = new Y.Map();
const foodArray = new Y.Array();
foodArray.insert(0, ['apple', 'banana']);
ymap.set('food', foodArray);
ymap.get('food') === foodArray; // => true
ymap.set('fruit', foodArray); // => Error! foodArray is already defined
监听协同数据
yjs 通过 observe
和observeDeep
实现了协同数据的监听。其中 observeDeep
监听协同数据本身和其孩子的更新,observe
监听协同数据本身的更新。下面是observe
和observeDeep
的类型定义。当协同数据更新时,会触发监听的回调函数,回调函数会通过更新事件YEvent
传入当前的更新内容,从而执行相应的操作。
AbstractType.prototype.observeDeep(f: (arg0: Array<YEvent>, arg1: Transaction) => void): void;
AbstractType.prototype.observe(f: (arg0: EventType, arg1: Transaction) => void): void;
class YEvent {
target: AbstractType<any>; // 创建事件的类型
currentTarget: AbstractType<any>; // 调用观察回调的当前目标。
transaction: Transaction; // 发出事件的 transaction
get path(): (string | number)[]; // yjs 到 指定目标的路径
get changes(): {
added: Set<Item>; // 增加 Item
deleted: Set<Item>; // 删除 Item
// 针对 key-value 数据
keys: Map<string, {
action: 'add' | 'update' | 'delete';
oldValue: any;
newValue: any;
}>;
// 针对 线性 数据
delta: Array<{
insert?: Array<any> | string;
delete?: number;
retain?: number;
attributes?: {[x: string]: any;}
}>;
};
}
下面是监听协同数据的一个简单示例。
const yarray = doc.getArray('my-array');
yarray.observe((event) => {
console.log('yarray was modified');
});
yarray.insert(0, ['val']); // => "yarray was modified"
update
yjs 是协调编辑的 P2P 解决方案,每时每刻都有很多更新数据
的传输。为了减少每次传输数据的大小,yjs 会将更新数据进行编码,通过 update
事件发送出去。在 yjs 内部提供了 applyUpdate
用于将更新数据应用到一个新副本。
const doc1 = new Y.Doc();
const doc2 = new Y.Doc();
doc1.on('update', (update) => {
Y.applyUpdate(doc2, update);
});
doc2.on('update', (update) => {
Y.applyUpdate(doc1, update);
});
// All changes are also applied to the other document
doc1.getArray('myarray').insert(0, ['Hello doc2, you got this?']);
doc2.getArray('myarray').get(0); // => 'Hello doc2, you got this?'
也可以把一个副本的内容,全部发送到另一个副本。
const state1 = Y.encodeStateAsUpdate(ydoc1);
const state2 = Y.encodeStateAsUpdate(ydoc2);
Y.applyUpdate(ydoc1, state2);
Y.applyUpdate(ydoc2, state1);
多副本之间的通信
yjs 开源了一些 Providers
用于设置副本之间的通信、管理感知信息以及存储共享数据以供脱机使用。目前开源的实现包括了 y-webrtc
、y-websocket
和 y-indexeddb
,分别用于 webrtc 与 websocket 的网络传输 和 indexeddb 的持久性存储。Providers
之间可以相互组合。以下的例子显示了 Providers 的应用。
import * as Y from 'yjs';
import { WebrtcProvider } from 'y-webrtc';
import { WebsocketProvider } from 'y-websocket';
import { IndexeddbPersistence } from 'y-indexeddb';
const ydoc = new Y.Doc();
// this allows you to instantly get the (cached) documents data
const indexeddbProvider = new IndexeddbPersistence('count-demo', ydoc);
indexeddbProvider.whenSynced.then(() => {
console.log('loaded data from indexed db');
});
// Sync clients with the y-webrtc provider.
const webrtcProvider = new WebrtcProvider('count-demo', ydoc);
// Sync clients with the y-websocket provider
const websocketProvider = new WebsocketProvider('wss://demos.yjs.dev', 'count-demo', ydoc);
// array of numbers which produce a sum
const yarray = ydoc.getArray('count');
// observe changes of the sum
yarray.observe((event) => {
// print updates when the data changes
console.log('new sum: ' + yarray.toArray().reduce((a, b) => a + b));
});
// add 1 to the sum
yarray.push([1]); // => "new sum: 1"
yjs 应用到不同的编辑器
yjs 应用到不同的编辑器,是通过将协同数据
和应用数据
绑定到一起实现的。针对主流的编辑器,大都有相应开源库支持。接下来以 slate 为例,讲述一下如何将协同数据
和应用数据
绑定在一起;以 codemirror 和 websocket 为例,讲述一下在转化过程中的生命周期。
应用数据
与 协同数据
的绑定(以 slate 为例)
在 slate 中使用的协同数据
类型包括了 YMap
、 YArray
和 YText
三种。应用数据
跟据不同的应用有不同的形式。在 slate 内部,提供了两种类型的应用数据 Element
和 Text
,使用下面的类型定义:
type Descendant = Element | Text;
interface BaseElement {
children: Descendant[];
}
interface BaseText {
text: string;
}
下面是 slate 提供的应用数据
的一个例子
[
{
"type": "paragraph",
"children": [{ "text": "This is editable " }, { "text": "rich", "bold": true }]
}
]
在 slate-yjs
的实现中,Element/Text
与 YMap
对应;Element.children
与 YArray
对应;Text.text
与 YText
对应。
对于 应用数据
与 协同数据
的绑定,忽略掉 yjs 的内部细节,在应用层只需要做两件事:第一,通过 AbstractType.observeDeep
监听协同数据
,当其他副本的改动应用到本副本时,调用监听函数,然后将对应的改变应用于本副本;第二,将本副本的改动应用于协同数据
,然后 yjs 会将协同数据
的改动广播到其他副本。需要注意的一点是,在 slate 中,通过 原子操作
控制应用数据
,并且编辑器的操作也会转化为原子操作
。所以在 slate-yjs
中,通过 协同数据
和 原子操作
的相互转化完成了多副本的协同。
原子操作
到 协同数据
协同数据
到 原子操作
即将 Slate 的原子操作
应用到 yjs 的数据上。slate 的原子操作包括了九种。其中涉及到了 Node、Text 和 Selection 的操作。因为 Selection 不会对 slate 的数据造成影响,所以可以忽略。这里只对 Node 和 Text 做了相应的转化。这里只以 insertText
为例,讲解一下做法。
function insertText(doc: SharedType, op: InsertTextOperation): SharedType {
const node = getTarget(doc, op.path) as SyncElement; // 通过应用数据的 path 找到对应的协同数据
const nodeText = SyncElement.getText(node);
nodeText.insert(op.offset, op.text);
return doc;
}
协同数据
到 原子操作
协同数据
到 原子操作
是将其他副本的协同数据
产生的事件应用到原子操作
。协同数据
产生的事件包括了 YText、YMap 和 YArray 的事件。YArray 和 YText 都是链表结构。对他们的更改包含在 event.changes.delta
,包括了 retain、insert 和 delete 操作。YArray 的操作对应remove_node
和insert_node
原子操作;YText 的操作对应 remove_text
和insert_text
原子操作。YMap 是健值结构,对他的更改包含在 event.changes.keys
中,对应set_node
原子操作。这里以 YMap 为例,讲解一下具体的操作。
export default function translateMapEvent(editor: Editor, event: Y.YMapEvent<unknown>): NodeOperation[] {
const targetPath = toSlatePath(event.path); // 通过 协同数据的 path 找到对应的应用数据
const targetSyncElement = event.target as SyncElement;
const targetElement = Node.get(editor, targetPath) as Descendant;
const keyChanges = Array.from(event.changes.keys.entries());
const newProperties = Object.fromEntries(
keyChanges.map(([key, info]) => [key, info.action === 'delete' ? null : targetSyncElement.get(key)]),
);
const properties = Object.fromEntries(keyChanges.map(([key]) => [key, targetElement[key]]));
return [{ type: , newProperties, properties, path: targetPath }];
}
应用数据
和 协同数据
转化的生命周期(以 codemirror 为例)
在 yjs-demos
提供了 codemirror 的转化例子。codemirror 的应用数据
因为全部是文本,所以只需要与 协同数据
的 YText
做转化。本节讲述了 codemirror 的中 应用数据
和 协同数据
转化的生命周期。在开发过程中,可以顺着这个顺序去捋清楚内部的整个流程。因为 codemirror 的应用中,使用了 websocket 作为通信协议,下面的讲述会把通信的过程也加上。
发送周期:
- codemirror: 插入文本
- y-codemirror: 监听了 codemirror 的 change 事件
codeMirror.on('changes', targetObserver)
。在targetObserver
回调中,会使协同数据做相应的操作YText.insertText
。 - yjs:
insertText
产生一个 Item,并 通过Item.integrate()
将该 item 集成到本地副本中。在集成的过程中,可能会产生并解决冲突。 - yjs: 在 步骤 3 的执行过程中,会调用
cleanupTransactions
函数。该函数会将产生的更新通过doc.emit('update')
发送出去。在这个过程中,会对更新数据进行编码,以最小化发送的消息。 - y-websocket:监听了 doc 的 update 事件
doc.on('update', updateHandler)
,在 updateHandler 回调中,会将 更新消息 广播至全部副本。
接收周期:
- y-websocket:
websocket.onmessage
接收到更新消息。并通过readMessage => messageHandlers[messageSync] => syncProtocol.readSyncMessage => readUpdate
进行消息读取。 - y-protools: 在 readSyncMessage 中,会 根据 messageType 的不同调用不同的 读取函数。最终会调用
Y.applyUpdate
将收到的更新信息集成到本副本的文档中。 - yjs:
applyUpdate => readUpdateV2
- 3.1:
readUpdateV2.readClientsStructRefs
将读取到的更新数据
构造一个新的 Item - 3.2:
readUpdateV2.integrateStructs
: 将新 Item 集成到本地的 Item 链表中。这个过程中,对应的协同数据YText
会发生改变,同时会转成对应的更新事件。 - 3.3:
readUpdateV2 => cleanupTransactions
: readUpdateV2 包裹在 transact 中,最终会调用 cleanupTransactions,这里会遍历发生变化的 Itemtransaction.changed.forEach => itemtype._callObserver(transaction, subs) => callTypeObservers
,最终将触发 发生变化的 YText 的监听回调。
- y-codemirror: 监听了
YText
的变化。在回调中,将对应的变化应用到 codemirror。 - codemirror: 执行插入操作。