树形结构是前端常见的展现形式,今天就教大家用 SVG 和 React 实现一个平铺数组构建组织架构树。

先放一张我们要实现的效果图:

yyyS6ABoWC.gif

如上图所示,我们要实现的功能有:

  • 树形列表初始化
  • 增加节点
  • 删除节点

好了,了解了我们要实现的功能,接下来就开始吧。

数据

假设我们有这样一份平铺数组:

  1. let list = [
  2. { "id": "1", "parentId": "0", "name": "浙江省" },
  3. { "id": "1-1", "parentId": "1", "name": "杭州市" },
  4. { "id": "1-1-1", "parentId": "1-1", "name": "西湖区" },
  5. { "id": "1-2", "parentId": "1", "name": "宁波市" },
  6. { "id": "1-2-1", "parentId": "1-2", "name": "鄞州区" },
  7. { "id": "1-2-2", "parentId": "1-2", "name": "普陀区" },
  8. { "id": "1-3", "parentId": "1", "name": "衢州市" },
  9. { "id": "1-3-1", "parentId": "1-3", "name": "柯城区" },
  10. { "id": "1-3-2", "parentId": "1-3", "name": "衢江区" },
  11. ];

其中 id 为当前数据的唯一标示,parentId 表示这条数据父节点 id, name 为用于展示的文案。

坐标系

既然要通过 svg 画这样的图,那我们就需要建立一个坐标系的概念,假设左上角为坐标原点:

7.jpg

节点

再看图上的内容,最关键的是要构建这张图需要渲染的内容:节点、连线。

3.jpg

X 轴偏移量

不同节点在 x 轴和 y 轴都有不一样的缩进,我们先看 x 轴,它根据节点的层级有一个偏移量,在这里我们定位偏移 30px, 我们将数据构建为树形结构的过程中可以加上这个值:

5.jpg

  1. const convertToTree = (list, parentId = '0', level = 1) => {
  2. const out = [];
  3. for (let i=0; i < list.length; i++) {
  4. let node = list[i];
  5. if (node.parentId === parentId) {
  6. node.level = level;
  7. node.x = 30 * (node.level - 1); // x 轴偏移位置
  8. const children = convertToTree(list, node.id, level + 1)
  9. if (children.length) {
  10. node.children = children;
  11. }
  12. out.push(node);
  13. }
  14. }
  15. return out;
  16. }

转化后我们得到这样的数据:

  1. [
  2. { "id": "1",
  3. "parentId": "0",
  4. "name": "浙江省",
  5. "level": 1,
  6. "x": 0,
  7. "children":[
  8. {
  9. "id": "1-1",
  10. "parentId": "1",
  11. "name": "杭州市",
  12. "level": 2,
  13. "x": 30,
  14. "children": [{"id":"1-1-1","parentId":"1-1","name":"西湖区","level":3,"x":60}]
  15. },
  16. {
  17. "id":"1-2",
  18. "parentId": "1",
  19. "name": "宁波市",
  20. "level": 2,
  21. "x": 30,
  22. "children": [
  23. {"id":"1-2-1","parentId":"1-2","name":"鄞州区","level":3,"x":60},
  24. {"id":"1-2-2","parentId":"1-2","name":"普陀区","level":3,"x":60}
  25. ]
  26. },
  27. {
  28. "id": "1-3",
  29. "parentId": "1",
  30. "name": "衢州市",
  31. "level": 2,
  32. "x": 30,
  33. "children": [
  34. {"id":"1-3-1","parentId":"1-3","name":"柯城区","level":3,"x":60},
  35. {"id":"1-3-2","parentId":"1-3","name":"衢江区","level":3,"x":60}
  36. ]
  37. }
  38. ]
  39. }
  40. ]

我们成功将一个平铺的数组转化为一个树形结构,也在每个节点上增加了 x 轴的偏移值。

Y 轴偏移量

接下来我们需要计算每一个节点在 y 轴的偏移值,假设每个节点在 y 轴的偏移值单位为 60px,我们需要针对这个树形结构进行遍历,计算出每个节点 y 轴的位置:

6.jpg

  1. const traverseTree = (node, newList = []) => {
  2. if (!node) {return newList;}
  3. node.y = 60 * newList.length;
  4. const {children, ...rest} = node;
  5. newList.push({...rest});
  6. if (node.children && node.children.length > 0) {
  7. var i = 0;
  8. for (i = 0; i < node.children.length; i++) {
  9. traverseTree(node.children[i], newList);
  10. }
  11. }
  12. return newList;
  13. }

我们遍历树形结构后得到这样的数组:

  1. list = [
  2. {"id":"1","parentId":"0","name":"浙江省","level":1,"x":0,"y":0},
  3. {"id":"1-1","parentId":"1","name":"杭州市","level":2,"x":30,"y":60},
  4. {"id":"1-1-1","parentId":"1-1","name":"西湖区","level":3,"x":60,"y":120},
  5. {"id":"1-2","parentId":"1","name":"宁波市","level":2,"x":30,"y":180},
  6. {"id":"1-2-1","parentId":"1-2","name":"鄞州区","level":3,"x":60,"y":240},
  7. {"id":"1-2-2","parentId":"1-2","name":"普陀区","level":3,"x":60,"y":300},
  8. {"id":"1-3","parentId":"1","name":"衢州市","level":2,"x":30,"y":360},
  9. {"id":"1-3-1","parentId":"1-3","name":"柯城区","level":3,"x":60,"y":420},
  10. {"id":"1-3-2","parentId":"1-3","name":"衢江区","level":3,"x":60,"y":480}
  11. ]

