深度解析 Yjs 协同编辑原理【看这篇就够了】

前言

        思来想去,还是决定深入了解Yjs的实现原理,并摒弃Yjs原生支持,尝试应用于其他项目上;大家跟着我的思路去思考,相信大家一定会对协同编辑有一个深刻的认识,以后遇到类似场景,也能自己实现协同功能。

Yjs Docs

Introduction - Yjs DocsModular building blocks for building collaborative applications like Google Docs and Figma.icon-default.png?t=N7T8https://docs.yjs.dev/

Yjs 的应用

        我们将视野放大,先看一下Yjs的应用场景,才能看出其强大之处:

协同建模

Vertex Collaboration

Markdown 编辑器 

深度解析 Yjs 协同编辑原理【看这篇就够了】_第1张图片

协同代码编辑器 

深度解析 Yjs 协同编辑原理【看这篇就够了】_第2张图片 会议协同

https://coroom.app/?ref=room.sh

Yjs APIs

        从上应用不难看出,yjs在各个协同领域都有着非常广泛、成熟的应用,因此,学习并掌握yjs的原理,对我们实际项目的协同场景非常有帮助。

        Yjs是一种高性能的基于CRDT算法,用于构建自动同步的协作应用程序。我们上一篇文章quill的协同,只是yjs的原生支持方式,因此,我们会深入分析yjs的原理,并尝试应用于其他项目。

Yjs + Quill 实现文档多人协同编辑器开发(基础+实战)_quill文档-CSDN博客文章浏览阅读1.9k次,点赞9次,收藏10次。多人协同开发确实是比较难的知识点,在技术实现上有一定挑战。但随着各种技术库的发展,目前已经有了比较成熟的解决方案,而Yjs+Quill是比较简单实现多人协同编辑的开发技术_quill文档https://blog.csdn.net/weixin_47746452/article/details/132402713?spm=1001.2014.3001.5501        这是基础的yjs代码,现在看不懂没关系,通过我们的学习,后面再回来看,就看得懂了。

import * as Y from 'yjs'

// Yjs documents are collections of
// shared objects that sync automatically.
const ydoc = new Y.Doc()
// Define a shared Y.Map instance
const ymap = ydoc.getMap()
ymap.set('keyA', 'valueA')

// Create another Yjs document (simulating a remote user)
// and create some conflicting changes
const ydocRemote = new Y.Doc()
const ymapRemote = ydocRemote.getMap()
ymapRemote.set('keyB', 'valueB')

// Merge changes from remote
const update = Y.encodeStateAsUpdate(ydocRemote)
Y.applyUpdate(ydoc, update)

// Observe that the changes have merged
console.log(ymap.toJSON()) // => { keyA: 'valueA', keyB: 'valueB' }

 Y.Doc

import * as Y from 'yjs'

const doc = new Y.Doc()

        Doc会创建一个Yjs文档,用于保存共享数据,简单理解就是共享数据在网络传输的载体,里面有很多有用的属性:

doc.clientID

        标识会话的客户端的唯一id,只读属性!

doc.gc

        此单据实例上是否启用垃圾回收。将doc.gc=false设置为禁用垃圾收集并能够恢复旧内容。有关垃圾收集如何工作的更多信息,请参阅Internals - Yjs Docs。

doc.transact(function(Transaction): void [, origin:any])

        这个是做变更合并的,共享文档上的每一个更改都发生在一个事务中,在每个事务之后都会调用Observer调用和update事件。您应该将更改捆绑到单个事务中,以减少事件调用。

        即doc.transact(()=>{yarray.insert(..);ymap.set(..)})触发单个更改事件。您可以指定存储在transaction.origin和('update',(update,origin)=>..)上的可选origin参数【直译于官网】

        每个事务之后都会调用Observer调用和update事件,这句话非常非常重要!!!后面的实现原理就是基于这句话!!!

doc.on(eventName: string, function(event))

        进行事件监听,还有 once off 就不写了。

doc.on('beforeTransaction', function(tr: Transaction, doc: Y.Doc))

        事件处理程序在每次事务之前都会被调用

doc.on('beforeObserverCalls', function(tr: Transaction, doc: Y.Doc))

        事件处理程序在调用共享类型的观察程序之前立即调用

doc.on('afterTransaction', function(tr: Transaction, doc: Y.Doc))

        事件处理程序在每次事务之后立即调用

doc.on('update', function(update: Uint8Array, origin: any, doc: Y.Doc, tr: Transaction))

        收听共享文档上的最新消息。只要所有更新消息都传播给所有用户,每个人最终都会统一相同的状态。

事件的回调也是有顺序的:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第3张图片

Y.Map

import * as Y from 'yjs'

const ydoc = new Y.Doc()

// You can define a Y.Map as a top-level type or a nested type

