背景
目前大部分前端同学包括本人在内都是只会使用React/Vue这种数据驱动框架做页面开发的页面仔,然而很多画图编辑类的工具性页面的开发考验的是前端的架构能力。为了学习与训练自己的架构能力,本人开发了一个web思维导图页面。本文主要谈谈在开发web思维导图时的一些架构思考与心得。以下是 demo 和源码的传送门:
您的star是对我最大的支持,也欢迎随时提issue,本人会在第一时间回应。
设计方案
功能
设计方案之前需先了解功能。本思维导图的功能包括:
这些功能落实到具体代码实现可以简化为如下流程:
架构
根据上面流程图,可以知道思维导图的功能大概至少有两层:渲染层与操作层,在此基础上又多加了两层,如下图:
- 元件层:使用SVG渲染的图形元件,调用后会直接在画布上画出图形。
- 渲染层:用于渲染树形结构的思维导图。
- 操作前置层:公共的操作模块,作用是抽象出公共的操作方法提供给操作层使用。
- 操作层:用户事件的操作,包括工具栏和键盘操作。
技术选型
思维导图渲染使用SVG实现。为了不使用canvas?因为canvas更适合于图形密集型和像素级的处理,思维导图的节点与边的渲染和处理并不复杂,使用SVG实现会简单很多。
相比于canvas,svg还有另外一些好处是:svg 支持事件处理器,svg的文本渲染能力较强。
svg库选用了RaphaelJS。相比于其他SVG第三方库,RaphaelJS的API十分成熟,而且包体积也不大,压缩后的JS只有90KB。
关键技术实现
计算节点位置
思维导图最核心的功能是计算节点的位置。节点位置分为水平位置和垂直位置。
计算节点水平位置
首先求得父节点的中心点 F 的坐标为 (hfx, hfy),设父节点与子节点的水平距离为 interval,父节点的宽为 parentWidth。作水平线段 FC,C 点的横坐标即为子节点的横坐标 childX。如下图所示:
因此水平位置计算方式为:
const childX = parentWidth / 2 + interval;
计算节点垂直位置
节点的垂直位置需要做到同一层所有子节点的高度相对于父节点垂直居中。
为了让子节点间垂直隔开,每一个子节点上下都有补白,所以一个子节点所占的区域高度为该子节点的节点高度加上两个补白高度,所有子节点的高度和补白组成一个名为areaHeight的区域高度,该区域与父节点垂直居中。如下图所示:
接下来是计算每个子节点的垂直位置。首先求得 A 点的垂直坐标 startY = hfy - areaHeight / 2,第一个子节点的垂直坐标由 startY 加 padding 可得。求第二个子节点的垂直坐标时,startY 累加上一个子节点的区域高度,则第二个子节点的垂直坐标等于当前 startY 加上 padding。以此类推,计算剩下子节点的垂直位置
计算方式如下:
const childX = parentWidth / 2 + interval;
let startY = hfy - childrenAreaHeight / 2;
// 迭代子节点,求得每个子节点的垂直坐标
children.forEach(function(child){
const childY = startY + padding;
// 已经求得当前子节点坐标(childX, childY),在这里作渲染操作
render(childX, childY);
const curAreaHeight = getNodeHeight(child) + padding * 2;
// 累加高度
startY += curAreaHeight;
});
节点拖拽
另一个关键技术点是节点拖拽。拖拽节点到其他节点的子节点区域后可以将拖拽节点的父节点变为其他节点。如果鼠标还没放手,会有一个红色的占位块,表示如果放手后会变成哪个节点的子节点。
节点拖拽插入区域
被拖拽节点拖拽到某个节点的插入区域时会展示红色占位块。如下图所示,父节点的插入区域为彩色部分:
垂直方向上,以子节点的中间水平线为分界线,分割出垂直方向上的可插入的子节点位置。上图中有3个子节点中间水平线,加上下两个边界,得出可插入的位置有4个。
水平方向上,以父节点的右边为区域左边界,以最宽的子节点的右边为区域右边界。这样的水平方向边界看起来没问题,但是加上子节点的可插入区域就不一样了:
上图中蓝色方块为3个子节点的可插入区域,但是细心同学会发现子节点1与父节点的可插入区域重叠了,重叠区域为红色方块。子节点3也有同样的问题。
解决方案是如果遇到重叠区域,则可插入区域让给层次较深的节点。这是因为层次较浅的节点本身可插入区域很大,就算不要重叠部分也可以找到对应的插入位置。但是层次较深的节点则不然,没了重叠区域,那可插入区域就太小了。
可以使用树的层级算法实现这个解决方案:初始化一个可插入区域的数组,使用层级算法遍历思维导图树,层次较深的节点先放到数组前面。等拖拽的时候鼠标坐标与可插入区域数组从头到尾看看能重合的区域时哪个,这样就优先找到层次较深的节点,实现代码如下:
const getArea = (node) => {
// 返回节点的可插入区域,暂不实现
return { x: 0, y: 0, x2: 0, y2: 0 };
}
const getAreaList = (root) => {
const areaList = [];
const queue = [root];
while (queue.length > 0) {
const queueLength = queue.length;
for (let i = 0; i < queueLength; i++) {
const current = queue.shift();
const area = getArea(current);
// 通过层级遍历,把层级较浅的节点放在数组后面
areaList.push(area);
current.children.forEach((child) => queue.push(child));
}
}
};
let root; // 根节点,数据格式为: { children: [] }
let mouseX, mouseY; // 鼠标坐标
const areaList = getAreaList(root);
let hitAreaBox; // 获取命中区域
for (let i = 0; i < areaList.length; i++) {
const areaBox = areaList[i];
if (x >= areaBox.x && x <= areaBox.x2 && y >= areaBox.y && y <= areaBox.y2) {
hitAreaBox = areaBox;
break;
}
}
节点拖拽区域调试
在页面url中添加debug=1参数,在拖拽的时候会出现拖拽区域。如访问 rockyren.github.io/mindmaptree… ,拖拽的时候效果如下:
总结
思维导图的实现上并不难,难就难在如何组织高可维护性和高可扩展性的代码,此时对代码进行分层可以解决这个组织问题。相比于canvas,使用SVG实现思维导图会更加简单。本文也从聊了聊两个实现思维导图的关键技术,其实还有更多的技术要点,如文本编辑,有兴趣的同学可以看看思维导图源码。既然都看源码了,要不顺手点个star再走吧。