多维度子树、树节点key不唯一的Tree组件封装

0 阅读6分钟

前言

业务需要有个弹窗

  • 左侧 tree 展示。
  • 左侧 tree 模糊搜索,并且高亮匹配的节点。
  • 左侧 tree 节点选择时,一并勾上相同key的节点。并且,父子节点不联动选择。
  • 右侧回显选择结果时,去重显示。

并且,需要兼容所有后续的接口

企业微信截图_17478996082782.png

然后这个业务的数据源有个问题:

  • 子树多维度。比如下图子树其实有两个,一个 userDTOList,一个childList
  • 企业微信截图_17478972838201.png
  • 节点key不唯一。比如下图,节点A的子树和节点B的子树,存在相同的key节点
  • 企业微信截图_17478973809793.png

本文就是记录一下处理的过程

实现思路

分为下面几个方面

  • 配置项

    • 不同的接口可能使用不同的字段。比如A接口转树节点时,key对应id,title对应userName。B接口转树节点,key对应key,title对应DeptName。
    • 有的接口可能有多维度子树(比如childList 和 userlist),有的接口可能只有单维度子树(比如chiList)
  • 转换树

    • 根据配置项,构造树。
    • 子树部分需要兼容多维度子树、单维度子树的情况。
    • 树节点可能根据不同的情况,需要额外的字段。
  • Map映射key和节点关系

    • 性能优化。减少树的遍历次数

实现代码

配置项

export type TTXTreeCascaderType = "deptUser";

export interface IConfig {
  /**@param 弹窗标题 */
  title: ReactNode;
  /**@param tree接口api */
  fetchApi: string;
  /**@param tree接口初始参数 */
  request: TRecord;
  /**@param 左侧输入框placeholder */
  placeholder: string;
  /**@param 右侧选择title */
  selectedTitle: string;
  /**@param 树节点配置信息*/
  nodeConfig: INodeConfig[];
}

export interface INodeConfig {
  /**@param 树节点的children字段名 */
  childrenKey: string;
  /**@param 树结点的title字段名 */
  searchFilterKey: string;
  /**@param 树节点key字段名*/
  key: string;
  /**@param 树节点paretnKey字段名 */
  parentKey: string;
}


export const deptUserConfig: IConfig = {
  title: "选择人员",
  fetchApi: "/api/base/v1/sys-user/dept-tree-and-user",
  request: {},
  placeholder: "请输入人员/部门名称",
  selectedTitle: "已选人员",
  nodeConfig: [
    {
      childrenKey: "childList",
      searchFilterKey: "deptName",
      key: "id",
      parentKey: "parentId",
    },
    {
      childrenKey: "userDTOList",
      searchFilterKey: "userName",
      key: "id",
      parentKey: "parentId",
    },
  ],
};

export const configMap = new Map<TTXTreeCascaderType, IConfig>([
  ["deptUser", deptUserConfig],
]);

/** @function 获取配置信息 */
export const getConfigMap = (type: TTXTreeCascaderType) => {
  return configMap.get(type);
};

说明:

  • nodeConfig 是个数组,有多少维度子树,数组长度就为多少。需要指定每个维度树节点的 idparentIdchildrentitle 分别对应数据源的哪个字段
  • 通过 getConfigMap 向外暴露配置项

转换树结构

首先是入口函数

/**@function 转换源树为指定树结构 */
export const transformOriginTree = (
  type: TTXTreeCascaderType,
  tree: TRecord[],
  onCustomExtraNodeKey?: (node: TRecord) => object
): TTXTreeCascaderNode[] => {
  const nodeConfig = getConfigMap(type)?.nodeConfig;
  const dealTree = mergeTreeMultipleChidlrens(
    tree,
    nodeConfig?.map((config) => config.childrenKey) ?? []
  );
  return transformMergeTreeMultipleTree(
    dealTree,
    nodeConfig,
    null,
    0,
    type,
    onCustomExtraNodeKey
  );
};

说明:

  • 先获取配置项
  • 调用 mergeTreeMultipleChidlrens 合并子树
  • 调用 transformMergeTreeMultipleTree 转换源树为指定结构树

然后是合并子树的操作