// Method 1: Define a top-level type
const ymap = ydoc.getMap('my map type') 
// Method 2: Define Y.Map that can be included into the Yjs document
const ymapNested = new Y.Map()

// Nested types can be included as content into any other shared type
ymap.set('my nested map', ymapNested)

// Common methods
ymap.set('prop-name', 'value') // value can be anything json-encodable
ymap.get('prop-name') // => 'value'
ymap.delete('prop-name')

 ymap.doc: Y.Doc | null

        当前Map所属的doc文档,只读属性!

ymap.set(key: string, value: object|boolean|string|number|Uint8Array|Y.AbstractType)

        对分享类型 map 进行赋值操作,可以是对象、布尔值、字符串、数值型、Uint8Array、合并后的Y.Doc类型。这个比较自由,只需要 key value的形式即可。

ymap.get(key: string)

        这个对应的是取值操作,从 map 中取某个 key 的 value。

ymap.delete(key: string)

        删除某个 key value。

ymap.has(key: string)

        当前 map 是否存在某个key。

ymap 的迭代器

        ymap.entries()、ymap.values()、ymap.keys()都是迭代器,用于获取当前 map 的所有 kay value。

ymap.observe(function(YMapEvent, Transaction))

        注册一个更改观察器,每次修改此共享类型时都会同步调用该观察器。如果在observer调用中修改了此类型,则在当前事件侦听器返回后将再次调用事件侦听器。

        这个就是上面 yjs 中提到的每个事务之后都会调用Observer调用和update事件 中的 Observer 事件回调。

const ydoc = new Y.Doc();

const ymap = ydoc.getMap("my map type");

ydoc.on("update", () => {
  console.log("ydoc update");
});

ymap.observe((event) => {
  console.log("ymap observe",event);
});

ymap.set("my nested map", "ymapNested");

        上诉代码执行了一次 setMap,但是一定会引起 map 的观察器及 ydoc 的update 事件,并且是map 先监听到,ydoc 后update。

深度解析 Yjs 协同编辑原理【看这篇就够了】_第4张图片

ymap.unobserve(function)

        卸载观察器。

Y.Array

        Array 就是数组的使用方式,与 Map都是 YDoc的数据格式,这里就没什么说的,具体可以看官网的说明 【Y.Array - Yjs Docs】。

Y.Text

        而 Text 则侧重于RichText 富文本,比如常见的 markdown 数据格式(与Delta很类似):

import * as Y from 'yjs'

const ydoc = new Y.Doc()

// You can define a Y.Text as a top-level type or a nested type

// Method 1: Define a top-level type
const ytext = ydoc.getText('my text type') 
// Method 2: Define Y.Text that can be included into the Yjs document
const ytextNested = new Y.Text()

// Nested types can be included as content into any other shared type
ydoc.getMap('another shared structure').set('my nested text', ytextNested)

// Common methods
ytext.insert(0, 'abc') // insert three elements
ytext.format(1, 2, { bold: true }) // delete second element 
ytext.toString() // => 'abc'
ytext.toDelta() // => [{ insert: 'a' }, { insert: 'bc', attributes: { bold: true }}]
Quill:

{
  ops: [
    { insert: 'Gandalf', attributes: { bold: true } },
    { insert: ' the ' },
    { insert: 'Grey', attributes: { color: '#cccccc' } }
  ]
}

        至于YMap、YArray、YText 数据类型怎么选泽,如果是文本类、形式Delta数据结构的,直接用YText即可,如果是有明显的下标关系,那就用Array,如果没什么关系,就用 map。

Y.UndoManager

Y.UndoManager

        在共享类型的作用域上创建新的Y.UndoManager。如果任何指定的类型或其任何子类型被修改,UndoManager会在其堆栈上添加一个反向操作。也可以指定trackedOrigins来筛选特定的更改。默认情况下,将跟踪所有本地更改,UndoManager合并在特定captureTimeout(默认为500ms)内创建的编辑,将其设置为0可单独捕获每个更改。

undoManager.undo()

        撤销

undoManager.redo()

        重做

undoManager.stopCapturing()

        调用stopCapturing()以确保UndoManager上的下一个操作不会与上一个操作合并。

undoManager.clear()

        从撤消和重做堆栈中删除所有捕获的操作。(这个是清空操作管理器的记录哈)

undoManager.on('stack-item-added')

        监听向操作管理器添加操作

undoManager.on('stack-item-popped')

        监听向操作管理器撤销操作

Stop Capturing

// without stopCapturing
ytext.insert(0, 'a')
ytext.insert(1, 'b')
undoManager.undo()
ytext.toString() // => '' (note that 'ab' was removed)

// with stopCapturing
ytext.insert(0, 'a')
undoManager.stopCapturing() // 防止操作合并
ytext.insert(0, 'b')
undoManager.undo()
ytext.toString() // => 'a' (note that only 'b' was removed)

