作者最近在搬砖时遇到这样一个需求:
需要实现一个双节点树的穿梭框(目前仅支持单层树结构)。即穿梭框的左右两侧皆为节点树结构。且支持双侧的搜索、全选、穿梭
在完成此需求时,遇到了许多问题和坑点,在此记录分享。
需求调查
首先对项目的组件库antd4.11.2的穿梭框组件的文档进行查阅:
通过观察代码发现左侧的树形结构是使用 Tree 组件实现,并且通过render Props的设计上提Transfer的各项参数及操作函数如下:
由此确定基于antd的Transfer和Tree实现一个双节点树的穿梭框组件
实现过程
这里准备封装成SingleTreeTransfer组件
数据准备
首先对源数据进行处理,转换成Tree、Transfer对应格式的数据并传入
传入defaultTargetKeys确定默认选中项,并且对某父级的所有子级都被选中,该父级节点也要被选中
onTransferChange函数中回调targetKeys参数给外层组件使用可自定义
Tree的checkStrictly使Tree的父子节点不再关联,影响了正常的交互,这里去掉该属性
双侧穿梭
这里通过Render Props的方式取得穿梭框参数。首先我们完成选中的逻辑:
- 如果选中父节点,则等于选中其所有子节点
- 需要通过状态变量和onItemSelectAll onItemSelect函数去同时控制Tree以及Transfer的选中
- 注意:这里通过Ref保存双侧onItemSelectAll函数以提供给全选给功能使用
然后完成穿梭的逻辑:
- 如果是穿梭到右侧,targetKeys合并keys;如果是左侧,则将moveKeys排除
- 如果某父级下的所有子级被选中,该父节点也被选中
- onTransferChange中传入targetKeys
搜索
- 搜索为空则返回原结构
- 搜索内容包括父节点及子节点title,暂无大小写兼容匹配(可根据实际情况调整)
全选
- 选中当前侧的所有子节点
- 包括Tree以及Transfer的选中
样式
需要让树过长时出现滚动条
最终效果展示
可优化点
- 涉及大量计算,导致数据过多时,交互不流畅
- 选中项穿梭后,未自动展开
- 全选可增加取消全选逻辑(类似Table的全选)
- 如何支持多层树结构
组件全部代码
import { Button, Transfer, TransferProps, Tree } from 'antd';
import type { TransferDirection, TransferItem } from 'antd/es/transfer';
import type { DataNode } from 'antd/es/tree';
import React, { Key, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line import/no-unresolved
import './style.less';
interface TreeTransferProps {
transferProps: TransferProps<TransferItem>; // Transfer组件的props
treeData: DataNode[]; // 传入的树形数据
defaultTargetKeys?: string[]; // 默认选中项
onTransferChange?: (targetKeys: string[]) => void; // 选中项变化的回调函数
}
const generateTree = (treeNodes: DataNode[] = [], checkedKeys: string[] = [], level = 1): DataNode[] => {
return treeNodes.map(({ children, ...props }) => ({
...props,
disabled:
level === 1
? checkedKeys?.includes(props.key as any) ||
children?.every((item) => checkedKeys?.includes(item.key as any))
: checkedKeys?.includes(props.key as any),
children: generateTree(children, checkedKeys, level + 1),
}));
};
// 生成右侧树(仅包含选中节点及其父路径)
const generateRightTree = (dataSource: DataNode[], targetKeys: string[]): DataNode[] => {
const nodeMap = new Map<string, DataNode>();
const buildMap = (nodes: DataNode[]) => {
nodes?.forEach((node) => {
nodeMap.set(node.key as string, node);
if (node.children) buildMap(node.children);
});
};
buildMap(dataSource);
const shouldInclude = (key: string): boolean => {
if (targetKeys.includes(key)) return true;
const node = nodeMap.get(key);
return !!node?.children?.some((child) => shouldInclude(child.key as string));
};
const buildTree = (nodes: DataNode[]): DataNode[] =>
nodes
?.filter((node) => shouldInclude(node.key as string))
?.map((node) => ({
...node,
children: buildTree(node.children || []),
}));
return buildTree(dataSource);
};
// 仅支持一层父子结构的双侧树形穿梭框组件
// 每一层父级必须要有其子级
// CheckedKeys列表都为子级的
const SingleTreeTransfer = (props: TreeTransferProps) => {
const { transferProps, treeData, onTransferChange, defaultTargetKeys } = props || {};
const onItemSelectAllRef = useRef<Record<TransferDirection, (dataSource: string[], checkAll: boolean) => void>>({
left: null,
right: null,
});
const [targetKeys, setTargetKeys] = useState<string[]>(defaultTargetKeys || []);
const [checkedKeys, setCheckedKeys] = useState<Record<TransferDirection, Key[]>>({
left: [
...defaultTargetKeys,
...treeData
?.filter((item) => item?.children?.every((item) => defaultTargetKeys?.includes(item?.key as string)))
?.map((item) => item?.key),
],
right: [],
});
const [searchValue, setSearchValue] = useState({
left: '',
right: '',
});
// 父级keys列表
const fatherKeys = useMemo(() => {
return treeData?.map((item) => item.key);
}, [treeData]);
const currentTreeData = (direction: TransferDirection) => {
const generateTreeData = {
left: generateTree(treeData, targetKeys),
right: generateRightTree(treeData, targetKeys),
};
if (!searchValue[direction]) {
return generateTreeData?.[direction];
}
const newTreeData: DataNode[] = [];
generateTreeData?.[direction]?.forEach((item) => {
if ((item?.title as string)?.includes(searchValue[direction])) {
newTreeData.push(item);
} else if (item?.children?.some((child) => (child?.title as string)?.includes(searchValue[direction]))) {
newTreeData.push({
...item,
children: item.children?.filter((child) =>
(child?.title as string)?.includes(searchValue[direction]),
),
});
}
});
return newTreeData;
};
const getItenSonKeys = (treeData: DataNode[], key: string) => {
return treeData?.find((item) => item.key === key)?.children?.map((item) => item.key);
};
const onChange = (keys: string[], direction: TransferDirection, moveKeys: string[]) => {
let targetKeysCur: string[] = [];
if (direction === 'right') {
targetKeysCur = targetKeys.concat(keys);
} else {
targetKeysCur = targetKeys?.filter((item) => !moveKeys.includes(item));
}
targetKeysCur = [...new Set(targetKeysCur)];
setTargetKeys(targetKeysCur);
setCheckedKeys({
left: [
...keys,
...treeData
.filter(
(item) =>
item.children?.length &&
item.children?.every((child) => keys.includes(child.key as string)),
)
?.map((item) => item.key),
],
right: [],
});
onTransferChange?.(targetKeysCur);
};
const onTransferSearch = (direction: TransferDirection, value: string) => {
setSearchValue({
...searchValue,
[direction]: value,
});
};
return (
<Transfer
// dataSource={flattenData}
targetKeys={targetKeys}
onChange={onChange}
render={(item) => item.title}
className="tree-transfer"
showSelectAll={false}
showSearch
onSearch={(direction: TransferDirection, value: string) => onTransferSearch(direction, value)}
selectAllLabels={[
() => {
const totalCount = currentTreeData('left')?.reduce((acc, item) => {
return acc + (item.children?.length || 0);
}, 0);
return `${checkedKeys.left?.filter((item) => !fatherKeys?.includes(item))?.length}/${totalCount}项`;
},
() => {
const totalCount = currentTreeData('right')?.reduce((acc, item) => {
return acc + (item.children?.length || 0);
}, 0);
return `${
checkedKeys.right?.filter((item) => !fatherKeys?.includes(item))?.length
}/${totalCount}项`;
},
]}
footer={({ direction }) => {
return (
<Button
type="link"
onClick={() => {
const allKeys = currentTreeData(direction)
?.map((item) => item.children?.map((child) => child.key))
?.flat();
onItemSelectAllRef.current[direction](allKeys as string[], true);
setCheckedKeys({
...checkedKeys,
[direction]: allKeys,
});
}}
>
全选
</Button>
);
}}
{...transferProps}
>
{({ direction, onItemSelectAll, onItemSelect }) => {
onItemSelectAllRef.current[direction] = onItemSelectAll;
const currentTree = currentTreeData(direction);
return (
<Tree
blockNode
checkable
defaultExpandAll
checkedKeys={checkedKeys[direction]}
treeData={currentTree}
onCheck={(keys, { node: { key, checked } }) => {
// 这里限定了CheckedKeys列表都为子级的
if (fatherKeys.includes(key as string)) {
const data = getItenSonKeys(currentTree, key as string);
onItemSelectAll(data as string[], !checked);
} else {
onItemSelect(key as string, !checked);
}
setCheckedKeys({
...checkedKeys,
[direction]: keys,
});
}}
/>
);
}}
</Transfer>
);
};
export default SingleTreeTransfer;