如何构建一颗可交互的ui树?
-
关键三要素
-
根节点(蓝色节点)
-
不同的子节点(黄色,绿色,灰色均为不同类型节点)
-

-
最初的探索
其实市面上流程图的图表库还是很多的,最初我们想法也是基于图表库进行树的设计,而且原项目是有基于图表库做过一些流程图,树图的经验的,因此我们调研了实现树的几种形式...
这是我们开发之前的UI稿~:


UI的需求实际上可以分为几个部分:
-
每个节点其实都是自适应高度的,当修改节点的自适应高度后实际上整个树图的布局都要再次自适应布局
-
节点强烈的和UI组件交互的需求,每个节点的click,hover都是有交互事件
-
类似泳道概念:每一排节点上实际都是有一个类似泳道的交互
-
框选:来自同一个父节点的子节点需要具备框选的能力
| 方案 | 优势 | 劣势 |
|---|---|---|
| mxgraph | 老牌流程图组件库,很方便地实现流程图编辑等操作; | 使用起来非常复杂,可扩展性很差;性能一般,遇到较多的节点会有卡顿的情况;自动布局算法较差,无法实现设计稿上的自动布局;很难实现泳道和已选区域的绘制; |
| g6 | 蚂蚁金服出品前端组件库;基于Canvas绘制,性能较好; | 使用成本高,可扩展性较差;还原设计稿需要较大时间和精力; |
| Canvas + ZRender | 通过Canvas绘制,理论上能绘制任何图形; | 实现起来较为复杂;依赖自动布局算法;无法使用复杂的BUI组件; |
| SVG + Table | 实现方式较为简单,性能较好; 能够使用table做高度自适应的自动布局;方便绘制泳道和已选区域能够使用复杂的UI组件响应式 | 并不采用通用流程图组件思想实现,针对图的可扩展性较差(比如任意拖拽节点,自动定位等) |
实践demo发现:Mxgraph&G6在面对这个UI稿的几个难点就明显有点水土不服了...


那没有轮子怎么办呢,bingo,我们只能自己造轮子了,最终我的决定是:
-
Canvas + ZRender
-
SVG + Table
- 把节点放到表格中,这并不是一个普通流程图的思想,但是这恰巧满足了我们的UI需求,因为我们都知道直接绘制DOM是easy的!直接使用组件是清晰的!

架构设计
数据结构设计
如何设计数据结构?
Canvas + ZRender数据结构:
yaml
const data = {
// 点集
nodes: [
{
id: 'node1',
x: 100,
y: 200,
},
{
id: 'node2',
x: 300,
y: 200,
},
],
// 边集
edges: [
// 表示一条从 node1 节点连接到 node2 节点的边
{
source: 'node1',
target: 'node2',
},
],
};
SVG + Table数据结构:
yaml
const data = {
id: 'node1',
x: 100,
y: 200,
children: [{
id: 'node2',
x: 300,
y: 200,
parent: 'node1',
}]
};
| 数据结构类型 | Canvas + ZRender | SVG + Table |
|---|---|---|
| 优点 | 可以表示有环图;边上可以定义数据 | 获取children和parent比较简单;不用手动维护节点和边的关系 |
| 缺点 | 获取children和parent比较复杂; 要维护节点和边的关系 | 不能表示有环图;边上无法定义数据 |
对于我们ui稿图的树所需要的场景,SVG + Table显然是一种更好的方案
树的绘制
自动布局
-
使用递归函数,计算每个节点在表格中的位置(x, y),然后再把这个树打平,构建一个treeMap。再根据map构建一个tableArray数组,填入表格中,就实现了树的自动布局;
-
监听treeData等数据的变化,获取每个节点的offsetLeft和offsetTop等参数,使用svg绘制边和平行维度等;


