深度解析 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 简单实现了其协同。大致的协同实现都有类似的思想,大家以后需要协同的场景,希望也能自行开发。

相关推荐
AlienZHOU5 小时前
AI Coding 时代下,我的技术面试实践分享
前端·后端·面试
Captaincc9 小时前
AI用量v0.1.11更新发布 新增 jusage doctor 诊断指令 托盘展示token 和余额 新增 AutoClaw 支持
前端·后端·vibecoding
计算机魔术师10 小时前
德国Wiki被黑后两周,OpenAI终于把模型失控的账本摊开了
前端
kyriewen10 小时前
我让 AI 当面试官面了我一轮:第 3 个追问我就卡住了(附 10 道追问清单)
前端·面试·ai编程
IT_陈寒11 小时前
Python的GIL把我坑惨了,多线程跑得比单线程还慢
前端·人工智能·后端
前端snow11 小时前
ai agent --- 多agent框架之图编排引擎-langgraph
前端
竹林81811 小时前
OmniPic Studio v3.2.1 核心技术架构与全平台发版解析文档
前端·浏览器
JamesZhang8007811 小时前
页面内存只涨不跌? 一次泄漏排查, 牵出 WeakMap 的诞生
前端
Z小明11 小时前
第 6 章 组件进阶
前端·vue.js
江华森11 小时前
HTTP请求的完整过程详解:从DNS解析到TCP挥手的微秒级实战分析
前端