Tree组件深度解读

1,616 阅读9分钟

以arco为例,探讨下树形组件的具体实现思路:

1.tree组件结构是什么样的

首先看tree组件的react结构:

image.png

首先最外层的是tree组件,使得整个树作为一个整体被调用,向内是Context.Provider组件,用以让子孙组件共享数据,第一个Anonymous组件后有ForwardRef字眼,是tree-list组件,其内部的三个Anonymous是tree-node组件,有着各自的key

image.png

image.png tree组件对node-list组件有操作需求,而node组件属性较多,为避免不必要的变动,使用memo缓存组件。

至此tree组件内部的组织结构已明晰:

image.png

tree组件负责维护整个树的状态和事件,node-list组件负责渲染node组件和判断是否使用虚拟列表,node组件负责单个节点状态管理与事件

2.如何实现树形目录结构:

image.png

要点如下:

2.1 层次结构

tree的dom结构分为两种:

1.由treeData生成

image.png

2.由tree内嵌的dom生成

image.png

这两种结构统一由getTreeData方法转化为treeData形式

image.png

之后我们要基于最基本的原始数据进行node节点的属性拓展,例如checkable,children属性等都需要拓展进入node节点的属性内,最后组成一个nodeList集合

getNodeList = (treedata, prefix?) => {
  this.key2nodeProps = {};
  const prefixCls = prefix || this.context.getPrefixCls('tree');
  const nodeList = [];
  let currentIndex = 0;
  const { showLine, blockNode } = this.getMergedProps();

  const loop = (treeData, father) => {
    const totalLength = treeData.length;

    return treeData.map((data, index) => {
      const {
        children,
        selectable,
        checkable,
        key = `${father?._key || ''}-${index}`,
        ...rest
      } = this.getFieldInfo(data);
      const nodeProps: NodeProps & { children?: NodeProps[] } = {
        // data 中含有dataRef时,优先级较高
        dataRef: data,
        draggable: this.props.draggable,
        selectable,
        checkable,
        showLine,
        blockNode,
        ...data,
        ...rest,
        key,
        children,
        _key: key,
        _index: currentIndex++,
        parentKey: father ? father._key : undefined,
        pathParentKeys: (father && father.pathParentKeys) || [],
        _level: father._level || 0,
        // 保存node在sowLine模式下是否显示缩进线。如果父节点是其所在层级的最后一个节点,那么所有的子节点(包括孙子节点等)在父节点所在层级的缩进格都不显示缩进线。
        _lineless:
          father && father._lineless ? [...(father._lineless || []), father._isTail] : [],
      };

      if (totalLength === index + 1) {
        nodeProps.className = cs(`${prefixCls}-node-is-tail`, nodeProps.className);
      }

      nodeList.push(nodeProps);
      this.key2nodeProps[key] = nodeProps;

      if (children && children.length) {
        this.key2nodeProps[key].children = loop(children, {
          _key: key,
          _level: nodeProps._level + 1,
          _lineless: nodeProps._lineless,
          _isTail: totalLength === index + 1,
          pathParentKeys: [...(father?.pathParentKeys || []), key],
        });
      }
      return nodeProps;
    });
  };

  loop(treedata || [], {});
  return nodeList;
};

这个nodeList用在哪里呢?用在生成childrenList来形成node节点

image.png image.png image.png

注意这其中有个visibleKeys储存着节点展开信息的Set集:

image.png

回到形成节点信息那里:

image.png

因为是map数组形成node的数组集合,所以节点的dom关系是平级的,而不是父节点内嵌子节点,这为dom的渲染节省了很大一笔性能。

image.png

2.2 缩进处理

既然子树与子树的子树在dom结构上是平级的,那么在视图上是如何呈现出父子关系呢?

大部分树组件会通过缩进的不同来呈现父子关系,arco亦是如此,处理如下:

image.png

props._level存储了当前树节点的父级节点集合,遍历当前的集合,每有一个父级节点就增加一个indent-block,这个indent-block是个宽度为14px的span

image.png

说白了就是生成树节点信息时会依据树的层次在前面增加若干个宽度一定的块,以此来呈现缩进效果表明父子从属关系。

3.节点checkbox的选择状态是如何判断的?

我们以节点checkbox的选择状态为例,实际上通过此例子也能明晰全部node节点状态的获取与维护。

选中一个子节点时,父节点和爷节点呈现半选中状态,当逐个选中所有子节点或者点击父节点的选中键时,父节点又会变为全选状态。

image.png

此处通过getCheckedKeysByInitKeys拿到半选中节点的集合,因为选中一个节点后,要更新其所有的父节点状态,当且仅当父节点没有被选中时进行更新。

image.png

只有当父节点的路径都不包含当前选中的节点时,进行updateParent方法