ini
getTreeMap() {
const edge = [];
const treeMap = {};
let deepY = 0;
function travel(node, x, y) {
node.x = x;
node.y = y;
deepY = max([y, deepY]);
treeMap[node.nodeId] = node;
if (!node.ref && node.children) {
node.children.forEach((item, index) => {
let nextY = y + index;
if (deepY > y) {
nextY = deepY + 1;
}
edge.push({
source: node,
target: item,
})
travel(item, x + 1, nextY)
})
}
}
travel(this.treeData, 0, 1);
this.edge = edge;
return treeMap;
},
getTableArray() {
const tableArray = Object.values(this.treeMap);
let maxX = 0;
let maxY = 0;
tableArray.forEach((item) => {
if (item.x > maxX) {
maxX = item.x;
}
if (item.y > maxY) {
maxY = item.y;
}
});
const arrayTable = new Array(maxY + 1).fill(null).map(() => new Array(maxX + 1).fill(null));
tableArray.forEach((item) => {
arrayTable[item.y][item.x] = item;
});
return arrayTable;
},
ini
<template>
<div class="tree-graph-wrapper">
<div class="tree-graph">
<div :class="graphClass" :style="graphStyle">
<svg ref="svg" class="svg-layer"></svg>
<div class="bulk-layer">
<graph-bulk
v-for="(node, key) in bulkData"
:id="node.nodeId"
:isBulk="isBulk"
:key="generateBulkKey(bulkGeometrys[node.nodeId], key)"
:node="node"
:geometry="bulkGeometrys[node.nodeId]"
:bulkData="bulkData"
:treeMap="treeMap"
:isRead="isRead"
:ref="node.nodeId"
@select="handleCardClick"
/>
</div>
<bulk-choose-tips :selectNode="selectNode" :isBulk="isBulk"/>
<tr v-for="(x, xindex) in tableArray" :key="generateTrKey(x)">
<td v-for="(node, yindex) in x" :key="yindex" :style="selectBulkColumnStyle(yindex)">
<div v-if="xindex === 0" :style="mockCardStyle"/>
<graph-card v-else-if="node"
:id="node.nodeId"
:node="node"
:selectNode.sync="_selectNode"
:bulkData="bulkData"
:treeMap="treeMap"
:ref="node.nodeId"
:isBulk="isBulk"
:isRead="isRead"
@click.native="handleCardClick(node)"
/>
</td>
</tr>
</div>
</div>
<graph-tool v-if="!isNavigation" v-model="scale" v-bind="$props"/>
</div>
</template>
-
线的绘制
节点的步骤完成后,线的绘制其实也很明朗:
- 遍历节点之间的关系(source&target)
- 然后需要计算坐标,然后再用svg画出来就可以了~

ini
drawLines() {
if (this.isDrawLine) {
return;
}
this.isDrawLine = true;
this.$nextTick(() => {
this.draw.clear();
this.polylines.clear();
this.edge.forEach((edge) => {
const {source, target} = edge;
const sourceDom = this.$refs?.[source.nodeId]?.[0].$el;
const targetDom = this.$refs?.[target.nodeId]?.[0].$el;
if (!sourceDom || !targetDom) {
// eslint-disable-next-line no-console
console.warn('不存在dom节点');
return;
}
const {offsetLeft: x1, offsetTop: y1, offsetWidth: width1} = sourceDom;
const {offsetLeft: x2, offsetTop: y2} = targetDom;
const startx = x1 + width1;
let starty = y1 + defaultHeight / 2;
let endx = x2;
let endy = y2 + defaultHeight / 2;
const middlex = (startx + endx) / 2;
const points = [startx, starty, middlex, starty, middlex, endy, endx, endy];
const polyline = this.draw.polyline(points);
this.polylines.set(edge, polyline);
});
this.highlightLine();
this.isDrawLine = false;
})
},
-
框选节点
less
1. 计算框选节点包含节点的边界,再绘制出矩形框;
2. 平行维度选择时泳道绘制:使用css td:nth-child(even)

ini
calcBulkGeometrys() {
const bulkGeometrys = this.bulkData.reduce((obj, bulk) => {
const box = bulk.nodeIds.reduce((obj, item) => {
if (this.$refs?.[item]?.[0]) {
const dom = this.$refs[item][0].$el;
const {offsetLeft: x, offsetTop: y, offsetWidth: width, offsetHeight: height} = dom;
return {
x: [...obj.x, x, x + width],
y: [...obj.y, y, y + height],
};
} else {
return obj;
}
}, {x: [], y: []});
let maxX = max(box.x) + bulkPadding;
let minX = min(box.x) - bulkPadding;
let maxY = max(box.y) + bulkPadding;
let minY = min(box.y) - bulkPadding;
return {
...obj,
[bulk.nodeId]: {minX, minY, maxX, maxY},
};
}, {});
this.bulkGeometrys = bulkGeometrys;
},
css
td:nth-child(1), td:nth-child(even) {
background:#F4F4F4;
background-clip: content-box;
}
-
其他交互细节
图的缩放
- 使用 transform: scale css属性来实现图的缩放;
- 触摸板双指操作 == 按住ctrl键滚动
- 优点:实现起来简单,性能较高;配合dom+svg的方案缩放起来不会失真。
ini
handleTwoFingerZoom: throttle(function(e) {
if (e.ctrlKey) {
e.preventDefault();
e.stopImmediatePropagation();
const s = Math.exp(-e.deltaY / 100);
const scale = this.limitScale(this.value * s);
this.$emit('input', scale);
}
}),
缩略图

