80行代码,领你手撸一个Tree组件

好久没更新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组件系列到这里就结束啦,希望我的分享能够对你有帮助,如果上述讲解中出了一些错误,或者存在明显的改进点,也欢迎大家在评论区里指出,我们下期再见啦~~

相关推荐
百万蹄蹄向前冲39 分钟前
2024不一样的VUE3期末考查
前端·javascript·程序员
轻口味1 小时前
【每日学点鸿蒙知识】AVCodec、SmartPerf工具、web组件加载、监听键盘的显示隐藏、Asset Store Kit
前端·华为·harmonyos
alikami1 小时前
【若依】用 post 请求传 json 格式的数据下载文件
前端·javascript·json
wakangda2 小时前
React Native 集成原生Android功能
javascript·react native·react.js
吃杠碰小鸡2 小时前
lodash常用函数
前端·javascript
emoji1111112 小时前
前端对页面数据进行缓存
开发语言·前端·javascript
泰伦闲鱼2 小时前
nestjs:GET REQUEST 缓存问题
服务器·前端·缓存·node.js·nestjs
m0_748250032 小时前
Web 第一次作业 初探html 使用VSCode工具开发
前端·html
一个处女座的程序猿O(∩_∩)O2 小时前
vue3 如何使用 mounted
前端·javascript·vue.js
m0_748235952 小时前
web复习(三)
前端