image.png

在updateParent函数中对父节点进行状态更新,注意红框中的代码,如果被选中,则num+1,如果被半选,则num+0.5,最后当num等于总共的子节点数时,在半选集合中删除该父节点,同时在全选集合中加入该节点,若num不等于总共的子节点数就在半选集合中新增该节点,最后在tree组件的constructor阶段把indeterminateKeysSet作为halfCheckedKeys绑定在tree的state上,有了这个半选状态合集我们只需要判断单个node是否在这个集合中就行,arco封装了一个getNodeProps方法,传入一个node,连带着半选状态和其他一些状态,将此方法传入tree-node组件,即可得到每个node的props合集供node使用。

getNodeProps = (nodeProps, expandedKeysSet) => {
  const { autoExpandParent } = this.getMergedProps();
  const { loadMore } = this.props;

  const {
    selectedKeys,
    expandedKeys,
    checkedKeys,
    halfCheckedKeys,
    loadingKeys = [],
    loadedKeys = [],
  } = this.state;
  const hasChildren = nodeProps.children && nodeProps.children.length;
  const otherProps: NodeProps = {
    isLeaf: !hasChildren,
    autoExpandParent: hasChildren ? autoExpandParent : false,
    expanded: expandedKeysSet
      ? expandedKeysSet.has(nodeProps._key)
      : expandedKeys.indexOf(nodeProps._key) > -1,
  };
  if (loadMore) {
    const loaded = loadedKeys.indexOf(nodeProps._key) > -1;
    otherProps.loaded = loaded;
    otherProps.isLeaf = hasChildren ? false : nodeProps.isLeaf;
  }
  return {
    ...nodeProps,
    ...otherProps,
    selected: selectedKeys && selectedKeys.indexOf(nodeProps._key) > -1,
    indeterminated: halfCheckedKeys?.indexOf(nodeProps._key) > -1,
    loading: loadingKeys.indexOf(nodeProps._key) > -1,
    checked: checkedKeys && checkedKeys.indexOf(nodeProps._key) > -1,
    selectedKeys,
    checkedKeys,
    loadingKeys,
    loadedKeys,
    expandedKeys: this.state.expandedKeys,
    childrenData: nodeProps.children || [],
    children: null,
  };
};

getNodeProps核心在于从现有的数据集里返回当前查询的node的props,例如这段代码在判断节点是否半选时是依据halfCheckedKeys(即我们费尽心思拿到的tree半选数据集)是否包含现有节点的key

{childrenList.map((item) => {
  const node = <Node {...item} {...getNodeProps(item, expandedKeysSet)} key={item.key} />;
  saveCacheNode(node);
  return node;
})}

可以看到,node组件会使用getNodeProps拿到当前节点的拓展props

4.展开节点

4.1 如何外部控制展开的节点?

外部可通过

1.autoExpandParent(是否自动展开父节点,默认为true)

2.defaultExpandedKeys(默认展开的节点)

3.expandedKeys(展开的节点(受控))

这些props控制节点展开,在生成tree实例时会调用getInitExpandedKeys方法判断需要展开的树节点,见如下代码:

getInitExpandedKeys = (keys) => {
  if (!this.getMergedProps().autoExpandParent) {
    return keys || [];
  }
  if (!keys) {
    return Object.keys(this.key2nodeProps).filter((key) => {
      const props = this.key2nodeProps[key];
      return props.children && props.children.length;
    });
  }
  const expandedKeys = {};
  keys.forEach((key) => {
    const item = this.key2nodeProps[key];
    if (!item) {
      return;
    }
    expandedKeys[key] = 1;

    if (item.pathParentKeys) {
      item.pathParentKeys.forEach((x) => {
        expandedKeys[x] = 1;
      });
    }
  });

  return Object.keys(expandedKeys);
};

其中key2nodeProps即是key to node props,即依据node.key找到node.props

而getInitExpandedKeys又是在constructor中调用赋值给expandedKey的

image.png

这说明了autoExpandParent仅在Tree第一次挂载的时候生效。如果数据是从远程获取,可以在数据获取完成后,再去渲染Tree组件

4.2 展开节点是如何进行动画过渡的?

003_.gif

注意看展开收起的react组件,展开瞬间会有一个一闪而过的组件:

image.png

这其实是因为树节点展开时分为展开阶段和展开后,展开阶段如果需要过渡动画会用一个虚拟列表(即图中的VirtualList)占位,待展开后再渲染出子节点:

展开节点进入switchExpandStatus方法:

