React实战 Tree组件简单实现

5,072 阅读8分钟

一、前言

  这个简单树组件也是自己为了熟悉react而做的,所以只是简单实现了基本功能,但是做完之后感觉其中也有很多需要自己总结的地方。其中还有几个未解决的问题自己在解决之后会修改这篇博文。话不多少,首先来看看我们到底要实现什么效果

在这里插入图片描述
我们的目标很简单,就是实现这样一个功能就行了,但是注意需要有一个半勾选的状态,就是父节点的某个子结点被选中了,那么这个结点就是半勾选状态。

二、思路分析

  当我们有了目标之后,就可以先整理一下整体思路。树组件的根本思想就是维护一个数据结构,然后通过这个数据结构来管理各个子结点的渲染。其中最为重要的也是它的选中/半选/未选状态的改变。因为你改变一个结点的状态后会影响到其他结点。那么,我们可以有一个实现树组件非常直观的思路:

  • 我给Tree组件传入一个完整的数据结构,类似下面这样:
[
	{
		name:"root", 
		children: [
			{
				name: "1", 
				children: [
					{
						name: "1-1", 
						children: []
					}
				]}
			{
				name: "2", 
				children: []
			}
		]
	}
]

  随后Tree组件利用这个数据结构自己将全部结点渲染出来。

  • 在每个结点上保存子节已被全选的个数和半选的个数,通过和children的长度进行比较,确定当前结点的状态。
  • 当一个结点被点击的时候,从头开始遍历整个数据结构,直到找到对应结点,同时,保存下找到结点经过的父节点。状态被影响了的结点就是这些父节点。

  这样,我们就能实现一个树组件的基本功能。但是,这样有下面几个问题:

  • 不易使用: 我们需要为tree组件传入一个完整的数据结构,当树很复杂时很难使用。
  • 数据结构是一个树形结构,树很深时遍历会存在性能问题
  • 每个结点的状态是由自己来判断的,我们应该有一个统一的机制,比如由父节点统一来判断各个子结点的状态,这样更加合理。   自己在最初实现的时候就是依照这个思路进行实现的,最后也实现了基本效果。随后自己在网上参考了一个开源项目进行实现,对思路进行了调整。项目地址为: github.com/react-compo… 该项目有500多个start,所以应该还是有一定参考意义的。别问我为什么没有看antd的源码,问就是看不懂。

  所以,对思路进行调整之后,我们这样来实现一个树:

  • 使用Tree + TreeNode的方式来创建一个树,具体可以参考antd树组件的使用方式,那么很显然,我们不能直接获得 一个完整的数据结构,所以需要自己在内部进行处理。同时,因为在Tree上有一些公有的属性,但是TreeNode没办法获得这些属性,在开源项目中,他是通过使用react的context来处理的,在我的实现中我采取了父组件一路通过props传递属性实现。
  • 在判断子结点的状态时,我们可以直接为其传入一个bool值来决定他的状态。但是我们不可能在treeNode上手动计算这个bool值,所以我们需要做一层代理,根据某种逻辑计算出这个结点的状态,然后使用React.cloneElement在treeNode上加上他的状态。
  • 对于子结点状态的判断,我们之前说过,应该由父节点统一决定。所以我们在父节点上维护几个数组,分别保存 全选/半选/展开 状态下各个结点的key值。同时,我们在初始化时,需要遍历一遍整个结构,生成一个打平的数组来保存整个树。当点击某个结点后,使用该数据结构和旧的状态数组生成新的状态数组,再有父节点统一下发各结点状态。
  • 对于如何判断结点状态,我们采取下列逻辑:对于某个结点,我们设置checked和halfchecked,他们的初始值分别为true和false。随后遍历其子结点,如果子结点为全选状态,设置其halfchecked为true,否则设置其checked为false。这样遍历完字节点后我们就能得到当前结点的状态checked和halfchecked。checked的优先级高于halfchecked。如果两个都为false证明这个结点是未选中结点。

三、具体实现

  有了基本思路以后,我们来具体实现:   首先是对其进行遍历来生成一个初始的数据结构,方便我们后续使用。getDerivedStateFromProps是react16中的一个新的生命周期函数。

//tree.js
 static getDerivedStateFromProps(props, state) {
   const treeNodes = props.children;
   const entities = {};
   for(let i = 0, len = treeNodes.length; i < len; i++) {
     const node = treeNodes[i];
     convertTreeToEntities(node, i+"", entities, null);
   }
   const newState = {   
     keyEntities: entities,
     treeNodes: treeNodes
   }
   return newState;
 }

//until.js
export function convertTreeToEntities(treeNode,key, entities, parentKey) {
  // console.log(key)
  let entity = {
    key,
    childKey: [],
    parentKey: parentKey
  }

  entities[key] = entity;

  const { children } = treeNode.props;
  mapChildren(children, (item, index) => {
    entity['childKey'].push(key + "-" + index);
    return convertTreeToEntities(item, key+"-"+index, entities, key)
  })
}

