基于antd Transfer实现双侧树穿梭框

503 阅读4分钟

作者最近在搬砖时遇到这样一个需求:

需要实现一个双节点树的穿梭框(目前仅支持单层树结构)。即穿梭框的左右两侧皆为节点树结构。且支持双侧的搜索、全选、穿梭

在完成此需求时,遇到了许多问题和坑点,在此记录分享。

需求调查

首先对项目的组件库antd4.11.2的穿梭框组件的文档进行查阅: image.png 通过观察代码发现左侧的树形结构是使用 Tree 组件实现,并且通过render Props的设计上提Transfer的各项参数及操作函数如下: image.png 由此确定基于antd的Transfer和Tree实现一个双节点树的穿梭框组件

image.png

实现过程

这里准备封装成SingleTreeTransfer组件

数据准备

首先对源数据进行处理,转换成Tree、Transfer对应格式的数据并传入 image.png 传入defaultTargetKeys确定默认选中项,并且对某父级的所有子级都被选中,该父级节点也要被选中 image.png onTransferChange函数中回调targetKeys参数给外层组件使用可自定义

Tree的checkStrictly使Tree的父子节点不再关联,影响了正常的交互,这里去掉该属性

双侧穿梭

这里通过Render Props的方式取得穿梭框参数。首先我们完成选中的逻辑: image.png

  1. 如果选中父节点,则等于选中其所有子节点
  2. 需要通过状态变量和onItemSelectAll onItemSelect函数去同时控制Tree以及Transfer的选中
  3. 注意:这里通过Ref保存双侧onItemSelectAll函数以提供给全选给功能使用 然后完成穿梭的逻辑: image.png
  4. 如果是穿梭到右侧,targetKeys合并keys;如果是左侧,则将moveKeys排除
  5. 如果某父级下的所有子级被选中,该父节点也被选中
  6. onTransferChange中传入targetKeys

搜索

image.png

  1. 搜索为空则返回原结构
  2. 搜索内容包括父节点及子节点title,暂无大小写兼容匹配(可根据实际情况调整)

全选

image.png

  1. 选中当前侧的所有子节点
  2. 包括Tree以及Transfer的选中

样式

需要让树过长时出现滚动条

image.png

最终效果展示

image.png

可优化点

  1. 涉及大量计算,导致数据过多时,交互不流畅
  2. 选中项穿梭后,未自动展开
  3. 全选可增加取消全选逻辑(类似Table的全选)
  4. 如何支持多层树结构

组件全部代码

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;