用 React + TypeScript + Antd 打造一个动态加载的树形穿梭选择组件
作为一名前端工程师,我时常需要在项目中处理复杂的选择场景,比如组织架构选择、权限分配等。普通的下拉框已经无法满足需求,而 Ant Design 的 TreeSelect 组件虽然强大,但如何让它支持动态加载数据、树形结构选择,还能与穿梭框的效果结合起来?这篇文章将带你基于 React、TypeScript 和 Antd,打造一个功能强大且易用的 TransferComSelect 组件,解决这些痛点。
为什么需要这个组件?
在实际业务中,我们经常会遇到以下需求:
- 树形数据展示: 需要展示层级结构,比如公司部门树。
- 动态加载: 数据量大时,按需加载子节点以提升性能。
- 多选与穿梭: 支持多选,并清晰展示已选内容。
- 灵活性: 可切换“只选本级”或“包含子级”的选择模式。
基于这些需求,我们将实现一个结合 TreeSelect 的树形选择组件,支持动态加载和自定义交互。
核心代码实现
以下是 TransferComSelect 组件的核心代码,我会逐步拆解它的实现逻辑:
.switch {
display: flex;
background-color: #fff;
padding: 5px 10px;
width: 100%;
margin-bottom: 5px;
>span {
margin-right: 5px;
}
}
.tree {
height: 300px;
overflow-y: auto;
scrollbar-width: none;
/* Firefox */
-ms-overflow-style: none;
}
.tree::-webkit-scrollbar {
display: none;
}
import React, { useState, useEffect, useCallback } from 'react';
import { TreeSelect, Switch, Spin, Empty } from 'antd';
import {debounce} from 'lodash'; // 引入 lodash 的防抖函数
import './index.less';
interface TreeDataNode {
key: string;
value: string;
title: string;
children?: TreeDataNode[];
isLeaf?: boolean;
}
interface TransferComSelectProps {
loadDataFetch: (params: { parentId: string }) => Promise<any>;
treeData?: TreeDataNode[];
type?: number;
selectedData?: {id:string}[];
handleChange?: (data: { selectedIds: string[]; type: number }) => void;
className?: string;
style?: React.CSSProperties;
placeholder?: string; // 新增:自定义 placeholder
switchText?: { onlyThisLevel: string; on: string; off: string }; // 新增:开关文案
emptyText?: string; // 新增:空状态文案
}
const TransferComSelect: React.FC<TransferComSelectProps> = (props) => {
const {
loadDataFetch,
treeData = [],
type = 0,
selectedData = [],
handleChange,
className,
style,
placeholder = '请输入', // 默认值
switchText = { onlyThisLevel: '只选本级', on: '开', off: '关' }, // 默认文案
emptyText = '暂无数据', // 默认空状态文案
} = props;
const [sch, setSch] = useState(true);
const [targetKeys, setTargetKeys] = useState<string[]>([]);
const [treeDataSource, setTreeDataSource] = useState<TreeDataNode[]>(treeData);
const [loading, setLoading] = useState(false);
// 更新树数据源
const updateTreeDataSource = (data: TreeDataNode[], key: string, children: TreeDataNode[]) => {
return data.forEach((node) => {
if (node.key === key) {
node.children = children;
} else if (node.children) {
updateTreeDataSource(node.children, key, children);
}
});
};
// 动态加载子节点数据
const loadData = (treeNode: any) => {
return new Promise((resolve) => {
setLoading(true);
loadDataFetch({ parentId: treeNode.id }).then((children: any) => {
const newChildren = children.map((i: any) => ({
...i,
isLeaf: i?.leaf,
key: i?.id,
value: i?.id,
}));
updateTreeDataSource(treeDataSource, treeNode.props.eventKey, newChildren);
setTreeDataSource([...treeDataSource]);
setLoading(false);
resolve();
}).catch(() => setLoading(false));
});
};
// 处理选择变化
const onChange = (newValue: any[]) => {
setTargetKeys(newValue.map((i) => (i?.value||i)));
};
// 同步外部传入的已选数据
useEffect(() => {
if (selectedData?.length > 0) {
setTargetKeys(selectedData.map((i) => i?.id));
}
}, [selectedData]);
// 通知父组件选择结果
useEffect(() => {
if (handleChange) {
handleChange({
selectedIds: targetKeys,
type,
});
}
}, [targetKeys, handleChange, type]);
// 新增:防抖搜索函数
const filterTreeNode = useCallback(
debounce((inputValue: string, treeNode: any) => {
return treeNode.title.toLowerCase().includes(inputValue.toLowerCase());
}, 300), // 300ms 防抖
[]
);
const { SHOW_ALL } = TreeSelect;
const tProps = {
treeData: treeDataSource,
value: targetKeys,
onChange,
treeCheckStrictly: sch,
treeCheckable: true,
showCheckedStrategy: SHOW_ALL,
placeholder,
loadData,
showSearch: true,
filterTreeNode,
dropdownRender: (node: any) => (
<>
<div className='switch'>
<span>{switchText.onlyThisLevel}</span>
<Switch
checkedChildren={switchText.on}
unCheckedChildren={switchText.off}
checked={sch}
onChange={setSch}
/>
</div>
<Spin spinning={loading}>
{treeDataSource.length === 0 && !loading ? (
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={emptyText} />
) : (
node
)}
</Spin>
</>
),
className,
style: { width: '100%', ...style },
};
return <TreeSelect {...tProps} />;
};
export default TransferComSelect;
如何使用这个组件?
该组件已集成到 react-nexlif 开源库中。 具体文档可参考使用文档。你可以通过以下方式引入并使用:
pnpm add react-nexlif
import React from 'react';
import {TransferComSelect} from 'react-nexlif';
const App: React.FC = () => {
const initialTreeData = [
{ key: '1', value: '1', title: 'Department A',id:'1' },
{ key: '2', value: '2', title: 'Department B',id:'2' },
];
const fetchData = ({ parentId }: { parentId: string }) => {
return new Promise((resolve) => {
setTimeout(() => {
resolve([
{ id: `${parentId}-1`, title: `Sub ${parentId}-1`, leaf: true },
{ id: `${parentId}-2`, title: `Sub ${parentId}-2`, leaf: true },
]);
}, 1000);
});
};
const handleChange = (data: { list: { orgId: string }[]; type: number }) => {
console.log('Selected:', data);
};
return (
<div>
<h1>TransferComSelect 预览</h1>
<TransferComSelect
treeData={initialTreeData}
loadDataFetch={fetchData}
type={0}
selectedData={[{ id: '1' }]}
handleChange={handleChange}
classtitle="custom-tree-select"
style={{ border: '1px solid #1890ff', borderRadius: 4 }}
placeholder="Please input" // 自定义 placeholder
switchText={{ onlyThisLevel: 'Only this level', on: 'On', off: 'Off' }} // 英文文案
emptyText="No data available" // 自定义空状态文案
/>
</div>
);
};
export default App;
代码亮点解析
- 动态加载数据: 通过 loadData 函数实现按需加载子节点,结合 loadDataFetch 接口请求数据,优化性能。
- 类型安全: 使用 TypeScript 定义 TreeDataNode 和 TransferComSelectProps,确保传入参数和数据结构清晰可控。
- 灵活选择模式: 通过 treeCheckStrictly 和 Switch 组件实现“只选本级”与“包含子级”的切换。
- 自定义下拉: 使用 dropdownRender 在下拉菜单中添加开关,增强交互体验。
- 状态同步: 通过 useEffect 将选择结果同步给父组件,保持数据一致性。
- 加载状态提示
- 新增 loading 状态,在 loadData 中通过 setLoading 控制。
- 使用 Antd 的 Spin 组件包裹下拉内容,数据加载时显示旋转动画,直观反馈用户。
- 搜索功能
- 添加 showSearch: true 启用 TreeSelect 的搜索功能。
- 实现 filterTreeNode 函数,支持对节点标题的模糊匹配,用户输入时快速定位。
- 样式定制
- 新增 className 和 style 属性,允许调用者通过 CSS 类或内联样式调整组件外观。
- 默认宽度保持 100%,通过 ...style 合并用户传入的样式,确保灵活性。
- 搜索防抖
- 使用 lodash/debounce 包装 filterTreeNode,设置 300ms 延迟。
- 通过 useCallback 确保函数引用稳定,避免不必要的重新渲染。
- 用户快速输入时,只在停止输入 300ms 后触发搜索,显著减少性能开销。
- 空状态提示
- 在 dropdownRender 中检查 treeDataSource.length === 0 且 !loading。
- 使用 Antd 的 Empty 组件显示空状态,文案通过 emptyText 自定义。
- 当数据为空时,用户会看到清晰的“暂无数据”提示,而不是空白。
- 多语言支持
- 新增 placeholder、switchText 和 emptyText 属性,将所有文案提取为 props。
- 默认提供中文文案,调用者可传入英文或其他语言,轻松实现国际化。
- 比如,switchText 支持开关的标签和状态文案自定义。
总结
通过 React、TypeScript 和 Antd 的 TreeSelect,我们实现了一个支持动态加载、多选切换的树形穿梭选择组件。它不仅满足了复杂业务场景的需求,还保持了代码的可读性和扩展性。
如果你在项目中也有类似需求,不妨试试这个组件!复制代码,稍作调整就能跑起来。有什么优化建议或使用心得,欢迎留言交流~