两天实现思维导图的协同编辑?用Yjs真的可以

最近使用 Yjs 给自己开源的一个思维导图加上了协同编辑的功能,得益于该框架的强大,一直觉得很复杂的协同编辑能力没想到实现起来异常的简单,所以通过本文来安利给各位。

要实现协同编辑,目前主要有两种算法,一是 OT(Operational Transformation) ,二是 CRDT(Conflict-free Replicated Data Type) ,目前用的更多的是 OT ,它需要通过服务端来处理冲突,并将处理后的数据发送到各个端进行同步,CRDT 也支持这种模式,另外还支持直接在客户端处理冲突,然后通过点对点通信同步到其他客户端。

OT 是对编辑的数据操作进行转换,所以 OT 算法的实现依赖于编辑器数据模型的设计,不同的数据模型需要实现不同的操作转换算法。而 CRDT 本质是数据结构,通过数据结构的设计保证并发操作数据的最终一致性。所以只要将你的数据结构转换成它的数据结构即可帮你处理冲突和同步,在收到同步后的数据再转换回你的数据结构最后更新你的编辑器即可。相对而言,使用 CRDT 实现会更简单一点。

关于 OTCRDT 更详细的原理我也不会,各位可以搜索一下相关的文章,接下来看一下我是如何通过 Yjs 实现协同编辑的,先来看一下最终效果:

3.gif

安装

首先安装Yjs

npm i yjs

另外Yjs提供了一些网络同步的库,比如通过websocketwebrtc等等,详细介绍可以查看这个文档Connection Provider。每个库除了提供客户端的js npm包外,还提供了对应的服务端Nodejs的实现代码供你参考和测试,可以说是非常贴心了。我使用的是webrtc方式:

npm i y-webrtc

依赖就是这两个,接下来进行实例化:

import * as Y from 'yjs'
import { WebrtcProvider } from 'y-webrtc'

class Cooperate {
  constructor(opt) {
      // 思维导图应用实例
      this.mindMap = opt.mindMap
      // Yjs文档实例
      this.ydoc = new Y.Doc()
      // 网络连接实例
      this.provider = new WebrtcProvider('房间名称', this.ydoc, {
          signaling: ['http:ip:port']// webrtc的信令服务器
      })
  }
}

Yjs暴露给我们使用的主要是一些共享类型的数据Shared Types,比如Y.mapY.arrayY.text,使用起来就和jsmaparray对象基本是一样的,非常简单,具体使用哪种需要根据你的数据结构来决定。

Doc实例就是用来承载这些共享数据的容器。

只要实例化网络同步库时传入Doc实例,就能实现不同客户端的数据同步了,webrtc是需要通过服务端来传递信令数据的,所以需要传入信令服务器的地址。

编辑数据

我的思维导图数据结构本质就是一棵树:

{
    data: {
        text: 'xxx',
        uid: 'xxx',
        other: 'xxx'
    },
    children: [
        {
            data: {
                text: 'xxx',
                uid: 'xxx',
                other: 'xxx'
            },
            children: []
        }
    ]
}

但是Yjs并没有提供树结构的共享类型,那么怎么办呢,很简单,转换一下就好了,我们可以将树结构转换成如下结构的map类型:

{
    uid: {
        data: {
            text: 'xxx',
            uid: 'xxx',
            other: 'xxx'
        },
        children: ['uid1', 'uid2'],
    },
    uid2: {
        data: {
            text: 'xxx',
            uid: 'xxx',
            other: 'xxx'
        },
        children: [],
    }
}

通过uid来关联节点数据,children中只保存子节点的uid

转换也不难,相信对于算法都很强的各位来说是分分钟的事情,而我算法很拉,只能写出以下方法:

class Cooperate {
    // 树结构转平级对象
    transformTreeDataToObject(data) {
        const res = {}
        const walk = (root, parent) => {
            const uid = root.data.uid
            // 将自己的id添加到父节点的children属性中
            if (parent) {
                parent.children.push(uid)
            }
            // 以uid为key添加到对象上
            res[uid] = {
                isRoot: !parent,
                data: {
                    ...root.data
                },
                children: []
            }
            // 遍历子节点,同时把自己传进去
            if (root.children && root.children.length > 0) {
                root.children.forEach(item => {
                    walk(item, res[uid])
                })
            }
        }
        walk(data, null)
        return res
    }
}