渲染节点

可以看到,每个节点里面已经有了我们需要的坐标值了,接下来我们就把这些节点画在页面上,可以看到如果我们用原生的 SVG 元素区渲染节点里面的每一个内容将会非常痛苦,我们需要绘制一个大的矩形框,里面有一个小的包裹层级文案有背景色的矩形框,还有文字、icon…

image.png

幸运的是 SVG 给我们提供了一个 foreignObject 标签来包裹非 SVG 元素,这样我们就可以使用 div 布局了:

  1. <svg width="1000" height="1000" transform="translate(50,50)">
  2. {
  3. nodes.map((node) => {
  4. return <g className='node' key={node.id}>
  5. <foreignObject x={node.x} y={node.y} width="300" height="36">
  6. <div
  7. style={{
  8. display: 'inline-block',
  9. width: '240px',
  10. height: '36px',
  11. borderRadius: '5px',
  12. border: '1px solid #eee'
  13. }}
  14. >
  15. <span
  16. style={{
  17. display: 'inline-block',
  18. textAlign: 'center',
  19. background: '#e8e8e8',
  20. marginRight: '16px',
  21. width: '80px',
  22. lineHeight: '34px',
  23. borderRadius: '5px 0 0 5px'
  24. }}
  25. >{node.level}级</span>
  26. <span>{node.name}</span>
  27. </div>
  28. <Icon
  29. type="plus-circle"
  30. style={{
  31. float: 'right',
  32. lineHeight: '36px',
  33. }}
  34. />
  35. <Icon
  36. type="close-circle"
  37. style={{
  38. float: 'right',
  39. lineHeight: '36px',
  40. }}
  41. />
  42. </foreignObject>
  43. </g>
  44. })
  45. }
  46. </svg>

这样我们就可以看到这样的效果了:

9.jpg

连线

画完节点,我们接下来就开始构建连线,从图上可以看到连线的规律:父节点连接他的每一个直属子节点。

11.jpg

每一个父节点的出线位置在 x 轴有 10px 的偏移位置,在 y 轴偏移位置刚好是元素的高度,每一个子节点的入线:x 轴即该子节点的 x,y 轴有一个一半元素高度的偏移 36/2 ,这里我们定义元素的高度为 36px。

12.jpg

了解其中的逻辑我们接下来就来构建这样的数据:

使用在上一步构建的数据,传入 getLine 函数:

  1. const getLine = (list) => {
  2. const items = {}
  3. const map = {};
  4. // 获取每个节点的直属子节点,*记住是直属,不是所有子节点
  5. for (let i = 0; i < list.length; i++) {
  6. let key = list[i].parentId;
  7. map[list[i].id] = i;
  8. if (items[key]) {
  9. items[key].push(list[i])
  10. } else {
  11. items[key] = []
  12. items[key].push(list[i])
  13. }
  14. }
  15. // 构建路径
  16. const lines = [];
  17. Object.keys(items).forEach((itemKey) => {
  18. if (typeof map[itemKey] !== 'undefined') {
  19. const parentNode = list[map[itemKey]];
  20. for (let i = 0; i < items[itemKey].length; i++) {
  21. const children = items[itemKey][i];
  22. lines.push(`M${parentNode.x + 10} ${parentNode.y + 36} L${parentNode.x + 10} ${children.y + 18} L${children.x} ${children.y + 18}`)
  23. }
  24. }
  25. });
  26. return lines;
  27. }

渲染连线:我们使用 svg path 元素渲染连线

  1. {
  2. lines.map((line) => {
  3. return <g className='line' key={line}>
  4. <path d={line} style={{fill: 'transparent', stroke: '#999'}}/>
  5. </g>
  6. })
  7. }

以上的代码就完成了一个树形结构的初始化了:

10.jpg

增加节点

完成了树形的渲染,接下来我们来增加一点交互效果:点击增加按钮,在当前的子节点增加一条数据,为了演示方便,节点的 id 使用了时间戳,你可以使用 js 库生成一个更严谨的唯一id:

  1. add(node) {
  2. const parentId = node.id;
  3. list.push({id: `${parentId}-${new Date().getTime()}`, name: '子节点', parentId});
  4. const treeData = convertToTree(list);
  5. const nodes = traverseTree(treeData[0]);
  6. const lines = getLine(nodes);
  7. this.setState({
  8. nodes, lines
  9. });
  10. }

删除节点

点击删除,删除当前节点,如果当前节点存在子节点也一并删除,根节点不允许删除:

  1. remove(node, index) {
  2. if (node.parentId === '0') return;
  3. list.splice(index, 1);
  4. const treeData = convertToTree(list);
  5. const nodes = traverseTree(treeData[0]);
  6. const lines = getLine(nodes);
  7. this.setState({
  8. nodes, lines
  9. });
  10. }

自此我们完成了所有功能。

小结

以上代码可以封装成一个灵活的组件,让你在需要树形结构的里面方便使用,你也可以继续在这个基础上增加父子节点的折叠功能。为了演示方便,会有少量的重复代码,你在具体业务开发过程中可以将 list,convertToTree,traverseTree,getLine 封装在一个类里面,只向外暴露一个方法用于透出 nodes 和 lines。

希望你看完这篇文章对树形渲染有一点点小收获~