大家好,欢迎来到IT知识分享网。
前言
思来想去,还是决定深入了解Yjs的实现原理,并摒弃Yjs原生支持,尝试应用于其他项目上;大家跟着我的思路去思考,相信大家一定会对协同编辑有一个深刻的认识,以后遇到类似场景,也能自己实现协同功能。
Yjs Docs
Introduction – Yjs DocsModular building blocks for building collaborative applications like Google Docs and Figma.https://docs.yjs.dev/
Yjs 的应用
我们将视野放大,先看一下Yjs的应用场景,才能看出其强大之处:
协同建模
Vertex Collaboration
Markdown 编辑器
协同代码编辑器
会议协同
https://coroom.app/?ref=room.sh
Yjs APIs
从上应用不难看出,yjs在各个协同领域都有着非常广泛、成熟的应用,因此,学习并掌握yjs的原理,对我们实际项目的协同场景非常有帮助。
Yjs是一种高性能的基于CRDT算法,用于构建自动同步的协作应用程序。我们上一篇文章quill的协同,只是yjs的原生支持方式,因此,我们会深入分析yjs的原理,并尝试应用于其他项目。
深度解析 Yjs 协同编辑原理【看这篇就够了】 这是基础的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))
收听共享文档上的最新消息。只要所有更新消息都传播给所有用户,每个人最终都会统一相同的状态。
事件的回调也是有顺序的:
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。
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 上:
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 后就可以实现数据协同了,源码中:
this.doc.on(‘update’)这个事件我们应该不陌生嘛,监听 ydoc 文档的更新,然后广播事件,各客户端监听到message 事件后,进行消息处理:
执行应用更新操作:
这样,用户A发起的 协同,所有用户 applyUpdate 后,就都是最新的协同数据了。理论上来说,其他用户执行了applyupdate 后,又会引起 ydoc.update 事件,重新发 ws 导致死循环:
因此,需要在update 中判断 origin对象哈!这便是y-websocket的所有底层原理。
Logic Flow 流程图应用Yjs实现协同
了解了底层实现原理后,我们就尝试自己实现其他应用的协同【本次实现流程图的协同,其他应用协同原理类似哈】
Documentation · LogicFlowhttps://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 实现挂载:
能出现这个图表示正确了!
配置空面板
有的流程图是不能一开始就有节点的,因此我们配置空面板:
<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>
初始化协同
/ 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,详细的知识这里不说了,重点是协同的实现:
<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)
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数据
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 的哈,别忘了
this.ymap.observe((data) => { console.log(data); });
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 做一致性处理,并通过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)。
<!-- 实现光标 --> <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 简单实现了其协同。大致的协同实现都有类似的思想,大家以后需要协同的场景,希望也能自行开发。
免责声明:本站所有文章内容,图片,视频等均是来源于用户投稿和互联网及文摘转载整编而成,不代表本站观点,不承担相关法律责任。其著作权各归其原作者或其出版社所有。如发现本站有涉嫌抄袭侵权/违法违规的内容,侵犯到您的权益,请在线联系站长,一经查实,本站将立刻删除。 本文来自网络,若有侵权,请联系删除,如若转载,请注明出处:https://yundeesoft.com/142324.html