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

前言

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

Yjs Docs

"Introduction - Yjs Docs"

Yjs 的应用

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

协同建模

Vertex Collaboration

Markdown 编辑器

协同代码编辑器

会议协同

coroom.app/?ref=room.s...

Yjs APIs

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

Yjs是一种高性能的基于CRDT算法,用于构建自动同步的协作应用程序。我们上一篇文章quill的协同文章"Yjs + Quill 实现文档多人协同编辑器开发(基础+实战)_quill文档-CSDN博客" ,只是yjs的原生支持方式,因此,我们会深入分析yjs的原理,并尝试应用于其他项目。 这是基础的yjs代码,现在看不懂没关系,通过我们的学习,后面再回来看,就看得懂了。

js 复制代码
    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

js 复制代码
    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))

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

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

Y.Map

dart 复制代码
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 事件回调。

js 复制代码
    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。

ymap.unobserve(function)

卸载观察器。

Y.Array

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

Y.Text

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

js 复制代码
    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 }}]
js 复制代码
    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

js 复制代码
    // 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

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

javascript 复制代码
// 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 的使用。

csharp 复制代码
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 上:

wsProvider.disconnect()

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

Node 实现 y-websocket 服务

js 复制代码
    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 后就可以实现数据协同了,源码中:

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

执行应用更新操作:

这样,用户A发起的 协同,所有用户 applyUpdate 后,就都是最新的协同数据了。理论上来说,其他用户执行了applyupdate 后,又会引起 ydoc.update 事件,重新发 ws 导致死循环:

因此,需要在update 中判断 origin对象哈!这便是y-websocket的所有底层原理。

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

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

Documentation · LogicFlow

上面是官网哈,具体的安装我就不赘述了,看着官网实现即可。

js 复制代码
    // 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 实现挂载:

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

配置空面板

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

    <template>
      <div class="box"></div>
    </template>

    <script setup>
    import LogicFlow from "@logicflow/core";
    import { onMounted, reactive } from "vue";

    // lf
    let lf = reactive();

    onMounted(() => {
      lf = new LogicFlow({
        container: document.querySelector(".box"),
        grid: true,
      });
      lf.render({}); // 这个必须调一下
    });
    </script>

初始化协同

js 复制代码
    /** 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,详细的知识这里不说了,重点是协同的实现:

js 复制代码
    <template>
      <div class="main">
        <el-button class="add" @click="addNode">添加Node</el-button>
        <el-drawer v-model="visible" title="添加节点信息">
          <el-form :model="form" style="max-width: 460px">
            <el-form-item label="节点类型">
              <el-select v-model="form.type" class="m-2" placeholder="Select">
                <el-option label="矩形" value="rect" />
                <el-option label="圆形" value="circle" />
                <el-option label="椭圆" value="ellipse" />
                <el-option label="多边形" value="polygon" />
              </el-select>
            </el-form-item>
            <el-form-item label="节点X坐标">
              <el-input v-model="form.x" />
            </el-form-item>
            <el-form-item label="节点Y坐标">
              <el-input v-model="form.y" />
            </el-form-item>
            <el-form-item label="节点ID">
              <el-input v-model="form.id" />
            </el-form-item>
            <el-form-item label="节点文本">
              <el-input v-model="form.text" />
            </el-form-item>
            <el-form-item label="">
              <el-button @click="comfirm" type="primary">确认</el-button>
            </el-form-item>
          </el-form>
        </el-drawer>

        <div class="box"></div>
      </div>
    </template>

    <script setup>
    import LogicFlow from "@logicflow/core";
    import { onMounted, reactive } from "vue";
    import { myYjs } from "./utils/Yjs";
    import { useNode } from "./hooks/useNode";

    // lf
    let lf = reactive();

    let { visible, form, addNode } = useNode(lf);

    // yjs
    let yjs = new myYjs();

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

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

    <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
    }
    .box {
      height: calc(100vh - 50px);
      width: 100vw;
      overflow: hidden;
    }
    .add {
      left: 0;
      top: 0;
    }
    </style>

useNode.js (hook)

js 复制代码
   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 };
   };

实现的大致效果:

实现协同

确认的时候,设置map数据

ini 复制代码
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。

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

js 复制代码
       this.ymap.observe((data) => {
          console.log(data);
        });
js 复制代码
     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 提供的能力

js 复制代码
    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

js 复制代码
    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 做一致性处理,并通过ws 服务转发到其他客户端,其他客户端监听到变化,要复制相同的操作。

实现位置移动

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

YjsHandle.js

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)。

html 复制代码
     <!-- 实现光标 -->
        <div>
          <el-icon style="transform: rotateY(180deg)"><Position /></el-icon>
        </div>

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

Luckysheet协同原理分析

这种协同的模式可以说非常常见,luckysheet源码中,用户的每次操作,都会映射一次 保存,而在保存的逻辑中,则实现了发送数据到服务器

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

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

协同编辑模型图

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

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

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

总结

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

相关推荐
丁总学Java15 分钟前
微信小程序-npm支持-如何使用npm包
前端·微信小程序·npm·node.js
It'sMyGo24 分钟前
Javascript数组研究09_Array.prototype[Symbol.unscopables]
开发语言·javascript·原型模式
懒羊羊大王呀25 分钟前
CSS——属性值计算
前端·css
李是啥也不会40 分钟前
数组的概念
javascript
无咎.lsy1 小时前
vue之vuex的使用及举例
前端·javascript·vue.js
fishmemory7sec1 小时前
Electron 主进程与渲染进程、预加载preload.js
前端·javascript·electron
fishmemory7sec1 小时前
Electron 使⽤ electron-builder 打包应用
前端·javascript·electron
豆豆2 小时前
为什么用PageAdmin CMS建设网站?
服务器·开发语言·前端·php·软件构建
JUNAI_Strive_ving2 小时前
番茄小说逆向爬取
javascript·python
看到请催我学习2 小时前
如何实现两个标签页之间的通信
javascript·css·typescript·node.js·html5