Awareness & Presence

        感知功能是协同系统不可或缺的部分,通过共享光标位置、状态信息,帮助用户积极协同。

// All of our network providers implement the awareness crdt
const awareness = provider.awareness

// You can observe when a user updates their awareness information
awareness.on('change', changes => {
  // Whenever somebody updates their awareness information,
  // we log all awareness information from all users.
  console.log(Array.from(awareness.getStates().values()))
})

// You can think of your own awareness information as a key-value store.
// We update our "user" field to propagate relevant user information.
awareness.setLocalStateField('user', {
  // Define a print name that should be displayed
  name: 'Emmanuelle Charpentier',
  // Define a color that should be associated to the user:
  color: '#ffb61e' // should be a hex color
})

Connection Provider

        我们主要讲解 y-websocket 的使用。

import * as Y from 'yjs'
import { WebsocketProvider } from 'y-websocket'

const doc = new Y.Doc()
const wsProvider = new WebsocketProvider('ws://localhost:1234', 'my-roomname', doc)

wsProvider.on('status', event => {
  console.log(event.status) // logs "connected" or "disconnected"
})

/** 初始化的实例 wsProvider 有相关方法供我们调用  **/

        源码中,会直接将 roomname 拼接到 ws url 上: 

深度解析 Yjs 协同编辑原理【看这篇就够了】_第5张图片

wsProvider.disconnect()

        初始化的实例有关闭连接的方法。

Node 实现 y-websocket 服务

const { WebSocketServer } = require("ws");

// 创建 yjs ws 服务
const yjsws = new WebSocketServer({ port: 8000 });

yjsws.on("connection", (conn, req) => {
  console.log(req.url); // 标识每一个连接用户,用于广播不同的文件协同
  conn.onmessage = (event) => {
    yjsws.clients.forEach((conn) => {
      conn.send(event.data);
    });
  };

  conn.on("close", (conn) => {
    console.log("yjs 用户关闭连接");
  });
});

原理分析

        创建了 y-websocket 后就可以实现数据协同了,源码中:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第6张图片

        this.doc.on('update')这个事件我们应该不陌生嘛,监听 ydoc 文档的更新,然后广播事件,各客户端监听到message 事件后,进行消息处理:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第7张图片

执行应用更新操作: 

深度解析 Yjs 协同编辑原理【看这篇就够了】_第8张图片         这样,用户A发起的 协同,所有用户 applyUpdate 后,就都是最新的协同数据了。理论上来说,其他用户执行了applyupdate 后,又会引起 ydoc.update 事件,重新发 ws 导致死循环:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第9张图片         因此,需要在update 中判断 origin对象哈!这便是y-websocket的所有底层原理。

Logic Flow 流程图应用Yjs实现协同

        了解了底层实现原理后,我们就尝试自己实现其他应用的协同【本次实现流程图的协同,其他应用协同原理类似哈】

Documentation · LogicFlowicon-default.png?t=N7T8https://site.logic-flow.cn/docs/#/        上面是官网哈,具体的安装我就不赘述了,看着官网实现即可。

// main.js
import { createApp } from "vue";
import App from "./App.vue";

// element-plus
import ElementPlus from "element-plus";
import "element-plus/dist/index.css";

// vue-flow
import "@vue-flow/core/dist/style.css";
import "@vue-flow/core/dist/theme-default.css";
import "@logicflow/core/dist/style/index.css";

createApp(App).use(ElementPlus).mount("#app");

        在 App.vue 中 照着官网的案例配置数据,在onMounted 实现挂载:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第10张图片

        能出现这个图表示正确了!

配置空面板

        有的流程图是不能一开始就有节点的,因此我们配置空面板:



初始化协同

/** Yjs 主函数 */
import * as Y from "yjs";
/** Observable 是类的事件机制: emit on once off... */
import { Observable } from "./utils/Observable";
/** Websocket 连接 */
import { WebsocketProvider } from "y-websocket";

export class myYjs extends Observable {
  constructor() {
    super(); // 实现父类

    let ydoc = new Y.Doc();

    this.ymap = ydoc.getMap();

    // 【方案二】 websocket 方式实现协同(已自己搭建 websocket 服务)
    this.provider = new WebsocketProvider("ws://localhost:8000", "demo", ydoc);

    ydoc.on("update", () => {});
  }
}

定义addNode

        使用了 hook,详细的知识这里不说了,重点是协同的实现:






 useNode.js (hook)

import { ref, reactive } from "vue";