/**@function 初始节点合并多维度子树 */
export const mergeTreeMultipleChidlrens = (
  tree: TRecord[],
  fieldsToMerge: string[]
): TRecord[] => {
  return tree.map((node) => {
    const mergedNode = { ...node };
    const children: TRecord[] = [];

    fieldsToMerge.forEach((field) => {
      if (node[field] && Array.isArray(node[field])) {
        children.push(...node[field]);
        delete mergedNode[field];
      }
    });

    if (children.length > 0) {
      mergedNode.children = [...(mergedNode.children || []), ...children];
    }

    // 递归处理子节点
    if (mergedNode.children) {
      mergedNode.children = mergeTreeMultipleChidlrens(
        mergedNode.children,
        fieldsToMerge
      );
    }

    return mergedNode;
  });
};

说明:

  • fieldsToMerge 其实就是配置项 nodeConfig 中每一项的 childrenKey
  • 根据 childrenKey,把多维度子树都拼接到 children 下。由于业务中,基本返回的都是 childList,所以暂时没有覆盖源树chilren的情况
  • 返回合并后的结果

最后一步,处理多维度数为指定结构树

export interface ITXTreeDefaultCascaderNode extends TreeDataNode {
  /**@param 节点的源数据 */
  data?: TRecord;
  /**@param 是否高亮 */
  highLight?: boolean;
  /**@param 父节点id */
  parentId?: string;
  /**@param 层级 */
  level?: number;
  /**@param 其余自定义字段*/
  [key: string]: any;
}

// 转换后的树节点类型
export type TTXTreeCascaderNode = ITXTreeDefaultCascaderNode;

/**@function 处理多维度数为指定结构树 */
export const transformMergeTreeMultipleTree = (
  nodes: TRecord[],
  configs: INodeConfig[] | undefined = [],
  parentId: string | null = null,
  level: number,
  type: TTXTreeCascaderType,
  onCustomExtraNodeKey?: (node: TRecord) => object
): TTXTreeCascaderNode[] => {
  const txTreeMapHelper = TXTreeMapHelper.getInstance();
  return nodes.map((node) => {
    // 当前node是匹配哪一项配置
    const matchedConfigIndex = configs.findIndex((config) =>
      node.hasOwnProperty(config.searchFilterKey)
    );
    const matchedConfig = configs[matchedConfigIndex];
    const currentKey = level
      ? `${parentId}_${node[matchedConfig.key]}`
      : node[matchedConfig.key];
    const extraKeyValue = onCustomExtraNodeKey?.(node);
    const newNode: TTXTreeCascaderNode = {
      title: node[matchedConfig.searchFilterKey],
      key: currentKey,
      data: {
        ...node,
      },
      highLight: false,
      parentId: node[matchedConfig.parentKey] || parentId,
      level,
      ...(extraKeyValue ?? {}),
    };
    txTreeMapHelper.setTXTreeMapHelperMap(
      type,
      node[matchedConfig.key],
      newNode
    );
    // 递归处理子节点
    if (node.children) {
      newNode.children = transformMergeTreeMultipleTree(
        node.children,
        configs,
        currentKey,
        level + 1,
        type,
        onCustomExtraNodeKey
      );
    }

    return newNode;
  });
};

说明:

  • 根据之前 nodeConfig 配置项,构造指定树节点
  • 树节点的key是拼接了父节点key的。比如:1234_5678,1234是父节点的key,5678是节点本身的key
  • onCustomExtraNodeKey是由外部调用组件时,传入 props。此时回传 node 是接口返回的 node。方便自定义一些额外字段。
  • TXTreeMapHelper.getInstance() 获取单例Map对象,并构建映射关系

Map构建映射关系

由于树节点key不唯一,所以映射关系是 key一对多映射node

{
  key1: [
     node1,
     node2,
  ],
  key2: [
     node3
  ]
}

然后为了方便获取这个映射关系,TXTreeMapHelper是个单例对象,是个二维Map,结构如下

{
   'user': {
     key1: [node1, node2],
     key2: [node3],
     //...
   },
   
   'department': {
     key1: [node1, node2],
     key2: [node3, node4]
   }
}

这样不同的地方,如果调了相同的接口,都可以通过 user 或者 department 这个一级key,拿到对应的树节点映射关系。而且由因为是单例对象,所以只会构建一次。

然后在实际使用中,我们可以得到 map 如下:

企业微信截图_17478998482188.png

可以很清楚的看见相同的key对应多个子节点。并且转换后的节点key是拼接了父节点的key

回显选中态