export function mapChildren(children, f) {
  if(Array.isArray(children)) {
    return children.map((item, index) => f(item, index))
  }else if(children) {
    return f(children, "0");
  }

  return null;
}

  最后生成的数据结构是下面这种:

在这里插入图片描述
  这个对象以结点的key值为键值,保存了结点的key值,字节点key值,父结点key值。所以通过这个数据结构我们可以遍历到树中的每个结点。   随后是结点的逻辑判断函数

export function conductCheck(clickKey, checkStatus, keyEntities) {
  const checkedKeys = {},
        halfCheckedKeys = {};
  (checkStatus.checkedKeys || []).forEach((key) => {
    checkedKeys[key] = true;
  });
  (checkStatus.halfCheckedKeys || []).forEach((key) => {
    halfCheckedKeys[key] = true;
  });
  let conduct = (parentNode) => {
    let checked = true,
        halfChecked = false;
    let { childKey } = parentNode;
    // console.log(children)
    if(childKey.length > 0) {
      mapChildren(childKey, (key) => {
        if(key in checkedKeys && checkedKeys[key]) {
          halfChecked = true;
        }else {
          checked = false;
        }
      })
    }else{
      return ;
    }
    
    halfCheckedKeys[parentNode.key] = halfChecked;
    checkedKeys[parentNode.key] = checked;
  }
  let conductDown = (currNode) => {
    let { childKey } = currNode;
    conduct(currNode);
    // console.log(keyEntities[node.Key])
    mapChildren(childKey, (key) => conductDown(keyEntities[key]));
  }

  let conductUp = (currNode) => {
    conduct(currNode);
    
    if(currNode.parentKey) {
      conductUp(keyEntities[currNode.parentKey]);
    }
  }

  const currNode = keyEntities[clickKey];
  conductUp(currNode);
  conductDown(currNode);
  
  let temp = {
    halfCheckedKeys: Object.keys(halfCheckedKeys).filter((item) => halfCheckedKeys[item]),
    checkedKeys: Object.keys(checkedKeys).filter((item) => checkedKeys[item])
  };
  return temp;
}

  整个逻辑就是上文思路分析中的逻辑,当理清了之后写起来还是比较快的。   第三就是如何为各个子结点设置状态:

  renderTreeNode(child, key){
    return React.cloneElement(child, {
      Key: key,
      initKey: child.key,
      isHalfChecked: this.props.halfCheckedKeys.includes(key),
      isChecked: this.props.checkedKeys.includes(key),
      isExpanded: this.props.expandedKeys.includes(child.key),
      halfCheckedKeys: this.props.halfCheckedKeys,
      checkedKeys: this.props.checkedKeys,
      expandedKeys: this.props.expandedKeys,
      keyEntities: this.props.keyEntities,
      onNodeClick: this.props.onNodeClick,
      updateExpanded: this.props.updateExpanded,
      isSingle: !('children' in child.props)
    })
  }

  这就是我们实现传递状态的主要函数。我们利用状态数组,判断当前key值是否在数组中来判断结点状态,然后通过React.cloneElement创建一个新的jsx对象返回。最后,在渲染的时候我们可以根据bool值来加载不同的样式展现不同的效果。完整代码在此。

四、反思

在自己使用两种实现方式实现了组件之后,对两种思路进行了对比。

  • 使用为tree传递整个数据结构的方式对于后续处理肯定更加方便,但是在实际使用中会有很大的困难
  • 在使用Tree+TreeNode的方式时,初始化需要遍历两次结构,第一次需要遍历以后生成一个数据结构,第二次才是真正的渲染结点,相对于第一种方式来说增加了开销,但是对于后续处理取得的好处是显而易见的(强行说明我的思路也不是一无是处嘛)
  • 为了避免树层级过深,产生的数据结构过深而产生性能瓶颈,采取将数组打平的方式。但是在深入源码的过程中发现,第二种方式存储的children结点,也是一个完整的对象,有的类似于我在最开始时使用的嵌套数据结构,我暂时还不明白这样做的目的,我觉得当树很深的时候这个数据结构的嵌套也会很深。使用一个打平的一层数据结构也可以完成功能,所以我在实现的时候生成的数据结构只有一层。

五、结语

  这篇博客的主要目的不是为了介绍树的实现,主要是为了记录自己的一次学习历程吧。通过比较自己的实现和他人的实现可以让自己学习一些东西,比如说项目代码的结构以及巧妙的实现逻辑,设计结构。比如说我们应该通过Tree结点来分发状态,这样思路比较明晰,对于有利于后续代码的修改。同时自己也感受到了一个真正可复用的组件要考虑的东西其实是非常多的,也希望自己有一天可以写出这种开源项目的代码吧!