//  节点相关 hook
export const useNode = (lf) => {
  //   定义弹窗
  let visible = ref(false);

  //   定义添加节点信息
  let form = reactive({
    type: "rect",
    x: 0,
    y: 0,
    text: "rect",
    id: "", // 自动生成
    properties: {},
  });

  //   添加按钮点击事件
  function addNode() {
    visible.value = true;
  }

  return { visible, form, addNode };
};

 实现的大致效果:深度解析 Yjs 协同编辑原理【看这篇就够了】_第11张图片

实现协同

        确认的时候,设置map数据

function comfirm() {
  form.id = Math.random().toString().split(".")[1];
  visible.value = false;
  lf.addNode(form);
  yjs.setMap("addNode", form);
}

/**
  setMap(key, value) {
    this.ymap.set(key, value);
  }
*/

        我们现在监听的是ydoc update 事件,发现更新的数据是Uint8Array 而且这个是全量更新,应用于 applyUpdate 文档的,因此我们不用这个 实现。还有一个事:第二个参数 origin 表示更新来源,我发起的是null ws转发的是websocket。 

深度解析 Yjs 协同编辑原理【看这篇就够了】_第12张图片

         使用 ymap.observe()观察器,observe还是有 origin 的哈,别忘了

   this.ymap.observe((data) => {
      console.log(data);
    });

深度解析 Yjs 协同编辑原理【看这篇就够了】_第13张图片

 

 this.ymap.observe(({ transaction, changes }) => {
      if (!transaction.origin) return; // 没有 origin 表示的是本地发起
      changes.keys.forEach((change, key) => {
        console.log(change, key);
      });
    });

        这样就拿到了更新的key ,通过 ymap.get(key)拿到value:

        回传给App.vue,这个emit 就是extends Observable 提供的能力

this.ymap.observe(({ transaction, changes }) => {
      if (!transaction.origin) return; // 没有 origin 表示的是本地发起
      changes.keys.forEach((change, key) =>
        this.emit("update", {
          change,
          key,
          value: this.ymap.get(key),
        })
      );
    });

 App.vue 监听 update

onMounted(() => {
  lf = new LogicFlow({
    container: document.querySelector(".box"),
    grid: true,
  });
  lf.render([]);

  yjs.on("update", (data) => YjsHandle(lf, data));
});

// yjsHandle.js
 
export function YjsHandle(lf, { change, key, value }) {
  switch (key) {
    case "addNode":
      lf.addNode(value);
      break;

    default:
      break;
  }
}

 实现效果:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第14张图片

         这便是协同的实现原理于应用。你的操作要通过 yjs 做一致性处理,并通过ws 服务转发到其他客户端,其他客户端监听到变化,要复制相同的操作。

实现位置移动

 lf.on("node:drag", ({ data, e }) => {
    let { x, y, id } = data;
    yjs.setMap("nodeMove", { x, y, id });
  });

 YjsHandle.js

 case "nodeMove":
      let { x, y, id } = value;
      console.log(x, y, id );
      lf.graphModel.moveNode2Coordinate(id, x, y, true);
      break;

        实现文本编辑、连线等其他事件,可以根据事件表来实现响应功能。当然,也不能每次事件都自己监听,当画布上的元素发生变化时会触发history:change事件,可以统一处理。

实现光标

        这个在 logic flow 是没有原生实现的,因此手动实现(element-plus position icon)。

 
    

深度解析 Yjs 协同编辑原理【看这篇就够了】_第15张图片

        当然还有瑕疵哈,更多细节大家自己刻画,只是提供一个思路。

Luckysheet协同原理分析

        这种协同的模式可以说非常常见,luckysheet源码中,用户的每次操作,都会映射一次 保存,而在保存的逻辑中,则实现了发送数据到服务器深度解析 Yjs 协同编辑原理【看这篇就够了】_第16张图片

        客户端接收到数据后,执行 update Message方法:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第17张图片

        而该方法就是调用原生API或者操作DOM实现页面渲染:

深度解析 Yjs 协同编辑原理【看这篇就够了】_第18张图片

协同编辑模型图

        如下图,我们分析出,协同的原理就是监听用户操作,通过websocket转发,被广播的用户需要调用相应API完成用户相同的操作。

        而Yjs在其中扮演的角色也是非常重要的,如果没有 yjs ,数据一致性得不到保证,那每个用户看到的,都可能不一样。

        中间的算法部分,有的采用 CRDT实现,有的用OT 算法实现,可别少了这个步骤哦。

深度解析 Yjs 协同编辑原理【看这篇就够了】_第19张图片

总结

       本文带大家分析了Yjs的API、y-websocket 的实现原理、Yjs的应用及底层协同模型,并使用Logic Flow 简单实现了其协同。大致的协同实现都有类似的思想,大家以后需要协同的场景,希望也能自行开发。

你可能感兴趣的:(协同编辑,CRDT应用,Yjs,Docs,Yjs,Yjs协同原理分析,Yjs应用,协同编辑原理,y-websocket,实现)