回显时需要注意一个问题:

  • 如果是先打开弹窗选择后,关闭弹窗,然后再打开弹窗,此时 key 是拼接了父节点的 key。Tree组件能自动回显

  • 如果我们调一个详情接口,然后这个接口返回我们之前选择过的节点 key,此时 key 是只没有拼接父节点key的。

这种情况下回显的逻辑就依靠 map

/**@function 弹窗打开时,初始化选中态/
initTreeCheckedStatus() {
    const { propsStore } = this.rootStore;
    let result: TTXTreeCascaderNode[] = [];
    const initCheckedKeys: string[] =
      this.initData?.checkedNodes.map((node) => {
        let key = node.key as string;
        let newKey = key.includes("_") ? key.split("_")[node.level ?? 1] : key;
        return newKey;
      }) ?? [];
    const txTreeMapHelperMap = TXTreeMapHelper.getInstance().getTXTreeHelperMap(
      propsStore.props.type
    );
    initCheckedKeys.forEach((key) => {
      result = [...result, ...(txTreeMapHelperMap?.get(key) ?? [])];
    });

    this.checkedKeys = result.map((node) => node.key as string);
    this.checkedNodes = result;
}

说明:

  • 每一个节点的 key 通过 key.includes("_") ? key.split("_")[node.level ?? 1] : key 拿到节点本身的key。

    • 比如 1_2_33,我们得到的结果都是 3,即节点本身的 key 就是 3
  • 然后通过 map 就可以拿到 key 映射的节点列表。直接把结果回填到 checkedNodes 即可

企业微信截图_17479001746708.png

比如假如我们之前选择了 key 是 348913242698940451 的节点,通过 map 发现他对应两个节点。那这两个节点就作为 checkedNodes 即可。

然后 checkedkeys 就可以通过 checkedNodes.map((item) => item.key) 赋值

回显展开态

这个就简单些,直接帖代码:

initExpandStatus() {
    const keys = this.generateExpandKeysWithCheckedNodes();
    this.expandKeys = Array.from(keys);
    this.initExpandKeys = Array.from(keys);
}

generateExpandKeysWithCheckedNodes() {
    const keyMap = generateNodePath(this.originTransformTreeData);
    const keys = new Set<string>();

    const findParents = (key: string) => {
      const node = keyMap.get(key);
      if (node?.parentId) {
        keys.add(node.parentId);
        findParents(node.parentId);
      }
    };

    this.checkedNodes.forEach((node) => findParents(node.key as string));

    return Array.from(keys);
}

模糊查询

由于树节点以及转换了,所以直接匹配 title 即可

/**@function 模糊查询/
onSearch(value: string) {
    if (value.trim()) {
      const { filteredData, expandKeys } = filterTreeWithExpand(
        this.originTransformTreeData,
        value
      );
      this.treeData = filteredData;
      this.expandKeys = expandKeys;
    } else {
      this.treeData = [...this.originTransformTreeData];
      this.expandKeys = this.generateExpandKeysWithCheckedNodes();
    }
}

/**@function 过滤转换后的树,返回新的树及指定树节点的路径 */
export const filterTreeWithExpand = (
  tree: TTXTreeCascaderNode[],
  searchText: string
): { filteredData: TTXTreeCascaderNode[]; expandKeys: string[] } => {
  const expandedKeys: Set<string> = new Set();

  const filterFn = (nodes: TTXTreeCascaderNode[]): TTXTreeCascaderNode[] => {
    return nodes
      .map((node) => {
        const isMatch = (node.title as string)
          .toLowerCase()
          .includes(searchText.toLowerCase());
        const children = node.children
          ? filterFn(node.children as TTXTreeCascaderNode[])
          : undefined;
        const hasMatchedChild = children && children.length > 0;

        // 标记需展开的节点key(当前匹配或包含匹配子节点)
        if (isMatch || hasMatchedChild) {
          expandedKeys.add(node.key as string);
        }

        return {
          ...node,
          highLight: isMatch,
          children: hasMatchedChild || isMatch ? children : undefined,
        };
      })
      .filter(
        (node) =>
          node.children?.length ||
          (node.title as string)
            .toLowerCase()
            .includes(searchText.toLowerCase())
      );
  };

  return {
    filteredData: filterFn(tree),
    expandKeys: Array.from(expandedKeys),
  };
};

结尾

实现思路和过程大致这样