const switchExpandStatus = useCallback(async () => {
  const { isLeaf, expanded } = props;
  if (isLeaf) {
    return;
  }
  // 加载更多
  if (!props.childrenData?.length && isFunction(treeContext.loadMore) && !expanded) {
    await treeContext.loadMore(props);
  } else {
    // 单纯展开
    setExpand(!expanded);
  }
}, [props, setExpand, treeContext.loadMore]);

若没有设置loadMore加载,则进入setExpand方法:

const setExpand = useCallback(
  (newExpand: boolean) => {
    if (newExpand === expanded) {
      return;
    }
    treeContext.onExpand && treeContext.onExpand(newExpand, _key);
  },
  [expanded, treeContext.onExpand]
);

调用treeContext上的onExpand方法,进入handleExpand方法:

handleExpand = (expanded: boolean, key: string) => {
  const { currentExpandKeys, expandedKeys = [] } = this.state;
  const { onExpand } = this.props;
  if (currentExpandKeys.indexOf(key) > -1) {
    // 如果当前key节点正在展开/收起,不执行操作。
    return;
  }
  let newExpandedKeys = [];
  if (expanded) {
    newExpandedKeys = Array.from(new Set([...expandedKeys, key]));
  } else {
    newExpandedKeys = expandedKeys.filter((k) => k !== key);
  }
  if (!('expandedKeys' in this.props)) {
    this.setState({
      expandedKeys: newExpandedKeys,
      currentExpandKeys: [...currentExpandKeys, key],
    });
  }
  onExpand &&
    onExpand(newExpandedKeys, {
      expanded,
      node: this.cacheNodes[key],
      expandedNodes: newExpandedKeys.map((x) => this.cacheNodes[x]).filter((x) => x),
    });
};

这个方法把展开(收起)的节点的key放入currentExpandKeys数组中,维护一个正在展开(收起)的节点集合,此时currentExpandKeys数据传给AnimationNode组件,改变CSSTransition组件的in属性为true(in 为控制动画开启关闭的“开关”,true为开启,false为关闭),开启动画

image.png

image.png

待展开(收起)动画结束后进入treeContext上的onExpandEnd方法:

handleExpandEnd = (key) => {
    const { currentExpandKeys } = this.state;
    if (currentExpandKeys.indexOf(key) > -1) {
      this.setState({
        currentExpandKeys: currentExpandKeys.filter((v) => v !== key),
      });
    }
  };

将当前刚刚展开(收起)完毕的节点的key从currentExpandKeys中移除,AnimationNode的in属性为false,动画组件消失,此时如何将真实dom组件呈现出来呢?

答案还在于currentExpandKeys中当前key移除的这一步:

const visibleKeys: Set<string> = useMemo(() => {
  const newKeys = new Set<string>();
  const currentExpandKeysSet = new Set(currentExpandKeys);
  nodeList.forEach((nodeProps) => {
    const pathParentKeys = nodeProps.pathParentKeys || [];
    // 如果父节点处于正在展开状态,子节点暂时不可见,因为父节点的children会在animation中渲染出来。
    // 当动画完成时,父节点children隐藏,此时在这里渲染子节点。 anyway,一切为了动画!!!
    if (
      pathParentKeys.every((key) => !currentExpandKeysSet.has(key) && expandedKeysSet.has(key))
    ) {
      newKeys.add(nodeProps._key);
    }
  });
  return newKeys;
}, [expandedKeysSet, currentExpandKeys, nodeList]);

currentExpandKeys改变触发visibleKeys重新计算,又触发了依赖于visibleKeys的calcChildrenList重新计算展示的子节点

const calcChildrenList = useCallback(() => {
    return nodeList.filter((item) => {
      const pass = !filterNode || (filterNode && filterNode(item));

      if (pass && visibleKeys.has(item.key)) {
        return true;
      }
      // 过滤掉的也缓存一下,避免被收起的节点在onSelect回调中,selectedNodes出现undefined
      saveCacheNode(<Node {...item} {...getNodeProps(item)} key={item.key} />);
      return false;
    });
  }, [nodeList, filterNode, visibleKeys]);

calcChildrenList改变触发重新update:

useUpdate(() => {
    setChildrenList(calcChildrenList());
  }, [calcChildrenList]);

即在dom上更改了数据源childrenList,从而实现dom的更新:

image.png

流程图如下

image.png

5.拖拽功能

拖拽分为三种形态:

  1. 放在目标node之上,放下后目标元素与被拖拽元素呈现同级关系并排序在目标元素之前
  2. 放在目标node之内,放下后目标元素与被拖拽元素呈现父子关系并在目标元素之内
  3. 放在目标node之下,放下后目标元素与被拖拽元素呈现同级关系并排序在目标元素之后

对应的拖拽动作和独特class如下红框所示:

image.png

对于目标元素而言:绑定的onDragOver属性绑定有以下方法:

image.png

