前言
业务需要有个弹窗
- 左侧 tree 展示。
- 左侧 tree 模糊搜索,并且高亮匹配的节点。
- 左侧 tree 节点选择时,一并勾上相同key的节点。并且,父子节点不联动选择。
- 右侧回显选择结果时,去重显示。
并且,需要兼容所有后续的接口
然后这个业务的数据源有个问题:
- 子树多维度。比如下图子树其实有两个,一个
userDTOList
,一个childList
- 节点key不唯一。比如下图,节点A的子树和节点B的子树,存在相同的key节点
本文就是记录一下处理的过程
实现思路
分为下面几个方面
-
配置项
- 不同的接口可能使用不同的字段。比如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
是个数组,有多少维度子树,数组长度就为多少。需要指定每个维度树节点的id
、parentId
、children
、title
分别对应数据源的哪个字段- 通过 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 如下:
可以很清楚的看见相同的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_3
和3
,我们得到的结果都是3
,即节点本身的 key 就是3
- 比如
-
然后通过 map 就可以拿到 key 映射的节点列表。直接把结果回填到
checkedNodes
即可
比如假如我们之前选择了 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),
};
};
结尾
实现思路和过程大致这样