阅读 1823

实现一个不太一样的树状图组件

某次需要在页面上实现一个用于反映节点间父子关系的树状图,如下:

图1

在线Demo

一般来说,这种图表类的功能,都会通过引用第三方库来实现,一开始我也是打算想直接引入一个库来完成这个功能,但是我大概找了一圈,发现实现这个功能的第三方库还是比较少的

  • OrgChart 似乎是个存在时间比较长的库了,样式和交互都有点古老,功能冗余不够简约,Vue版本压缩后的体积在 100KB 左右,也不算小了,最主要的是,无法自定义节点的结构和UI,不方便改造

  • vue-org-tree 样式和交互都比较舒适,但其只能垂直排列,没有水平排列的选项,满足不了遇到的实际业务

基于以上原因,没有一个满意的第三方库可以满足业务需求,又不甘心应付了事,于是决定自己动手造个轮子

DOM结构

首先分析一下 UI 结构,跟常见的树状图结构不同,我所接到的需求UI中,父节点并不是与所有的子节点整体保持垂直居中对齐(为了方便叙述,这种对齐模式简称为横向垂直对齐模式,暂不考虑与之对应的竖向水平居中对齐模式),而是与第一个子节点居中对齐(为了方便叙述,这种对齐模式简称为第一元素对齐模式

这种布局的优点比较明显,那就是在子节点足够多的情况下,父节点不至于为了跟所有子节点形成的整体保持对齐,而使得父节点本身的位置超过了用户可轻松触达的范围

比如,如果子节点整体的高度超出了两屏幕高度,那么父节点为了跟子节点整体保持垂直居中,父节点的 y 轴高度就要超过一屏高度,也就是要在屏幕外面了,用户需要滑动一下屏幕才能找到父节点;而如果采取父节点始终与第一个子节点保持居中对齐的模式,则这种情况出现的几率就会小很多,用户可见范围内能够出现更多的节点

图2

当然了,弊端也比较明显,那就是整体的美感不如居中对齐的好(但也不至于难看),而且这种布局模式也只适用于横向树状图,竖向的话就比较奇怪了

两种布局模式各有优缺,谈不上更好更坏,只是一种权衡,看你更需要哪种功能了

第一元素对齐模式,有两个问题需要解决

  • 父子节点对齐问题

对于第一元素对齐模式而言,父节点不仅要与直接第一个子节点对齐,还要与直接第一个子节点的直接第一个子节点对齐,如果往下还有子节点,那么继续对齐

如下图,0 是根节点,1是0的第一个子节点,2是0的第二个子节点,3是0的第三个子节点,4是1的第一个子节点,5是4的第一个子节点,那么节点 0、1、4、5 必须垂直居中对齐

图3
  • 子节点连接线的高度

如果使用第一元素对齐模式,那么这个连接线的高度是需要计算才能得出的

1、2、3作为0节点的子节点,连接0与1、2、3节点的那条竖向连接线的高度,是需要实时计算的,因为节点0、1、2、3都是可以自定义的,那么这四个节点的高度都是不一样的,竖向连接线的高度依赖于1、2、3以及1、2、3各自子节点的高度,当树状图初始化,或者节点展开/收缩的时候,这条竖向连接线的高度,都需要随之改变

对于第一点,我第一时间想到可以将 0、1、4、5放到同一个容器里,然后通过设置 vertical-align: middlecss属性进行解决,但是这样会打乱节点间的上下级结构关系,同时也会对后续的一些操作造成困扰,所以决定换种布局方式

图4

如上图,对于一个节点来说,既可能是父节点也可能是子节点,也可能同时是父节点和子节点,例如节点3是节点0的子节点,同时节点3也是节点6、7的父节点 那么,我们可以将父节点(无论是哪个层级的父节点,只要其有子节点则其就是父节点)及其所有的子节点(无论层级多么深的子节点都包含在内)看做一个整体,放入一个容器中

对于节点0来说,节点1、2、3、4、5、6、7都是其子节点,则将0、1、2、3、4、5、6、7共同放入一个容器中,也就是上图的蓝色虚线框 对于节点1来说,节点4、5都是其子节点,则将1、4、5共同放入一个容器中,也就是上图的青虚线框

对于节点3来说,节点6、7都是其子节点,则将3、6、7共同放入一个容器中,也就是上图的红色虚线框

对于节点4来说,节点5是其子节点,则将4、5共同放入一个容器中,也就是上图的黄色虚线框

对于其他没有子节点的节点来说,也可以看成是没有子节点的父节点,那么它们本身放入一个容器中

这种布局模式,保证了 DOM结构与数据结构一致,维持了父子层级间的关系

节点位置初始化

如果每个节点都位于其正确的位置,那么整个树状图自然也就是正确的了,想要保证每个节点位于其正确的位置,其实就是对于上面提到的两个问题的解决

父节点与其所有子层级下的第一个子节点保持垂直居中对齐

如果是横向垂直居中对齐模式,那么这个对齐问题是很好解决的,只需要将父节点单独看成一个容器,将父节点下所有的子节点单独看成一个容器,那么对两个容器应用 vertical-align: middle(或者其他的能够让两个平级元素垂直居中的方式)即可

图5

至于第一元素对齐模式这种,也可以将父节点及其所有层级下的第一个子节点放到同一个DOM层级下,然后使用 vertical-align: middle(或者其他的能够让两个平级元素垂直居中的方式)也可以,但是前面说了,这种 DOM布局方式会破坏层级结构,并且不利于后续的一些操作,而如果多个元素分布在多个容器内的话,是很难用 CSS来垂直居中

css不行,就只能借助 js了,而利用 js计算并操作 DOM的能力,其实也比较简单

通过深度优先遍历,就可以获取到父节点及其所有子层级下的第一个子节点的DOM了,然后从这些节点中找到高度最大的那个节点,其高度就作为整个容器的高度,其他节点以这个高度作为垂直居中对齐的基准即可

比如对于上面图4中的节点0、1、4、5四个节点,高度最高的是节点4,则节点4位置不需要变化,通过调整节点0、1、5的y轴位置,保证与节点4垂直居中对齐(我这里采用了 margin-top

function computedParentElesPosition(parentEles: HTMLElement[]) {
  const heights = parentEles.map(ele => ele.offsetHeight)
  // 找出最大高度
  const maxHeight = Math.max.apply(null, heights)
  const halfMaxHeight = maxHeight / 2
  parentEles.forEach((ele, index) => {
    if (heights[index] < maxHeight) {
      ele.style.marginTop = halfMaxHeight - heights[index] / 2 + 'px'
    } else {
      ele.style.marginTop = '0'
    }
  })
}
复制代码

子节点连接线的高度

如果是横向垂直居中对齐模式,其实仅通过 css照样可以解决

图6

如上图,对于父节点0来说,其直接子节点1、2、3连成的竖向连接线的高度,其实就是节点1及其所有层级子节点共同所在容器的高度的一半,加上节点2所在容器的高度,加上节点3及其所有层级子节点共同所在容器的高度的一半

如上图,竖向连接线的高度,就是所有子节点中:第一个子节点高度一半 + 最后一个子节点高度一半 + 其他所有子节点的高度

这里说的子节点高度,包括的是子节点及其所有层级下所有子节点组成的一个大容器的高度

例如,对于节点 1来说,这个高度是指节点1及其节点4、5、6、7共同组成的大容器的高度,也就是图中的红色虚线框的高度,另外为了保持树状图的协调性,父节点下的子节点间上下会存在一定的间距,这部分间距也作为大容器的一部分

那么就可以利用这些子元素所在的大容器,来拼接竖向连接线,对于节点1、4、5、6、7所在的红色虚线大容器元素来说,其左边框的下半部分就可以作为竖向连接线的一部分,通常利用一个高度是红色虚线大容器元素高度一半的伪元素即可完成;

对于节点2,其所在大容器的整个左边框都是竖向连接线的一部分;

对于节点3、8、9所在的青色虚线大容器元素来说,其左边框的上半部分就可以作为竖向连接线的一部分,通常利用一个高度是青色虚线大容器元素高度一半的伪元素即可完成;

这样,就完成了竖向连接线的拼接,至于那些横向的连接线,就更简单了,一般也是通过伪元素完成

但是如果换成第一元素对齐模式,就没那么简单了

第一元素对齐模式,对齐的是所有层级下的第一个子节点,这些节点不在同一个层级下,并且其本身高度、有无子节点、子节点高度、有几层子节点等都是不确定的,子节点竖向连接线的起始点位置和高度是无法前置确定的,所以需要通过计算来完成

不过也不难,只需要找到父节点下所有子节点中的第一个子节点和最后一个子节点,通过计算二者之间的位置,即可得到竖向连接线的高度,因为DOM结构维持了父子间的层级结构,所以还是很容易得到的

比如对于图4中的节点0来说,连接器子节点的竖向连接线的高度,就是:

Node3.bottom - Node1.top - Node1.height / 2 - Node3.height / 2
复制代码

当然,除了高度之外,竖向连接线的位置也需要进行计算,就比较简单了

树状图更新

节点的收缩/展开和节点内容高度的变化,必然会影响到树状图的布局

对于横向垂直居中对齐模式,无需额外操作,节点收缩/展开之后,自动应用 css

图7

例如对于上图,节点1收缩后,节点4、5、6、7从树状图中删除,节点1、4、5、6、7所在的大容器元素,即红色虚线框元素高度自动变小,节点1、2、3所在的大容器组成的竖向连接线高度随之变小,节点0与这个大容器垂直居中对齐,那么也会自动改变位置

但是如果是第一元素对齐模式,就要麻烦点了,还是需要进行计算

更新可以看成是整体树状图的再一次初始化,通过整体的重新初始化来解决,但这肯定不太友好,一是页面会发生跳动,还可能影响到用户的操作(比如滚动条的变化),二是肯定存在大量的重复计算,没有实现性能最优,不符合追求极致的理念

大部分的更新,只是局部更新,只影响到局部的少部分节点,没有必要整个树状图都重新初始化一遍

图8

例如,对于上图的节点2来说,其将子节点4收缩起来(同时节点5也就随之不可见了),那么将会影响到两个地方

  1. 节点2所在容器高度 之前节点2、4、5三个节点,以节点4为基准进行垂直居中对齐,现在节点4、5没了,还剩下节点2,那么节点2的高度就是容器的高度,其应当恢复到其原有的位置,不需要跟任何节点保持垂直居中对齐

  2. 子元素连接线高度 之前节点2、4、5所在容器的高度,比现在只有节点2所在的容器高度要高,那么连接节点1、2、3的竖向连接线的高度,肯定就变短了,需要重新计算高度

除此之外,由于本组件支持节点在初始化的时候就允许其子节点不展开(也就是可能存在有的子节点并没有在初始化的时候展开)的情况,所以如果节点的子节点是第一次展开,那么就需要将这个节点看成是一棵小的树状图,对其进行局部的初始化,以计算其下首次展开的子节点的位置关系

而在节点2上方的节点0和节点1,不会有任何变化,这是一个规律,那就是节点的变动,只会影响到它同层以及下方的节点,其上方的节点不会受到任何影响,那么在计算的过程中,就不需要将上方的元素也一并计算了

小结

本组件虽然是基于 Vue的,但实际上存在很多直接的DOM计算,这是追求组件定制化的代价,如果不采用第一元素对齐模式,而是普通的横向垂直对齐模式,那么渲染的结果基本上即是最终结果,无需任何DOM调整,不过这也并不代表第一元素对齐模式性能就很差了,通过命中精准的必要逻辑和缓存等手段,可以减少大量的无意义计算

在我的电脑上,初始化一个有100个节点的树状图,耗时 30ms,就算是一般的中档次电脑,测试结果应该也差不了多少,因为哪怕是没有使用任何的优化措施,这点计算量对于cpu来说也根本不算瓶颈,而实际场景中,展示给用户的树状图,很难有100个节点之多(如果超过100个,就要好好想想一下子给用户展示那么大的信息量是不是合理的),所以完全够用了

代码已经放到 github了,本组件也封装成了 npm 包,有兴趣的可以看下

文章分类
前端
文章标签