注意此时传入的e就是目标元素(非被拖拽元素),进入updateDragOverState方法内:

const updateDragOverState = useCallback(
  throttleByRaf((e) => {
    const dom = nodeTitleRef.current;
    if (!dom) return;
    const rect = dom.getBoundingClientRect();
    const offsetY = window.pageYOffset + rect.top;
    const pageY = e.pageY;
    const gapHeight = rect.height / 4;
    const diff = pageY - offsetY;
    const position = diff < gapHeight ? -1 : diff < rect.height - gapHeight ? 0 : 1;
    const isAllowDrop = treeContext.allowDrop(props, position);

    setState({
      ...state,
      isAllowDrop,
      isDragOver: true,
      dragPosition: position,
    });
    treeContext.onNodeDragOver && treeContext.onNodeDragOver(e, props, position);
  }),
  [treeContext.onNodeDragOver]
);

利用被拖拽元素与目标元素的绝对位置之差得到diff这个相对位置,依据相对位置的大小得出dragPosition这一属性,此处dragPosition只有-1,0,1三种取值

dragPosition: 0 | -1 | 1;

dragPosition为0,代表要拖拽进入目标元素内,dragPosition为-1,代表要拖拽到目标元素之上,dragPosition为1,代表要拖拽到目标元素之下。

当放下时会触发onDrop方法:

image.png

触发treeContext上的onNodeDrop方法,触发handleNodeDrop再触发外界传给tree的onDrop方法:

例如拖拽的方法如下:

onDrop={({ dragNode, dropNode, dropPosition }) => {
  const loop = (data, key, callback) => {
    data.some((item, index, arr) => {
      if (item.key === key) {
        callback(item, index, arr)
        return true
      }
      if (item.children) {
        return loop(item.children, key, callback)
      }
    })
  }
  const data = [...treeData]
  let dragItem
  loop(data, dragNode.props._key, (item, index, arr) => {
    arr.splice(index, 1)
    dragItem = item
    dragItem.className = 'tree-node-dropover'
  })
  if (dropPosition === 0) {
    loop(data, dropNode.props._key, (item, index, arr) => {
      item.children = item.children || []
		// 在dropPosition等于0时放入node内部
      item.children.push(dragItem)
    })
  } else {
    loop(data, dropNode.props._key, (item, index, arr) => {
		// 在dropPosition小于0时在node之前添加,大于0时在node之后添加
      arr.splice(dropPosition < 0 ? index : index + 1, 0, dragItem)
    })
  }
  setTreeData([...data])

  setTimeout(() => {
    dragItem.className = ''
		// 更新数据
    setTreeData([...data])
  }, 1000)
}}

这段代码的核心是依据dropPosition的不同对原treeData数据进行更改,dropPosition等于0时放入node内部,在dropPosition小于0时在node之前添加,大于0时在node之后添加,至此即实现了拖拽前后的核心操作

6.滚动至某一节点

滚动时约定边界为tree组件内,传入滚动的节点,利用scroll-into-view-if-needed包的scrollIntoView方法进行滚动,详细见:github.com/stipsan/scr… 若为虚拟列表则直接调用虚拟列表组件内的scrollTo方法。

scrollIntoView: (_index, nodeProps) => {
  let index = _index;
  const isKey = typeof _index === 'string';

  if (isKey) {
    let key = _index;
    // 查找离得最近的可见的父节点,进行滚动。
    if (!visibleKeys.has(_index) && nodeProps && nodeProps.pathParentKeys) {
      key =
        [...nodeProps.pathParentKeys].reverse().find((key) => visibleKeys.has(key)) || index;
    }
    // _index attributes and index are not the same due to some hidden items
    index = childrenList.findIndex(({ _key }) => _key === key);
  }

  if (!isVirtual && treeWrapperRef.current) {
    const wrapperDom = treeWrapperRef.current;
    const node = wrapperDom ? wrapperDom.children[index] : null;

    node &&
      scrollIntoViewIfNeed(node as HTMLElement, {
        boundary: wrapperDom.parentElement,
      });
  } else if (virtualListRef.current) {
    virtualListRef.current.scrollTo({ index });
  }
},

7.关于缓存树

tree的多个node节点是经过复杂的计算与复合形成的,如果每次操作都要重新render出一个node的话太过消耗性能,因此把最终的node及其props存入cacheNodes对象中,建立一个由node.key到node的映射:

image.png

image.png

image.png

存储好cacheNodes后,render后的操作只需要传入key即可查到对应复合后的node,储存cacheNodes对于render之后进行的操作有很重要的意义,例如进行select选择时:

extra.selectedNodes = selectedKeys.map((x) => this.cacheNodes[x]);

可直接调用cacheNodes进行数据查询,而无需重新计算