- 使用组件循环引用,将tree-graph组件作为子组件,$props透传;
- 使用使用 webpack 的异步
import解决vue组件循环引用问题; - 缺点:在渲染较大树时(同时拥有近200个节点),性能较差
ini
<template>
<div class="graph-navigation" :style="navigationStyle">
<div class="navigation-title">
<div class="navigation-title-text">{{I18n.t('快捷定位')}}</div>
<byted-icon class="navigation-title-close" name="close" color="#fff" @click="handleClose"></byted-icon>
</div>
<tree-graph :isNavigation="true" v-bind="$attrs" :graphScale="graphScale"/>
</div>
</template>
css
components: {
// 使用 webpack 的异步 import,解决vue组件循环引用问题
TreeGraph: () => import('../index.vue'),
},
滚动到指定节点
使用 scrollIntoView({behavior: "smooth", block: "center", inline: "center"})
php
scrollNodeIntoView(nodeId) {
this.$refs?.[nodeId]?.[0]?.$el?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
},
数据结构
镜像节点 - Proxy&Reflect

镜像节点是一种特殊的节点-改变源节点或者改变镜像节点,另外的节点都会被更新,但只是部分属性更新,比如镜像节点的parent属性和源节点的parent属性就是不同的。
-
最初的想法是,使用一个特殊的function去处理:特殊逻辑特殊处理,这没什么问题...
- 但实际上如果我们想使用这些function,我们就可能在watch中来回穿梭,去寻找,定位这些复杂的逻辑,这样的情况显示是不利于维护的,这个方案很快就被我们否定了...

-
镜像节点的这些特性,很容易让我们想到Proxy这种数据结构:我们试图让两个节点共享同一个数据资源,但是又想要保证节点的一些独特属性(比如parent)
- Proxy可以通过handler中的get和set对想要的属性进行数据劫持,达到同步更新的效果,这里每一次新建一个镜像节点就是创建了一个Proxy,同时镜像节点有一个特殊的属性res,通过Reflect来获取源节点的属性,这个相当于对源节点的引用
ini
export function createMirrorNodeProxy(node, outerOptions = {}) {
const options = {...outerOptions};
const mirrorHandlers = {
get(target, key) {
if (options[key]) {
return options[key];
}
let res = Reflect.get(...arguments);
return res;
},
set(targt, key, value) {
if (!isNil(options[key])) {
options[key] = value;
return true;
}
let res = Reflect.set(...arguments);
return res;
},
};
return new Proxy(node, mirrorHandlers);
}
这里相当于只是使用了Proxy的基本特性,值得注意的是,在Vue框架中需要把对Proxy的转换放到响应式之后去处理,在回填信息时这里是需要注意的。
kotlin
this.treeData = treeData;
// 兼容proxy在Vue中响应式问题
this.treeData = parseProxyNode(this.treeData);
Proxy当然还可以做很多更有意思的事情,利用handler,是可以操作对属性进行各种操作,比如值修正和计算属性等等
其他
超大树节点刷新性能问题
遇到的问题:
项目上线后,很快就遇到了超大树的性能问题,主要集中在切换,删除,新增的节点会出现性能卡顿。chrome的火焰图可以看出,在进行这些操作时vue会进行大量的patch操作。

解决办法: 实际上我犯了一个很愚蠢的问题,使用index做key...
一些在开发过程中的设计原则
组件是一种编程抽象,目的是复用。
- DRY原则:Don't repeat yourself,不要开发重复的功能;
- 三次原则:当某个功能第三次出现时,才进行"抽象化";
软件的首要技术使命:管理复杂度
computed 优先于 watch
- 滥用watch会导致数据流向不清晰(熵增);
- 计算属性是基于它们的响应式依赖进行缓存的;
### 最好不要写出超大组件:
1. 当组件超过500行,就要准备拆分;
2. 当组件超过700行,就要开始重构;
3. 当组件超过1000行,就很难维护了;
4. 合适的组件行数一般在30\~400行;
### 不要滥用mixin和provide
1. mixin很容易发生冲突,并且可重用性是有限的;
- 在 Vue 2 中,mixin 是将部分组件逻辑抽象成可重用块的主要工具。但是,他们有几个问题:
- mixin 很容易发生冲突:因为每个特性的属性都被合并到同一个组件中,所以为了避免 property 名冲突和调试,你仍然需要了解其他每个特性。
- 可重用性是有限的:我们不能向 mixin 传递任何参数来改变它的逻辑,这降低了它们在抽象逻辑方面的灵活性
- provide使用较多会使重构变得困难,并且它提供的props是非响应式的;
整个treeConfig组件中,作为父级组件,为子组件提供了两个比较重要的依赖注入,分别是getNextNodeId和calcTreeData,用于获得递增节点id和生成和后端交互的treeNode。
然而,依赖注入还是有负面影响的。它将你应用程序中的组件与它们当前的组织方式耦合起来,使重构变得更加困难。同时所提供的 property 是非响应式的。
kotlin
provide() {
return {
getNextNodeId: this.getNextNodeId,
calcTreeData: this.calcTreeData,
};
},