YJS 协同编辑入门

10,190 阅读7分钟

yjs 是用于协同编辑的开源实现,可以跟任何编辑器组合在一起,实现编辑器的协同编辑。在 yjs 的应用中,包含协同数据应用数据两种类型的数据。协同数据是指 yjs 中继承自 AbstractType 的多类型数据,可以解决多副本更新时产生的冲突;应用数据是编辑器中的数据。yjs 协同的原理是将协同数据应用数据绑定起来,协同数据负责保持多个副本数据的一致,应用数据负责编辑器内的各类操作和展示。

本文讲述如何应用 yjs。首先讲述 yjs 的常用 API,然后通过两个案例,介绍如何绑定数据数据协同时的生命周期

yjs 常用 API

构造协同数据

yjs 内部定义了不同类型的协同数据。包括了常用的数据结构YTextYArrayYMap和 Web 相关的数据YXmlElementYXmlFragmentYXmlText 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 通过 observeobserveDeep 实现了协同数据的监听。其中 observeDeep 监听协同数据本身和其孩子的更新,observe 监听协同数据本身的更新。下面是observeobserveDeep的类型定义。当协同数据更新时,会触发监听的回调函数,回调函数会通过更新事件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-webrtcy-websockety-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 中使用的协同数据类型包括了 YMapYArrayYText 三种。应用数据 跟据不同的应用有不同的形式。在 slate 内部,提供了两种类型的应用数据 ElementText,使用下面的类型定义:

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/TextYMap 对应;Element.childrenYArray 对应;Text.textYText 对应。

对于 应用数据协同数据 的绑定,忽略掉 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_nodeinsert_node原子操作;YText 的操作对应 remove_textinsert_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 作为通信协议,下面的讲述会把通信的过程也加上。

发送周期:

  1. codemirror: 插入文本
  2. y-codemirror: 监听了 codemirror 的 change 事件 codeMirror.on('changes', targetObserver)。在 targetObserver 回调中,会使协同数据做相应的操作 YText.insertText
  3. yjs: insertText 产生一个 Item,并 通过 Item.integrate() 将该 item 集成到本地副本中。在集成的过程中,可能会产生并解决冲突。
  4. yjs: 在 步骤 3 的执行过程中,会调用 cleanupTransactions 函数。该函数会将产生的更新通过doc.emit('update')发送出去。在这个过程中,会对更新数据进行编码,以最小化发送的消息。
  5. y-websocket:监听了 doc 的 update 事件doc.on('update', updateHandler),在 updateHandler 回调中,会将 更新消息 广播至全部副本。

接收周期:

  1. y-websocket:websocket.onmessage 接收到更新消息。并通过 readMessage => messageHandlers[messageSync] => syncProtocol.readSyncMessage => readUpdate 进行消息读取。
  2. y-protools: 在 readSyncMessage 中,会 根据 messageType 的不同调用不同的 读取函数。最终会调用 Y.applyUpdate 将收到的更新信息集成到本副本的文档中。
  3. yjs: applyUpdate => readUpdateV2
  • 3.1:readUpdateV2.readClientsStructRefs 将读取到的更新数据构造一个新的 Item
  • 3.2:readUpdateV2.integrateStructs: 将新 Item 集成到本地的 Item 链表中。这个过程中,对应的协同数据YText会发生改变,同时会转成对应的更新事件。
  • 3.3:readUpdateV2 => cleanupTransactions: readUpdateV2 包裹在 transact 中,最终会调用 cleanupTransactions,这里会遍历发生变化的 Item transaction.changed.forEach => itemtype._callObserver(transaction, subs) => callTypeObservers,最终将触发 发生变化的 YText 的监听回调。
  1. y-codemirror: 监听了 YText 的变化。在回调中,将对应的变化应用到 codemirror。
  2. codemirror: 执行插入操作。