这样我们就可以使用Y.map类型的数据了,创建一下实例:

class Cooperate {
    constructor(opt) {
        this.mindMap = opt.mindMap
        this.ydoc = new Y.Doc()
        // 共享数据
        this.ymap = this.ydoc.getMap()
        // 监听共享数据改变
        this.ymap.observe(this.onObserve)
    }

    onObserve() {
        // todo
    }
}

可以通过observe方法监听共享数据的修改,这样当我们调用ymapymap.setymap.delete等方法修改数据后就可以监听到改变了,因为我们实例化了WebrtcProvider的网络同步实例,所以其他客户端也能监听到你所做的修改,就是这么简单。

首先需要将初始思维导图数据同步到ymap中:

class Cooperate {
    constructor(opt) {
        // ...
        // 思维导图树结构转平级对象结构
        this.currentData = this.transformTreeDataToObject(data)
        // 将思维导图数据添加到共享数据中
        Object.keys(this.currentData).forEach(uid => {
          this.ymap.set(uid, this.currentData[uid])
        })
    }
}

遍历转换后的对象调用ymap.set方法添加到ymap数据中即可。

然后思维导图数据有变动后会发送事件,所以可以在这个事件回调里找出更新点更新ymap数据:

class Cooperate {
    constructor(opt) {
        // ...
        this.mindMap.on('data_change', (data) => {
            // 更新后的思维导图数据同样转换对象结构
            const newData = this.transformTreeDataToObject(data)
            // 上一次的思维导图数据
            const oldData = this.currentData
            this.currentData = newData
            // 在transact方法中多次修改ymap只会触发一次事件
            this.ydoc.transact(() => {
                // 找出新增的或修改的思维导图节点
                Object.keys(newData).forEach(uid => {
                    // 新增的或已经存在的,如果数据发生了改变
                    if (!oldData[uid] || !isSameObject(oldData[uid], newData[uid])) {
                        this.ymap.set(uid, newData[uid])
                    }
                })
                // 找出删除的思维导图节点
                Object.keys(oldData).forEach(uid => {
                    if (!newData[uid]) {
                        this.ymap.delete(uid)
                    }
                })
            })
        })
    }
}

逻辑很简单,就是比对当前和上一次的数据,找出更新的思维导图节点,然后同步到ymap数据中即可,这样就会触发自己和其他客户端的observe事件,在该事件的回调中能拿到Yjs帮我们处理完冲突后的数据,我们再更新思维导图即可:

class Cooperate {
    onObserve(event) {
        // 获取到当前同步后的数据
        const data = event.target.toJSON()
        // 如果数据没有改变直接返回
        if (isSameObject(data, this.currentData)) return
        this.currentData = data
        // 平级对象转树结构
        const res = this.transformObjectToTreeData(data)
        if (!res) return
        // 更新思维导图画布
        this.mindMap.renderer.setData(res)
        this.mindMap.render()
    }
}

获取到同步后的最新数据,先和当前的数据对比一下,因为前面说了也会触发自己客户端的observe事件,防止没有必要的更新。

然后将对象结构再转换回思维导图需要的树结构,最后调用相关方法更新思维导图画布即可实现同步更新。

同样贴一下对象转树结构的方法:

class Cooperate {
    // 将平级对象转树结构
    transformObjectToTreeData(data) {
        const uids = Object.keys(data)
        if (uids.length <= 0) return null
        // 找出根节点的uid
        const rootKey = uids.find(uid => {
            return data[uid].isRoot
        })
        // 根节点不存在直接返回
        if (!rootKey || !data[rootKey]) return null
        // 根节点
        const res = {
            data: data[rootKey].data,
            children: []
        }
        const map = {}
        map[rootKey] = res
        // 遍历所有uid
        uids.forEach(uid => {
            // 找出父节点的uid
            const parentUid = this.findParentUid(data, uid)
            // 当前节点的数据
            const cur = data[uid]
            // 如果已经添加到了缓存对象上,那么直接使用缓存的数据即可
            // 否则需要进行缓存
            const node = map[uid] || {
                data: cur.data,
                children: []
            }
            if (!map[uid]) {
                map[uid] = node
            }
            // 如果存在父节点
            if (parentUid) {
                // 找出当前节点在兄弟节点中的索引
                const index = data[parentUid].children.findIndex(item => {
                    return item === uid
                })
                // 如果还没遍历到父节点,也就是父节点还没添加到缓存对象上,那么直接帮父节点进行缓存
                if (!map[parentUid]) {
                    map[parentUid] = {
                        data: data[parentUid].data,
                        children: []
                    }
                }
                // 将自己添加到父节点的子节点的指定位置
                map[parentUid].children[index] = node
            }
        })
        return res
    }

