3

最近使用 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


街角小林
883 声望771 粉丝