Web思维导图实现的技术点分析(附完整源码)
简介
思维导图是⼀种常见的表达发散性思维的有效⼯具,市⾯上有⾮常多的⼯具可以⽤来画思维导图,有免费的也有收费的,此外也有⼀些可以⽤来帮助快速实现的JavaScript类库,如:、。
技术选型
这种图形类的绘制⼀般有两种选择:svg和canvas,因为思维导图主要是节点与线的连接,使⽤与html⽐较接近的svg⽐较容易操作,svg的类库在试⽤了和后,有些需求在snap⾥没有到对应的⽅法,所以笔者最终选择了svgjs。
为了能跨框架使⽤,所以思维导图的主体部分作为⼀个单独的npm包来开发及发布,通过类的⽅式来组织代码,⽰例页⾯的开发使⽤的
是vue2.x全家桶。
整体思路
笔者最初的思路是先写⼀个渲染器,根据输⼊的思维导图数据,渲染成svg节点,计算好各个节点的位置,然后显⽰到画布,最后给节点连上线即可,接下来对思维导图的操作都只需要维护这份数据,数据变化了就清空画布,然后重新渲染,这种数据驱动的思想很简单,在最初的开发中也没有任何问题,⼀切都很顺利,因为模拟数据就写了四五个节点,然⽽后来当我把节点数量增加到⼏⼗个的时候,发现凉了,太卡了,点击节点激活或者展开收缩节点的时候⼀秒左右才有反应,就算只是个demo也⽆法让⼈接受。
卡的原因⼀⽅⾯是因为计算节点位置,每种布局结构最少都需要三次遍历节点树,加上⼀些计算逻辑,会⽐较耗时,另⼀⽅⾯是因为渲染节点内容,因为⼀个思维导图节点除了⽂本,还要⽀持图⽚、图标、标签等信息、svg不像html会⾃动按流式布局来帮你排版,所以每种信息节点都需要⼿动计算它们的位置,所以也是很耗时的⼀个操作,并且因为svg元素也算是dom节点,所以数量多了⼜要频繁操作,当然就卡了。
卡顿的原因到了,怎么解决呢?⼀种⽅法是不⽤svg,改⽤canvas,但是笔者发现该问题的时候已经写了较多代码,⽽且就算⽤canvas树的遍历也⽆法避免,所以笔者最后采⽤的⽅法的是不再每次都完全重新渲染,⽽是按需进⾏渲染,⽐如点击节点激活该节点的时候,不需要重新渲染其他节点,只需要重新渲染被点击的节点就可以了,⼜⽐如某个节点收缩或展开时,其他节点只是位置需要变化,节点内容并不需要重新渲染,所以只需要重新计算其他节点的位置并把它们移动过去即可,这样额外
的好处是还可以让它们通过动画的⽅式移动过去,其他相关的操作也是如此,尽量只更新必要的节点和进⾏必要的操作,改造完后虽然还是会存在⼀定卡顿的现象,但是相⽐之前已经好了很多。
数据结构
思维导图可以看成就是⼀棵树,我把它称作渲染树,所以基本的结构就是树的结构,每个节点保存节点本⾝的信息再加上⼦节点的信息,具体来说,⼤概需要包含节点的各种内容(⽂本、图⽚、图标等固定格式)、节点展开状态、⼦节点等等,此外还要包括该节点的私有样式,⽤来覆盖主题的默认样式,这样可以对每个节点进⾏个性化:
{
"data": {
"text": "根节点",
"expand": true,
"color": "#fff",
// ...
"children": []
}
详细结构可参考:。
仅有这棵渲染树是不够的,我们需要再定义⼀个节点类,当遍历渲染树的时候,每个数据节点都会创建⼀个节点实例,⽤来保存该节点的状态,以及执⾏渲染、计算宽⾼、绑定事件等等相关操作:
// 节点类
class Node {
constructor(opt = {}) {
this.isRoot =  opt.isRoot// 是否是根节点
this.layerIndex = opt.layerIndex// 节点层级
this.width = 0// 节点宽
this.height = 0// 节点⾼
this.left = opt.left || 0// left

this.parent = opt.parent || null// ⽗节点
this.children = []// ⼦节点
// ...
}
svg canvas// ...
因为⼀个节点可能包含⽂本、图⽚等多种信息,所以我们使⽤⼀个g元素来作为节点容器,⽂本就创建⼀个text节点,需要边框的话就再创建⼀个rect节点,节点的最终⼤⼩就是⽂本节点的⼤⼩再加上内边距,⽐如我们要渲染⼀个带边框的只有⽂本的节点:
import {
G,
Rect,
Text
} from '@svgdotjs/svg.js'
class Node {
constructor(opt = {}) {
// ...
}
/
/ 计算节点宽⾼
getSize() {
let textData = ateTextNode()
this.width = textData.width + 20// 左右内边距各10
this.height = textData.height + 10// 上下内边距各5
}
// 创建⽂本节点
createTextNode() {
let node = new Text().)
let { width, height } = node.bbox()// 获取⽂本节点的宽⾼
return {
node,
width,
height
}
}
// 渲染节点
render() {
let textData = ateTextNode()
// 创建⼀个矩形来作为边框
/
/ ⽂本节点添加到节点容器⾥
// 在画布上定位该节点
// 容器添加到画布上
this.draw.up)
}
}
如果还需要渲染图⽚的话,就需要再创建⼀个image节点,那么节点的总⾼度就需要再加上图⽚的⾼,节点的总宽就是图⽚和⽂字中较宽的那个⼤⼩,⽂字节点的位置计算也需要根据节点的总宽度及⽂字节点的宽度来计算,需要再渲染其他类型的信息也是⼀样,总之,所有节点的位置都需要⾃⾏计算,还是有点繁琐的。
节点类完整代码请看:。
逻辑结构图
思维导图有多种结构,我们先看最基础的【逻辑结构图】如何进⾏布局计算,其他的⼏种会在下⼀篇⾥进⾏介绍。
逻辑结构图如上图所⽰,⼦节点在⽗节点的右侧,然后⽗节点相对于⼦节点总体来说是垂直居中的。
节点定位
这个思路源于笔者在⽹上看到的,⾸先根节点我们把它定位到画布中间的位置,然后遍历⼦节点,那么⼦节点的left就是根节点的left +根节点的width+它们之间的间距marginX,如下图所⽰:
然后再遍历每个⼦节点的⼦节点(其实就是递归遍历)以同样的⽅式进⾏计算left,这样⼀次遍历完成后所有节点的left值就计算好了。
class Render {
// 第⼀次遍历渲染树
derTree, null, (cur, parent, isRoot, layerIndex) => {
// 先序遍历
// 创建节点实例
let newNode = new Node({
data: cur,// 节点数据
layerIndex// 层级
// 节点实例关联到节点数据上
cur._node = newNode
// 根节点
if (isRoot) {
< = newNode
// 定位在画布中⼼位置
newNode.left = (this.mindMap.width - node.width) / 2

} else {// ⾮根节点
// 互相收集
newNode.parent = parent._node
parent._node.addChildren(newNode)
// 定位到⽗节点右侧
newNode.left = parent._node.left + parent._node.width + marginX
}
}, null, true, 0)
}
接下来是top,⾸先最开始也只有根节点的top是确定的,那么⼦节点怎么根据⽗节点的top进⾏定位呢?上⾯说过每个节点是相对于其所有⼦节点居中显⽰的,那么如果我们知道所有⼦节点的总⾼度,那么第⼀个⼦节点的top也就确定了:

如图所⽰:
第⼀个⼦节点的top确定了,其他节点只要在前⼀个节点的top上累加即可。
那么怎么计算childrenAreaHeight呢?⾸先第⼀次遍历到⼀个节点时,我们会给它创建⼀个Node实例,然后触发计算该节点的⼤⼩,所以只有当所有⼦节点都遍历完回来后我们才能计算总⾼度,那么
显然可以在后序遍历的时候来计算,但是要计算节点的top只能在下⼀次遍历渲染树时,为什么不在计算完⼀个节点的childrenAreaHeight后⽴即就计算其⼦节点的top呢?原因很简单,当前节点的top都还没确定,怎么确定其⼦节点的位置呢?
// 第⼀次遍历
derTree, null, (cur, parent, isRoot, layerIndex) => {
// 先序遍历
// ...
}, (cur, parent, isRoot, layerIndex) => {
// 后序遍历
// 计算该节点所有⼦节点所占⾼度之和,包括节点之间的margin、节点整体前后的间距
let len = cur._node.children
cur._node.childrenAreaHeight = cur._duce((h, node) => {
return h + node.height
}, 0) + (len + 1) * marginY
}, true, 0)
总结⼀下,在第⼀轮遍历渲染树时,我们在先序遍历时创建Node实例,然后计算节点的left,在后序遍历时计算每个节点的所有⼦节点的所占的总⾼度。
接下来开启第⼆轮遍历,这轮遍历可以计算所有节点的top,因为此时节点树已经创建成功了,所以可以不⽤再遍历渲染树,直接遍历节点树:
// 第⼆次遍历
, null, (node, parent, isRoot, layerIndex) => {
if (node.children && node.children.length > 0) {
// 第⼀个⼦节点的top值 = 该节点中⼼的top值 - ⼦节点的⾼度之和的⼀半
let top = p + node.height / 2 - node.childrenAreaHeight / 2
let totalTop = top + marginY// node.childrenAreaHeight是包括⼦节点整体前后的间距的
node.children.forEach((cur) => {

totalTop += cur.height + marginY// 在上⼀个节点的top基础上加上间距marginY和该节点的height
})
}
}, null, true)
事情到这⾥并没有结束,请看下图:
可以看到对于每个节点来说,位置都是正确的,但是,整体来看就不对了,因为发⽣了重叠,原因很简单,因为【⼆级节点1】的⼦节点太多了,⼦节点占的总⾼度已经超出了该节点⾃⾝的⾼,因为【⼆级节点】的定位是依据【⼆级节点】的总⾼度来计算的,并没有考虑到其⼦节点,解决⽅法也很简单,
再来⼀轮遍历,当发现某个节点的⼦节点所占总⾼度⼤于其⾃⾝的⾼度时,就让该节点前后的节点都往外挪⼀挪,⽐如上图,假设⼦节点所占的⾼度⽐节点⾃⾝的⾼度多出了100px,那我们就让【⼆级节点2】向下移动50px,如果它上⾯还有节点的话也让它向上移动50px,需要注意的是,这个调整的过程需要⼀直往⽗节点上冒泡,⽐如:
点,那么它们可能也要发⽣重叠了,⽽且下⽅的【⼦节点2-1-1】和【⼦节点1-2-3】显然挨的太近了,所以【⼦节点1-1】⾃⼰的兄弟节点调整完后,⽗节点【⼆级节点1】的兄弟节点也需要同样进⾏调整,上⾯的往上移,下⾯的往下移,⼀直到根节点为⽌:
// 第三次遍历
, null, (node, parent, isRoot, layerIndex) => {
// 判断⼦节点所占的⾼度之和((除去⼦节点整体前后的margin))是否⼤于该节点⾃⾝
let difference = node.childrenAreaHeight - marginY * 2 - node.height
// ⼤于则前后的兄弟节点需要调整位置
if (difference > 0) {
this.updateBrothers(node, difference / 2)
}
}, null, true)
updateBrothers⽤来向上递归移动兄弟节点:
updateBrothers(node, addHeight) {
if (node.parent) {
let childrenList = node.parent.children
// 到⾃⼰处于第⼏个节点
let index = childrenList.findIndex((item) => {
return item === node
})
childrenList.forEach((item, _index) => {
if (item === node) {
return
}
let _offset = 0
// 上⾯的节点往上移
if (_index < index) {
_offset = -addHeight
} else if (_index > index) { // 下⾯的节点往下移
_offset = addHeight
}
// 移动节点

// 节点⾃⾝移动了,还需要同步移动其所有下级节点
if (item.children && item.children.length) {
this.updateChildren(item.children, 'top', _offset)
}
})
// 向上遍历,移动⽗节点的兄弟节点
this.updateBrothers(node.parent, addHeight)
}
}
// 更新节点的所有⼦节点的位置
updateChildren(children, prop, offset) {
children.forEach((item) => {
item[prop] += offset
if (item.children && item.children.length) {
this.updateChildren(item.children, prop, offset)
}
})
}
到此【逻辑结构图】的整个布局计算就完成了,当然,有⼀个⼩⼩⼩的问题:
就是严格来说,某个节点可能不再相对于其所有⼦节点居中了,⽽是相对于所有⼦孙节点居中,其实这样问题也不⼤,实在有强迫症的话,可以⾃⾏思考⼀下如何优化(然后偷偷告诉笔者),这部分完整代码请移步。
节点连线
节点定位好了,接下来就要进⾏连线,把节点和其所有⼦节点连接起来,连线风格有很多,可以使⽤直线,也可以使⽤曲线,直线的话很简单,因为所有节点的left、top、width、height都已经知道了,所以连接线的转折点坐标都可以轻松计算出来:
我们重点看⼀下曲线连接,如之前的图⽚所⽰,根节点的连线和其他节点的线是不⼀样的,根节点到其⼦节点的如下所⽰:
这种简单的曲线可以使⽤⼆次贝塞尔曲线,起点坐标为根节点的中间点:
let x1 = root.left + root.width / 2
let y1 = p + root.height / 2
终点坐标为各个⼦节点的左侧中间:
let x2 = node.left
let y2 = p + node.height / 2
那么只要确定⼀个控制点即可,具体这个点可以⾃⼰,⼀个看的顺眼的位置即可,笔者最终选择的是:
let cx = x1 + (x2 - x1) * 0.2
let cy = y1 + (y2 - y1) * 0.8)
再看下级节点的连线:
可以看到有两段弯曲,所以需要使⽤三次贝塞尔曲线,也是⼀样,⾃⼰选择两个合适的控制点位置,笔者的选择如下图,两个控制点的x处于起点和终点的中间:
let cx1 = x1 + (x2 - x1) / 2
let cy1 = y1
let cx2 = cx1
let cy2 = y2
接下来给Node类加个渲染连线的⽅法即可:
class Node {
// 渲染节点到其⼦节点的连线
renderLine() {
let { layerIndex, isRoot, top, left, width, height } = this
this.children.forEach((item, index) => {
// 根节点的连线起点在节点中间,其他都在右侧
let x1 = layerIndex === 0 ? left + width / 2 : left + width
let y1 = top + height / 2
let x2 = item.left
let y2 = p + item.height / 2
let path = ''
if (isRoot) {
path = quadraticCurvePath(x1, y1, x2, y2)
} else {
path = cubicBezierPath(x1, y1, x2, y2)
}
// 绘制svg路径到画布
this.draw.path().plot(path)
})
}
}
// 根节点到其⼦节点的连线
const quadraticCurvePath = (x1, y1, x2, y2) => {
// ⼆次贝塞尔曲线的控制点
let cx = x1 + (x2 - x1) * 0.2
let cy = y1 + (y2 - y1) * 0.8
return `M ${x1},${y1} Q ${cx},${cy} ${x2},${y2}`
}
// 其他节点到其⼦节点的连线
const cubicBezierPath = (x1, y1, x2, y2) => {
// 三次贝塞尔曲线的两个控制点
let cx1 = x1 + (x2 - x1) / 2
let cy1 = y1
let cx2 = cx1
let cy2 = y2
return `M ${x1},${y1} C ${cx1},${cy1} ${cx2},${cy2} ${x2},${y2}`
}
节点激活
点击某个节点就相对于把它激活,为了能有点反馈,所以需要给它加⼀点激活的样式,通常都是给它加个边框,但是笔者不满⾜于此,笔者认为节点所有的样式,激活时都可以改变,这样可以更好的与主题融合,也就是节点的所有样式都有两种状态,普通状态和激活状态,缺点是激活和取消激活时的操作多了,会带来⼀点卡顿。
实现上可以监听节点的单击事件,然后设置节点的激活标志,因为同时是可以存在多个激活节点的,所以⽤⼀个数组来保存所有的激活节点。
class Node {
bindEvent() {

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。