    // 找到父节点的uid
    findParentUid(data, targetUid) {
        const uids = Object.keys(data)
        let res = ''
        uids.forEach(uid => {
            const children = data[uid].children
            const isParent =
                  children.findIndex(childUid => {
                      return childUid === targetUid
                  }) !== -1
            if (isParent) {
                res = uid
            }
        })
        return res
    }
}

到这里,编辑数据的协同处理就已经结束了,是不是so easy。

感知数据

所谓感知数据就是用来显示其他协作人员的信息,一般就是其他人员当前的光标位置及对应的名字或头像,主要是用来提示当前这里谁在编辑,你就不要过来了,虽说冲突可以被处理掉,但是实际上大多数时候的协同编辑都是大家一起编辑一个文档不同的部分,而不是一起互相制造冲突,那样可能会打起来,效率反而低了。

感知数据完全可以你自己来传输,但是Yjs也提供了这个能力,每个Connection Provider都支持传输感知数据,使用起来同样非常简单。

对于思维导图场景,显示其他协作者的实时鼠标位置其实没有必要,因为大多数操作都是要在选中节点的情况下进行的,所以只要在激活的节点上显示激活该节点的协作人员信息即可,同样有相关的事件可以监听:

class Cooperate {
    constructor(opt) {
        // ...
        // provider提供的感知数据处理对象
        this.awareness = this.provider.awareness
        // 监听思维导图的节点激活事件
        this.mindMap.on('node_active', (node, nodeList) => {
            // 调用setLocalStateField方法设置或更新感知状态数据
            this.awareness.setLocalStateField(this.userInfo.name, {
                // 用户信息
                userInfo: {
                    ...this.userInfo
                },
                // 当前激活的节点uid列表
                nodeIdList: nodeList.map(item => {
                    return item.uid
                })
            })
        })
    }
}

可以通过awareness属性获取Connection Provider提供的感知数据处理对象,然后在节点的激活事件回调函数中设置或更新协作人员激活的节点列表,同样,awareness也提供了监听其他协作者感知数据改变的方法:

class Cooperate {
    constructor(opt) {
        // ...
        this.awareness.on('change', () => {
            const walk = (list, callback) => {
                list.forEach(value => {
                    const userName = Object.keys(value)[0]
                    if (!userName) return
                    const data = value[userName]
                    const userInfo = data.userInfo
                    const nodeIdList = data.nodeIdList
                    // 遍历协作人员激活的节点uid列表
                    nodeIdList.forEach(uid => {
                        // 通过uid找到节点实例
                        const node = this.mindMap.renderer.findNodeByUid(uid)
                        if (node) {
                            callback(node, userInfo)
                        }
                    })
                })
            }
            // 清除之前的数据
            walk(this.currentAwarenessData, (node, userInfo) => {
                node.removeUser(userInfo)
            })
            // 设置当前数据
            const data = Array.from(this.awareness.getStates().values())
            this.currentAwarenessData = data
            walk(data, (node, userInfo) => {
                // 不显示自己
                if (userInfo.id === this.userInfo.id) return
                // 在节点上方显示当前操作的人员的头像
                node.addUser(userInfo)
            })
        })
    }
}

逻辑同样很简单清晰,在感知数据改变后先清除画布上当前的信息,然后再根据新信息进行渲染。

到这里,给一个思维导图添加基本的协同编辑能力就完成了。

总结

本文详细介绍了我是如何使用Yjs给一个思维导图加上协同编辑的能力,可以看到使用Yjs实现协同编辑整体逻辑是非常简单清晰的,对于原有代码逻辑的入侵也非常小,只要做一下数据结构的转换工作和感知数据的渲染即可,所以Yjs非常适合个人开发者或小团队。

