脑图(思维导图后续统称为脑图)的数据结构可类比为一棵树。 其从属关系有:
- root (根节点)
- children(孩子节点)
- parent (父亲节点)
- id (自身节点 id,当前脑图唯一标识)
- data (节点信息)
对于每个节点自身含有一个 data
属性。表明这个节点自身的数据,如果希望少一层嵌套,那么我们可以直接在节点上挂载相应的内容(节点文本内容、节点是否有特殊的图标、节点是否有超链接等)。
在运行时期间,我们可能需要一些辅助类的字段。比如是否绘制在页面上,节点是否收起或者展开。
对于是否收起或展开,如果需要做持久化,那么该字段是需要被记录下来的。 所以一个简洁的脑图的数据结构大概是这样的。
type Node = {
id: string,
children: Node[],
root: Node,
parent: Node,
expanded: boolean,
topic: string, //文本内容
}
假设我们要持久化存储,我们应该怎么保存数据呢?
以服务器为例,显然,最通用的就是JSON
数据格式。然而,这种从属关系存在相互引用的问题,对于单纯的脑图数据是不能简单的导出的。我们需要遍历这棵树,获取相应的纯数据。
在实际的数据存储中,我们可以在遍历过程中,将其平铺成一个数组,保存节点的 id 和 parentid。下次导入时可以根据 id 和 parentid 再次生成树的对应关系。
但如果保存的是嵌套的结构,我们就可以用 children 属性来取代 id 和 parentid 字段。因为嵌套结构本身就表明了节点之间的对应关系。
以下是两种数据结构的简单对比,其中简化了 data 属性,由 topic 来表示一个节点的文本内容。
{
topic: 'root',
children: [
{
topic: 'child1',
children: [
topic: 'child1-1'
]
},
{
topic: 'child2',
}
]
}
[
{id: 'root', topic: 'root', parentid: 'null'},
{id: 'child1', topic: 'child1', parentid: 'root'},
{id: 'child1-1', topic: 'child1-1', parentid: 'child1'},
{id: 'child2', topic: 'child2', parentid: 'child2'},
]
对比平铺结构和嵌套结构,我们会发现嵌套结构,少了 id 和 parentid 两个字段。 而针对平铺结构的数组,额外要求我们以深度优先的方式导出,否则在还原数据结构时需要通过多次遍历处理额外的节点关系。
运行时的数据结构非常灵活,因为这会涉及到节点的布局算法,以及一些性能优化的标记,类似 react-fiber 的数据结构,假设我们的脑图实现不依赖任何框架,那么渲染的性能优化自然是需要脑图的开发者来实现(读者也可以思考不依赖框架时,怎样的运行时数据结构可以帮助性能提升)。此外,可能还要考虑到布局的 UI 呈现,比如 1 级主题字体比 2级主题的字体要大,那么节点还需要明确的层级字段等用于渲染节点的业务逻辑。
再举一个例子:用户点击了一个节点,即进行选中节点操作,那么我们是应该给 Node 添加 isSelected 字段,还是外层再维护一个 Set 结构存储选中的 id,在渲染节点时进行比对判断节点是否选中?
针对这种场景,每个节点是否选中都由外层动态比对得出,Node 本身不持有该字段。
而对于节点收起展开,我们则是需要保存到节点自身的数据集中的。
针对数据存在哪里,没有明确的规则,视实际情况而定。以下原则可以作为参考:
- 该数据是否需要持久化(如有,则优先考虑存储在节点自身)
- 该数据描述相对于脑图全局而言则优先考虑存储外部
- 该数据描述节点自身特性且随着操作频繁更新(优先考虑存储在节点内部)
减小数据体积的方式主要从两方面下手,一是字段压缩,另外可以对 id 生成长度做调整。
为了进一步降低服务端的数据存储,我们可以借助时间换空间的一些基础概念。存储的时候可以将字段压缩一次, 比如将 topic 改为 tp,将 children 简写成 ch。当加载脑图数据时,需要在对应解析数据的时机转换成运行时需要的数据结构。
我们可以给脑图全局设置一个初始 id,假设是 0,当新建节点后,将该 id 赋值给节点,并全局 id 累加。 这样的好处是节点 id 起始数据会比较小,而且生成方式非常简单。但它不够稳定,比如当页面再次刷新之后全局变量就清空。
一般我们会采用 uuid 的方式生成,如果考虑到实际的使用情况,可以适当限制 uuid 生成后的个数。 比如当前架构支持的节点树是万级,那么 6 位数的 id 足矣,再适当考虑扩容将 id 位数增加。
其实 id 在持久化时不需要也可以(如果使用嵌套数据结构的话),但当遇到需要协同编辑的场景,id 的作用就尤为突出。协作者之间需要传递节点 id 数据表示彼此都在哪些节点操作。
扩展字段是为了支持更多的特性,比如前文提到的脑图的备注信息、或者是超链接。数据库中的字段扩展,要考虑原先的字段的数据类型,扩展字段的默认数据类型,性能等。脑图的数据扩展和数据库的扩展字段类似,但会有所简化。通常脑图会作为 json 文件存储在数据库的某个字段中。至于脑图内的 json 文件需要考虑哪些字段,数据库并不需要关心。因此,对于脑图而言,字段的扩展非常灵活,本质自上是 key-value 的形式进行扩展。
在实际开发过程中会遇到一个难点是:如何兼容好新旧版本的数据在同一套代码下展示以及同步。
比如,在 1.0 版本中,我们只保存核心的字段。其中 width 和 height 是运行时的 UI 数据,不需要保存。
// 获取纯数据
function pickCoreDataV1(data) {
const readyToSave = {
id,
parentid,
topic
} = data
return readyToSave
}
const data = {id: 'id-1', parentid:'root', topic: '一级主题', width: 100, height: 50}
const saveData = pickCoreDataV1(data)
// 持久化,比如:发送脑图数据到服务端
fetch('url', {body: JSON.stringify(saveData)})
假设我们在 2.0 版本中,新增了 remark 字段表示节点的备注信息。 接着用户又在 1.0 版本中打开了并保存该脑图。
const data = {id: 'id-1', parentid:'root', topic: '一级主题', remark: '节点备注信息', width: 100, height: 50}
pickCoreDataV1(data)
fetch('url', {body: JSON.stringify(saveData)})
我们会发现,在 1.0 版本中存储的数据是没有 remark 的。当下次用户再用 2.0 版本脑图打开时会发现节点丢失备注信息。
问题出现在我们的 pickCoreDataV1 里,它只提取了必要的数据存储到服务端。而面对未来字段扩展的不确定性,我们是无法预测到底可能后续会新增哪些字段。
所以,这里真正的解决方案的思想是过滤掉不必要的字段,而非提取必要字段。
const filterUnusedData = (data) => {
const { width, height, ...rest } = data
return rest
}
这里用 spread 操作符,就能解决。
除了节点数据以外,脑图还有很大的曲线数据。面向脑图内部操作的撤销回退,我们也需要考虑额外的数据存储。面向多种主题和多种布局方式,我们也需要考虑相应的数据存储。脱离脑图本身,把脑图当做文本内容,需要有版本号(脑图自身的版本号以及当前脑图数据相对于服务器而言的版本号)。
假设,保存到服务器的数据因为同步问题丢失,我们还需要考虑根据某个服务器版本回退脑图数据,这些可能脱离了脑图本身,但却又是实实在在的数据。这部分内容会由专业的服务端同事来处理,但脑图的开发者也应该知晓当多端数据同步时造成脑图节点数据异常大致的处理方式。