好久没更新UI专栏啦,这次我们准备撸一个Tree组件。之前看过我文章的小伙伴应该会有疑惑,这次不应该到了kagol大佬提到的级联组件,或者是之前一直在说的引导页组件了嘛,这是什么情况。我贱贱的小声说道,会有的。
市面上的Tree组件都差不多一样,我们以antd为例:
其实看到这个组件,我没多想,就直接打开了控制台,查看了这个组件的dom结构,如下图:
这么一看,着实清晰了。那接下来的问题就很清晰了:
- 如何定义数据类型
- 如何抽取最小单元
- 如何维护单元状态
定义数据类型
这个我觉着树就可以,数据格式如下:
javascript
let data = [
{
id: 'father1',
name: 'father1',
value: 'father1',
children: [
{
id: 'father1-child1',
name: 'father1-child1',
value: 'father1-child1',
children: [
{ id: 'father1-child1-child1', name: 'father1-child1-child1', value: 'father1-child1-child1' },
{ id: 'father1-child1-child2', name: 'father1-child1-child2', value: 'father1-child1-child2' },
]
},
{
id: 'father1-child2',
name: 'father1-child2',
value: 'father1-child2'
}
]
},
{ id: 'father2', name: 'father2', value: 'father2' }
];
抽取最小单元
其实从下图就可以看出来,每一条数据都是一个最小单元:
具体如下:
jsx
import React, { useState } from 'react';
function Tree(props){
let [ data, setData ] = useState([
{
id: 'father1',
name: 'father1',
value: 'father1',
children: [
{
id: 'father1-child1',
name: 'father1-child1',
value: 'father1-child1',
children: [
{ id: 'father1-child1-child1', name: 'father1-child1-child1', value: 'father1-child1-child1' },
{ id: 'father1-child1-child2', name: 'father1-child1-child2', value: 'father1-child1-child2' },
]
},
{
id: 'father1-child2',
name: 'father1-child2',
value: 'father1-child2'
}
]
},
{ id: 'father2', name: 'father2', value: 'father2' }
]);
return <div className='tree-box'>
{
data.map(item => {
return <TreeNode/>
})
}
</div>
}
export default Tree;
<TreeNode>组件
也特别的好写,支持传入一个对象,将对象的name作为显示,并且,如果有children属性,那就再map递归渲染TreeNode组件,具体如下:
jsx
function TreeNode(props){
let [ privateObj, setPrivateObj ] = useState(props.obj);
return <>
<div className='tree-node-box'>
{ privateObj.name }
</div>
{
privateObj.children && privateObj.children.map(
item => {
return <TreeNode obj={item} />
}
)
}
</>
}
我们再添加点样式,让所有的内容都左对齐,css如下:
css
.tree-node-box {
width: 300px;
height: 50px;
text-align: left;
box-sizing: border-box;
}
渲染之后,现象如下:
接下来的任务就是让Tree组件有层级感,这个功能我们可以通过padding来实现,具体就是 当前层数 * 固定值
来得到当前treeNode的偏移量。
改动如下:
javascript
function TreeNode(props){
let [ privateObj, setPrivateObj ] = useState(props.obj);
return <>
<div className='tree-node-box' style={{
paddingLeft: `${20 * (props.curLevel)}px`
}}>
{ privateObj.name }
</div>
{
privateObj.children && privateObj.children.map(
item => {
return <TreeNode obj={item} curLevel={props.curLevel+1}/>
}
)
}
</>
}
function Tree(props){
// 其余代码不变=====
return <div className='tree-box'>
{
data.map(item => {
return <TreeNode obj={item} curLevel = {0}/>
})
}
</div>
}
现在我们通过层数
的这个概念把父子孙之间的层级给渲染出来了。
维护单元状态
我们现在都是默认展开的,我们应该有一个开关,来控制它什么时候展开。在讲解这个功能之前,我们还需要给这个组件再增添一点样式,如下:
css
.tree-node-box {
width: 300px;
height: 50px;
text-align: left;
box-sizing: border-box;
display: flex;
justify-content: flex-start;
align-items: center;
}
.icon {
width: 16px;
height: 16px;
box-sizing: border-box;
background-color: #fff;
border: 1px solid #d9d9d9;
border-radius: 2px;
margin-right: 5px;
}
同时,我们再给TreeNode组件增加一个icon的标签,修改如下:
jsx
function TreeNode(props){
let [ privateObj, setPrivateObj ] = useState(props.obj);
return <>
<div className='tree-node-box' style={{
paddingLeft: `${20 * (props.curLevel)}px`
}}>
<div className='icon'></div>
{ privateObj.name }
</div>
{
privateObj.children && privateObj.children.map(
item => {
return <TreeNode obj={item} curLevel={props.curLevel+1}/>
}
)
}
</>
}
现在样式如下:
现在我们来看一下展开收起的逻辑。想要实现这个功能,我觉着下面的几点应该是绕不过去的:
- 展开收起肯定是组件的私有状态。
- 这个私有状态是否跟数据有关。也就是state的初始值是否跟props相关。
显而易见,关键点在最后一个,而最后一个的解决方案是跟Tree组件的用途紧密相关的。
如果你的业务里说明,Tree组件里节点的展开收起是用户可配置的,那就是A方案;如果不支持用户配置,那就是B方案。
方案B
这2个方案我们都会讲到,首先是B方案(不支持用户配置),这个就比较简单了,组件的初始状态都是false,只有点击的时候,才会去一层一层的展开,修改如下:
jsx
function TreeNode(props){
let [ privateObj, setPrivateObj ] = useState(props.obj);
let [ curIsSelected, setCurInSelected ] = useState(false); // 当前是否被选中
// 点击当前treenode事件
let clickCurTreeNode = () => {
setCurInSelected(
state => {
return !state
}
);
}
return <>
<div
className='tree-node-box'
style={{
paddingLeft: `${20 * (props.curLevel)}px`
}}
onClick={clickCurTreeNode}
>
<div className={`icon ${curIsSelected ? 'selected-icon' : ''}`}></div>
{ privateObj.name }
</div>
{
curIsSelected && privateObj.children && privateObj.children.map(
item => {
return <TreeNode obj={item} curLevel={props.curLevel+1}/>
}
)
}
</>
}
修改样式如下:
css
/** 其余样式不变 */
.selected-icon {
position: relative;
background-color: #1677ff;
}
.selected-icon::after {
content: '';
display: block;
position: absolute;
top: 50%;
inset-inline-start: 25%;
display: table;
width: 8px;
height: 8px;
border: 2px solid #fff;
border-top: 0;
border-left: 0px;
box-sizing: border-box;
transform: rotate(45deg) translate(-50%,-50%);
}
方案A
这种方案就是支持用户控制每个节点的默认展开与隐藏,想要做到这点,我们就需要给用户一个反馈,用户的数据里必须有isSelected属性,组件才会去支持用户控制节点的展开与收起。具体的数据格式如下:
javascript
let data = [
{
id: 'father1',
name: 'father1',
value: 'father1',
isSelected: true,
children: [
{
id: 'father1-child2',
name: 'father1-child2',
value: 'father1-child2',
}
]
}
];
根据上面的数据配置,我们大致能够推测出来,这个结果应该是父级展开,子级不展开。
走到这里,我们还需要考虑一个事情,当前的Tree组件是否有权利改变用户的真实数据。
是否有权利改变用户的数据,这个就看具体场景而定吧。
修改如下:
jsx
function TreeNode(props){
let [ privateObj, setPrivateObj ] = useState(props.obj);
// 新做出的修改+++++++
let [ curIsSelected, setCurInSelected ] = useState(props.obj.isSelected); // 当前是否被选中
let [ allChildIsSelected, setAllChildIsSelected ] = useState(false); // 当前所有的子元素,是否都被选中
let clickCurTreeNode = () => {
setCurInSelected(
state => {
return !state
}
);
}
return <>
<div
className='tree-node-box'
style={{
paddingLeft: `${20 * (props.curLevel)}px`
}}
onClick={clickCurTreeNode}
>
<div className={`icon ${curIsSelected ? 'selected-icon' : ''}`}></div>
{ privateObj.name }
</div>
{
curIsSelected && privateObj.children && privateObj.children.map(
item => {
return <TreeNode obj={item} curLevel={props.curLevel+1}/>
}
)
}
</>
}
当我们传入这样的数据,刷新页面后,组件是一个正确的结果:
javascript
let data = [
{
id: 'father1',
name: 'father1',
value: 'father1',
isSelected: true,
children: [
{
id: 'father1-child2',
name: 'father1-child2',
value: 'father1-child2',
}
]
},
{ id: 'father2', name: 'father2', value: 'father2' }
];
效果如下:
如何实现全选 or 全不选
这个也比较好弄,在递归的时候,将当前节点的isSelected属性覆盖子组件的属性就可以了,修改如下:
jsx
function TreeNode(props){
let [ curIsSelected, setCurInSelected ] = useState(props.obj.isSelected); // 当前是否被选中
return <>
{/** 其余代码不变====== */}
{
curIsSelected && privateObj.children && privateObj.children.map(
item => {
return <TreeNode obj={ { ...item, isSelected: curIsSelected } } curLevel={props.curLevel+1}/>
}
)
}
</>
}
最终效果如下:
待完成的点
参考市面上的组件,Tree组件还应该有一个半选中
状态。想要实现这个半选中
,首先就要改造一下我们的curIsSelected
私有状态,在这里它是一个boolean值,但是扩展性上来看,他应该是一个枚举比较好。
还有一个就是遍历,每次选中,你都要遍历当前分支(因为你始终都要反馈给上层节点),至于怎么遍历,方法有很多,欢迎大家评论区里pk。
最后
好啦,本期的UI组件系列到这里就结束啦,希望我的分享能够对你有帮助,如果上述讲解中出了一些错误,或者存在明显的改进点,也欢迎大家在评论区里指出,我们下期再见啦~~