当然以上只是个人的最简单实践,可能会存在一些问题,日后如果遇到了再来分享。

最后,欢迎各位关注一下我开源的思维导图:simple-mind-map。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/115543.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

关于Intel Press出版的《Bedyong BIOS》第2版的观后感

文章目录 此书的背景UEFI运行时DXE基础CPU架构协议PCI协议UEFI驱动的初始化串口DXE驱动示例 《Beyond BIOS》首先介绍一个简单的UEFI应用程序模块&#xff0c;用于展示UEFI应用程序的行为。作者为Waldo。该模块名为“InitializeHelloApplication”&#xff0c;它接受两个参数&a…

【Mysql】Mysql中表连接的原理

连接简介 在实际工作中&#xff0c;我们需要查询的数据很可能不是放在一张表中&#xff0c;而是需要同时从多张表中获取。下面我们以简单的两张表为例来进行说明。 连接的本质 为方便测试说明&#xff0c;&#xff0c;先创建两个简单的表并给它们填充一点数据&#xff1a; …

保障效率与可用,分析Kafka的消费者组与Rebalance机制

系列文章目录 上手第一关&#xff0c;手把手教你安装kafka与可视化工具kafka-eagle Kafka是什么&#xff0c;以及如何使用SpringBoot对接Kafka 架构必备能力——kafka的选型对比及应用场景 Kafka存取原理与实现分析&#xff0c;打破面试难关 防止消息丢失与消息重复——Kafka可…

react+canvas实现横跨整个页面的动态的波浪线(贝塞尔曲线)

本来写这个特效 我打算用css实现的&#xff0c;结果是一波三折&#xff0c;我太难了&#xff0c;最终没能用css实现&#xff0c;转战了canvas来实现。来吧先看效果图 当然这个图的波浪高度、频率、位置、速度都是可调的&#xff0c;请根据自己的需求调整&#xff0c;如果你讲波…

windows10系统-17-文献管理软件

参考诸多文献管理软件的优劣比较如何&#xff1f;你有哪些使用心得&#xff1f; 参考我愿称之为目前最好用的文献管理和阅读软件&#xff01;readpaper 1 文献总结 文献总结是非常重要的一项技能&#xff0c;不知道大家看完文献后有没有总结文献的习惯&#xff0c;有的话那挺…

数据仓库-拉链表

在数据仓库中制作拉链表&#xff0c;可以按照以下步骤进行&#xff1a; 确定需求&#xff1a;首先明确需要使用拉链表的场景和需求。例如&#xff0c;可能需要记录历史数据的变化&#xff0c;以便进行时间序列分析等。设计表结构&#xff1a;在数据仓库中&#xff0c;拉链表通…

Web自动化测试 —— PageObject设计模式!

一、page object 模式简介 1.1、传统 UI 自动化的问题 无法适应 UI 频繁变化无法清晰表达业务用例场景大量的样板代码 driver/find/click 二、page object 设计原则 2.1、POM 模式的优势 降低 UI 变化导致的测试用例脆弱性问题让用例清晰明朗&#xff0c;与具体实现无关 2.…

JVM常用命令

jps —查看pid jstat -gcutil 4364 1000 2000 —查看堆内存占用百分比&#xff0c;每秒打印1次&#xff0c;总共打印2000次 S0&#xff1a;幸存1区当前使用比例 S1&#xff1a;幸存2区当前使用比例 E&#xff1a;伊甸园区使用比例 O&#xff1a;老年代使用比例 M&#xff1a;元…

服务Service

一、服务概述 Service(服务)是Android四大组件之一&#xff0c;是能够在后台长时间执行操作并且不是供用户界面的应用程序组件。Senice可以与其他组件进行交互&#xff0c;一般由Activity启动&#xff0c;但是并不依赖于Activity。当Activity的生命周期结束时&#xff0c;Serv…

野火霸天虎 STM32F407 学习笔记_1 stm32介绍;调试方法介绍

STM32入门——基于野火 F407 霸天虎课程学习 前言 博主开始探索嵌入式以来&#xff0c;其实很早就开始玩 stm32 了。但是学了一段时间之后总是感觉还是很没有头绪&#xff0c;不知道在学什么。前前后后分别尝试了江协科技、正点原子、野火霸天虎三次 stm32 的课程学习。江协科…

