前言
思来想去,还是决定深入了解Yjs的实现原理,并摒弃Yjs原生支持,尝试应用于其他项目上;大家跟着我的思路去思考,相信大家一定会对协同编辑有一个深刻的认识,以后遇到类似场景,也能自己实现协同功能。
Yjs Docs
Yjs 的应用
我们将视野放大,先看一下Yjs的应用场景,才能看出其强大之处:
协同建模
Markdown 编辑器
协同代码编辑器
会议协同
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实现协同
了解了底层实现原理后,我们就尝试自己实现其他应用的协同【本次实现流程图的协同,其他应用协同原理类似哈】
上面是官网哈,具体的安装我就不赘述了,看着官网实现即可。
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 简单实现了其协同。大致的协同实现都有类似的思想,大家以后需要协同的场景,希望也能自行开发。