您好,如果喜欢我的文章,可以关注我的公众号「量子前端」,将不定期关注推送前端好文~
Tree选择器结构比较特殊,类似于数据结构中的树,因此设计对于优化有很多的关系。
先看一下组件库文档:
设计中主要用到的思路是递归,先看一下基础渲染吧:
const treeData = [
{
title: 'parent1',
value: '0-0',
children: [
{
title: 'parent 1-0',
value: '0-0-1',
},
{
title: 'parent 1-1',
value: '0-0-2',
children: [
{
title: 'leaf2',
value: '0-0-0-1',
},
],
},
],
},
{
title: 'parent2',
value: '0-1',
children: [
{
title: 'parent 2-0',
value: '0-0-3',
},
],
},
];
渲染结构是这样的,就是一个树的结构,通过渲染函数将所有树节点递归渲染出来,核心代码如下:
而具体这个level是从哪里来的呢,其实使用者上文只需要传递title、value、children即可,设计中其实是在渲染之前对这个树节点进行了一些结构改造的,以便于组件开发。
在上图中,二次改造函数对每个节点都进行了height和level的计算和添加,这些后面都会用到,具体备注在图片中很清楚。
业务开发
核心点主要在切换菜单,切换菜单时我的设计是展开只进行下一层节点的展示;收起的话如果是对根节点进行收起操作,则将所有子节点收起,核心代码如下:
上面所讲的是切换的实现,如果是点击无子节点的节点呢?就是直接选中操作了。
这里选中分为了单选了多选,组件默认是单选的,如需要支持多选,需要给组件传递avaChooseMore属性,具体可参照文档案例。
组件完整源码index.tsx:
import React, {
FC, memo, Fragment, useState, useEffect, useCallback } from 'react';
import {
CaretRightOutlined, CaretDownOutlined } from '@ant-design/icons';
import Input from '../Input';
import './index.module.less';
interface treeProps {
/**
* @description Tree配置参数
*/
treeData: Array<treeNode>;
/**
* @description 宽度
* @default 200px
*/
width?: string;
/**
* @description 支持搜索
* @default false
*/
avaSearch?: boolean;
/**
* @description 支持多选
* @default false
*/
avaChooseMore?: boolean;
/**
* @description 全展开
* @default false
*/
defaultOpen?: boolean;
/**
* @description 选择回调函数
*/
chooseCallback?: Function;
}
interface treeNode {
title: string;
value: string;
level: number;
height?: string;
children?: Array<treeNode>;
}
const Tree: FC<treeProps> = (props) => {
const {
width = '200', treeData, avaSearch, avaChooseMore, defaultOpen, chooseCallback } = props;
const [stateTreeData, setStateTreeData] = useState<Array<treeNode>>(treeData); //树结构
const [activedVal, setActivedVal] = useState<string>(''); //选中的节点值
const [containerHeight, setContainerHeight] = useState<string>('0px'); //容器高度
const [isFocus, setIsFocus] = useState(false); //聚焦状态
useEffect(() => {
resolveTreeData(treeData as Array<treeNode>, 1);
window.addEventListener('click', () => setContainerHeight('0px'));
}, []);
const resolveTreeData = (treeData: Array<treeNode>, nowIndexLevel: number) => {
//二次处理treeData
treeData.forEach((treeNode: treeNode) => {
treeNode.level = nowIndexLevel;
if (defaultOpen) {
//默认全展开
treeNode.height = '30px';
} else {
treeNode.height = treeNode.level == 1 ? '30px' : '0';
}
if (treeNode?.children?.length) {
//有子节点
resolveTreeData(treeNode.children, nowIndexLevel + 1);
} else {
//没有子节点,重置level为当前层级,继续寻找
nowIndexLevel = treeNode.level;
}
});
setStateTreeData(treeData); //更新状态
};
const toggleTreeMenu = (clickTreeNode: treeNode) => {
//菜单切换或直接选中终极节点
if (clickTreeNode?.children?.length) {
//菜单切换的情况
const oldStateTree = [...stateTreeData];
const editTreeNode = (treeNode: Array<treeNode>) => {
//所选节点后代收起处理函数
treeNode.forEach((child) => {
//找到节点,对子节点进行处理
if (child?.children?.length) {
child.height = '0';
editTreeNode(child.children);
} else {
child.height = '0';
}
});
};
const mapFn = (treeNode: Array<treeNode>) => {
//深度优先查找节点函数
treeNode.forEach((t: treeNode, i: number) => {
if (t.title == clickTreeNode.title && t.value == t.value) {
if (t?.children?.length) {
//后代节点处理,如果打开,只需打开下一代即可,如果关闭,需要关闭所有后代
if (t.children[0].height == '0') {
//打开
t.children = t.children.map((child: treeNode) => {
return {
...child,
height: child.height == '0' ? '30px' : '0',
};
});
} else {
//关闭
editTreeNode(t.children); //对后代节点进行处理
}
}
} else if (t?.children?.length) {
mapFn(t.children);
}
});
};
mapFn(oldStateTree);
setStateTreeData(oldStateTree);
} else {
//选中终极节点的情况
if (avaChooseMore) {
//多选
if (activedVal.split(',').includes(clickTreeNode.title)) {
//取消选中
let updateVal: Array<string> | string = activedVal;
updateVal = updateVal.split(',');
updateVal.splice(
activedVal.split(',').findIndex((t) => t == clickTreeNode.title),
1,
);
updateVal = updateVal.join(',');
setActivedVal(updateVal);
chooseCallback && chooseCallback(updateVal);
} else {
setActivedVal(
activedVal == '' ? clickTreeNode.title : activedVal + ',' + clickTreeNode.title,
);
chooseCallback &&
chooseCallback(
activedVal == '' ? clickTreeNode.title : activedVal + ',' + clickTreeNode.title,
);
}
} else {
//单选
setActivedVal(clickTreeNode.title);
chooseCallback && chooseCallback(clickTreeNode.title);
}
}
};
const handleIptChange = (val: string) => {
//文本改变回调
if (avaSearch) {
setActivedVal(val);
} else {
setActivedVal('');
}
};
const handleClick = () => {
//点击回调
if (avaSearch) {
if (isFocus && containerHeight == '100%') {
setContainerHeight('0px');
} else {
setContainerHeight('100%');
}
} else {
setContainerHeight(containerHeight == '0px' ? '100%' : '0px');
}
};
const handleIptFocus = () => {
//聚焦回调
setTimeout(() => {
//异步,等待点击执行完毕
if (!isFocus) {
setIsFocus(true);
}
}, 150);
};
const handleIptBlur = () => {
//失去焦点回调
setIsFocus(false);
};
const searchStyle = useCallback(
(treeNode: treeNode): string => {
//搜索高亮样式
if (treeNode.title.includes(activedVal) && activedVal !== '') {
return '#1890FF';
} else {
return '#000000';
}
},
[activedVal],
);
const activedStyle = useCallback(
(treeNode: treeNode): string => {
//选中高亮样式
if (avaChooseMore) {
//多选
if (activedVal.split(',').includes(treeNode.title)) {
return '#bae8ff';
} else {
return '#ffffff';
}
} else {
//单选
if (activedVal == treeNode.title) {
return '#bae8ff';
} else {
return '#ffffff';
}
}
},
[activedVal],
);
const clearCallback = () => {
//清空
setActivedVal('');
};
const render = (data: Array<treeNode> = stateTreeData) => {
//动态规划render函数
return data.map((treeNode: treeNode, index) => {
return (
<Fragment key={
index}>
<div
className="treeNode"
style={
{
marginLeft: `${
treeNode.level * 10}px`,
height: `${
treeNode.height}`,
color: searchStyle(treeNode),
background: activedStyle(treeNode),
}}
onClick={
() => toggleTreeMenu(treeNode)}
>
{
treeNode?.children?.length ? (
treeNode.children[0].height == '0' ? (
<CaretDownOutlined />
) : (
<CaretRightOutlined />
)
) : (
<div style={
{
width: '12px', height: '12px' }}></div>
) //空间占位符
}
<span className="text">{
treeNode.title}</span>
</div>
{
treeNode?.children?.length && render(treeNode.children)}
</Fragment>
);
});
};
return (
<Fragment>
<div className="tree-container" onClick={
(e) => e.stopPropagation()}>
<Input
moreStyle={
avaSearch ? {
} : {
caretColor: 'transparent' }}
placeholder={
avaSearch ? '请输入' : ''}
width={
width}
defaultValue={
activedVal}
handleClick={
handleClick}
handleIptChange={
handleIptChange}
handleIptFocus={
handleIptFocus}
handleIptBlur={
handleIptBlur}
clearCallback={
clearCallback}
showClear
/>
<div
className="tree-select-dialog"
style={
{
width: `${
width}px`,
height: containerHeight,
opacity: containerHeight == '0px' ? '0' : '1',
padding: containerHeight == '0px' ? '0 10px 0 10px' : '10px',
}}
>
{
render()}
</div>
</div>
</Fragment>
);
};
export default memo(Tree);
github path戳~
React-View-UI组件库文档地址戳~
如果对该组件库有兴趣,在github的readme中有介绍基本入门方式,可以下载尝试,最后感谢花时间阅读此文,如果对Tree组件设计有更好的建议欢迎留言呀。