学Python,一个月从小白到大神?看你怎么学!

Python是一门超强大而且超受欢迎的编程语言。它被用在各种领域&#xff0c;比如网站开发、数据分析、人工智能和机器学习。学会Python会给你创造很多职业机会&#xff0c;所以绝对是值得一试的。 但你有没有过这样的梦想&#xff1a;一个月时间&#xff0c;从Python小白变成Py…

Docker DeskTop安装与启动(Windows版本)

一、官网下载Docker安装包 Docker官网如下&#xff1a; Docker官网不同操作系统下载页面https://docs.docker.com/desktop/install/windows-install/ 二、安装Docker DeskTop 2.1 双击 Docker Installer.exe 以运行安装程序 2.2 安装操作 默认勾选&#xff0c;具体操作如下…

页面淘汰算法模拟实现与比较

1.实验目标 利用标准C 语言&#xff0c;编程设计与实现最佳淘汰算法、先进先出淘汰算法、最近最久未使用淘汰算法、简单 Clock 淘汰算法及改进型 Clock 淘汰算法&#xff0c;并随机发生页面访问序列开展有关算法的测试及性能比较。 2.算法描述 1. 最佳淘汰算法&#xff08;Op…

编写shell脚本,利用mysqldump实现mysql数据库分库分表备份

摘要&#xff1a;本文介绍了如何使用 Shell 脚本和 mysqldump 工具实现 MySQL 数据库的分库分表备份。通过编写脚本&#xff0c;我们可以自动化备份多个数据库以及每个数据库中的所有表&#xff0c;并将备份文件按照数据库和表的层次结构进行存储。 一、准备工作 在开始编写 Sh…

HMDD 4.0:miRNA-疾病关系数据库

拥有多项自主专利技 术和软件著作权&#xff0c;具 有丰富的数据库平台 搭建经验。 凌恩-盈飞团队 MicroRNA&#xff08;miRNA&#xff09;是一类重要的小非编码RNA&#xff0c;在疾病诊断和治疗中发挥着重要作用。人类 MicroRNA 疾病数据库 (HMDD) 为 miRNA 相关医学提供了…

【云原生基础】了解云原生,什么是云原生?

&#x1f4d1;前言 本文主要讲了云原生的基本概念和原则的文章&#xff0c;如果有什么需要改进的地方还请大佬指出⛺️ &#x1f3ac;作者简介&#xff1a;大家好&#xff0c;我是青衿&#x1f947; ☁️博客首页&#xff1a;CSDN主页放风讲故事 &#x1f304;每日一句&#x…

人工智能师求职面试笔试题及答案汇总

人工智能师求职面试笔试题及答案汇总 1.如何在Python中实现一个生成器&#xff1f; 答&#xff1a;在Python中&#xff0c;生成器是一种特殊类型的迭代器。生成器允许你在需要时才生成值&#xff0c;从而节省内存。生成器函数在Python中是通过关键字yield来实现的。例如&…

leetCode 137. 只出现一次的数字 II(拓展篇) + 模5加法器 + 真值表(数字电路)

leetCode 137. 只出现一次的数字 II 有其他的题解可看我的往期文章&#xff1a; leetCode 137. 只出现一次的数字 II 位运算 模3加法器 真值表&#xff08;数字电路&#xff09; 有限状态机-CSDN博客https://blog.csdn.net/weixin_41987016/article/details/134138112?sp…

生成带分表和水印的excel压缩文件

功能描述 将查询结果生成带分表和水印的excel压缩文件 功能点 1、将查询结果导出为excel文件 2、每个表格存放50万条数据&#xff0c;超过50万条数据&#xff0c;生成新的分表 3、生成的表格需要添加水印 4、将生成的全部分表&#xff0c;打包成zip压缩文件 引入依赖 <…

【LeetCode】每日一题 2023_11_2 环和杆(题目质量不错)

文章目录 刷题前唠嗑题目&#xff1a;环和杆题目描述代码与解题思路看看别人的题解 结语 刷题前唠嗑 今天是简单&#xff0c;我快乐了 题目&#xff1a;环和杆 题目链接&#xff1a;2103. 环和杆 题目描述 代码与解题思路 func countPoints(rings string) (ans int) {num…