树形结构是前端常见的展现形式,今天就教大家用 SVG 和 React 实现一个平铺数组构建组织架构树。
先放一张我们要实现的效果图:
如上图所示,我们要实现的功能有:
- 树形列表初始化
- 增加节点
- 删除节点
好了,了解了我们要实现的功能,接下来就开始吧。
数据
假设我们有这样一份平铺数组:
let list = [
{ "id": "1", "parentId": "0", "name": "浙江省" },
{ "id": "1-1", "parentId": "1", "name": "杭州市" },
{ "id": "1-1-1", "parentId": "1-1", "name": "西湖区" },
{ "id": "1-2", "parentId": "1", "name": "宁波市" },
{ "id": "1-2-1", "parentId": "1-2", "name": "鄞州区" },
{ "id": "1-2-2", "parentId": "1-2", "name": "普陀区" },
{ "id": "1-3", "parentId": "1", "name": "衢州市" },
{ "id": "1-3-1", "parentId": "1-3", "name": "柯城区" },
{ "id": "1-3-2", "parentId": "1-3", "name": "衢江区" },
];
其中 id 为当前数据的唯一标示,parentId 表示这条数据父节点 id, name 为用于展示的文案。
坐标系
既然要通过 svg 画这样的图,那我们就需要建立一个坐标系的概念,假设左上角为坐标原点:
节点
再看图上的内容,最关键的是要构建这张图需要渲染的内容:节点、连线。
X 轴偏移量
不同节点在 x 轴和 y 轴都有不一样的缩进,我们先看 x 轴,它根据节点的层级有一个偏移量,在这里我们定位偏移 30px, 我们将数据构建为树形结构的过程中可以加上这个值:
const convertToTree = (list, parentId = '0', level = 1) => {
const out = [];
for (let i=0; i < list.length; i++) {
let node = list[i];
if (node.parentId === parentId) {
node.level = level;
node.x = 30 * (node.level - 1); // x 轴偏移位置
const children = convertToTree(list, node.id, level + 1)
if (children.length) {
node.children = children;
}
out.push(node);
}
}
return out;
}
转化后我们得到这样的数据:
[
{ "id": "1",
"parentId": "0",
"name": "浙江省",
"level": 1,
"x": 0,
"children":[
{
"id": "1-1",
"parentId": "1",
"name": "杭州市",
"level": 2,
"x": 30,
"children": [{"id":"1-1-1","parentId":"1-1","name":"西湖区","level":3,"x":60}]
},
{
"id":"1-2",
"parentId": "1",
"name": "宁波市",
"level": 2,
"x": 30,
"children": [
{"id":"1-2-1","parentId":"1-2","name":"鄞州区","level":3,"x":60},
{"id":"1-2-2","parentId":"1-2","name":"普陀区","level":3,"x":60}
]
},
{
"id": "1-3",
"parentId": "1",
"name": "衢州市",
"level": 2,
"x": 30,
"children": [
{"id":"1-3-1","parentId":"1-3","name":"柯城区","level":3,"x":60},
{"id":"1-3-2","parentId":"1-3","name":"衢江区","level":3,"x":60}
]
}
]
}
]
我们成功将一个平铺的数组转化为一个树形结构,也在每个节点上增加了 x 轴的偏移值。
Y 轴偏移量
接下来我们需要计算每一个节点在 y 轴的偏移值,假设每个节点在 y 轴的偏移值单位为 60px,我们需要针对这个树形结构进行遍历,计算出每个节点 y 轴的位置:
const traverseTree = (node, newList = []) => {
if (!node) {return newList;}
node.y = 60 * newList.length;
const {children, ...rest} = node;
newList.push({...rest});
if (node.children && node.children.length > 0) {
var i = 0;
for (i = 0; i < node.children.length; i++) {
traverseTree(node.children[i], newList);
}
}
return newList;
}
我们遍历树形结构后得到这样的数组:
list = [
{"id":"1","parentId":"0","name":"浙江省","level":1,"x":0,"y":0},
{"id":"1-1","parentId":"1","name":"杭州市","level":2,"x":30,"y":60},
{"id":"1-1-1","parentId":"1-1","name":"西湖区","level":3,"x":60,"y":120},
{"id":"1-2","parentId":"1","name":"宁波市","level":2,"x":30,"y":180},
{"id":"1-2-1","parentId":"1-2","name":"鄞州区","level":3,"x":60,"y":240},
{"id":"1-2-2","parentId":"1-2","name":"普陀区","level":3,"x":60,"y":300},
{"id":"1-3","parentId":"1","name":"衢州市","level":2,"x":30,"y":360},
{"id":"1-3-1","parentId":"1-3","name":"柯城区","level":3,"x":60,"y":420},
{"id":"1-3-2","parentId":"1-3","name":"衢江区","level":3,"x":60,"y":480}
]
渲染节点
可以看到,每个节点里面已经有了我们需要的坐标值了,接下来我们就把这些节点画在页面上,可以看到如果我们用原生的 SVG 元素区渲染节点里面的每一个内容将会非常痛苦,我们需要绘制一个大的矩形框,里面有一个小的包裹层级文案有背景色的矩形框,还有文字、icon…
幸运的是 SVG 给我们提供了一个 foreignObject 标签来包裹非 SVG 元素,这样我们就可以使用 div 布局了:
<svg width="1000" height="1000" transform="translate(50,50)">
{
nodes.map((node) => {
return <g className='node' key={node.id}>
<foreignObject x={node.x} y={node.y} width="300" height="36">
<div
style={{
display: 'inline-block',
width: '240px',
height: '36px',
borderRadius: '5px',
border: '1px solid #eee'
}}
>
<span
style={{
display: 'inline-block',
textAlign: 'center',
background: '#e8e8e8',
marginRight: '16px',
width: '80px',
lineHeight: '34px',
borderRadius: '5px 0 0 5px'
}}
>{node.level}级</span>
<span>{node.name}</span>
</div>
<Icon
type="plus-circle"
style={{
float: 'right',
lineHeight: '36px',
}}
/>
<Icon
type="close-circle"
style={{
float: 'right',
lineHeight: '36px',
}}
/>
</foreignObject>
</g>
})
}
</svg>
这样我们就可以看到这样的效果了:
连线
画完节点,我们接下来就开始构建连线,从图上可以看到连线的规律:父节点连接他的每一个直属子节点。
每一个父节点的出线位置在 x 轴有 10px 的偏移位置,在 y 轴偏移位置刚好是元素的高度,每一个子节点的入线:x 轴即该子节点的 x,y 轴有一个一半元素高度的偏移 36/2 ,这里我们定义元素的高度为 36px。
了解其中的逻辑我们接下来就来构建这样的数据:
使用在上一步构建的数据,传入 getLine 函数:
const getLine = (list) => {
const items = {}
const map = {};
// 获取每个节点的直属子节点,*记住是直属,不是所有子节点
for (let i = 0; i < list.length; i++) {
let key = list[i].parentId;
map[list[i].id] = i;
if (items[key]) {
items[key].push(list[i])
} else {
items[key] = []
items[key].push(list[i])
}
}
// 构建路径
const lines = [];
Object.keys(items).forEach((itemKey) => {
if (typeof map[itemKey] !== 'undefined') {
const parentNode = list[map[itemKey]];
for (let i = 0; i < items[itemKey].length; i++) {
const children = items[itemKey][i];
lines.push(`M${parentNode.x + 10} ${parentNode.y + 36} L${parentNode.x + 10} ${children.y + 18} L${children.x} ${children.y + 18}`)
}
}
});
return lines;
}
渲染连线:我们使用 svg path 元素渲染连线
{
lines.map((line) => {
return <g className='line' key={line}>
<path d={line} style={{fill: 'transparent', stroke: '#999'}}/>
</g>
})
}
以上的代码就完成了一个树形结构的初始化了:
增加节点
完成了树形的渲染,接下来我们来增加一点交互效果:点击增加按钮,在当前的子节点增加一条数据,为了演示方便,节点的 id 使用了时间戳,你可以使用 js 库生成一个更严谨的唯一id:
add(node) {
const parentId = node.id;
list.push({id: `${parentId}-${new Date().getTime()}`, name: '子节点', parentId});
const treeData = convertToTree(list);
const nodes = traverseTree(treeData[0]);
const lines = getLine(nodes);
this.setState({
nodes, lines
});
}
删除节点
点击删除,删除当前节点,如果当前节点存在子节点也一并删除,根节点不允许删除:
remove(node, index) {
if (node.parentId === '0') return;
list.splice(index, 1);
const treeData = convertToTree(list);
const nodes = traverseTree(treeData[0]);
const lines = getLine(nodes);
this.setState({
nodes, lines
});
}
自此我们完成了所有功能。
小结
以上代码可以封装成一个灵活的组件,让你在需要树形结构的里面方便使用,你也可以继续在这个基础上增加父子节点的折叠功能。为了演示方便,会有少量的重复代码,你在具体业务开发过程中可以将 list,convertToTree,traverseTree,getLine 封装在一个类里面,只向外暴露一个方法用于透出 nodes 和 lines。
希望你看完这篇文章对树形渲染有一点点小收获~