本篇文章记录仿写一个树
tree
组件细节。源码在github
上,也有演示效果的网址,大家可以拉下来,npm start
运行跑起来,结合注释有助于更好的理解。
github
仓库地址:https://github.com/shuirongsh...网址演示效果地址:http://ashuai.work:8888/#/myTree
树组件说明
关于树结构形式的效果,在工作中很常见,如,菜单树、权限树、关系组织树等一些展示层级关系的功能。本文使用组件递归的思想,实现一个树组件,我们先看一下效果图:
树组件的功能
我们知道,树组件三大核心功能:
- 基本功能
- 勾选相关功能
- 树节点懒加载功能
考虑到篇幅和阅读成本原因(作者懒),本文只讲述基本的树功能,后续树的勾选和懒加载以后,不忙了,再去发文章。
树的基本功能需要实现的效果
- 根据JSON树结构的数据递归组件展示层级关系(树节点要有缩进问题,树层级越深,越往右靠)
- 点击树的某个节点给树节点加上一个聚焦状态(点击加上背景色,点击别的地方背景色消失)
- 点击小图标收起树的子节点(展开与折叠)
- 统一控制树的展开或者收起(统一展开与统一折叠)
- 树组件小图标的更改(可以使用插槽,这里笔者使用的类名变量更改的,思想是一样的)
- 没有数据时,树组件做一个提示没数据(加个判断即可)
- 事件的传递(点击小图标折叠树节点、点击树节点item)
建议大家阅读完此文章以后,可以做一个参考,然后自己去实现一个树组件,实现树组件的方式有很多种,有可能读者的方式,更好呢,代码除了多看以外,还要多写多总结
树组件的实现
1. 根据JSON树结构的数据递归组件展示层级关系
想要递归,首先要有相应的树结构的数据,如下的数据:
let treeData = [
{
name: "中国",
eng: "China",
children: [
{
name: "北京",
eng: "Beijing",
},
{
name: "上海",
eng: "Shanghai",
children: [
{
name: "闵行区",
eng: "Minhang",
},
{
name: "静安区",
eng: "Jingan",
},
],
},
],
},
{
name: "美国",
eng: "American",
children: [
{
name: "纽约",
eng: "NewYork",
children: [
{
name: "曼哈顿区",
eng: "ManHattan",
},
{
name: "皇后区",
eng: "Queen",
},
{
name: "布鲁克林区",
eng: "Brooklyn",
},
],
},
{
name: "华盛顿",
eng: "Washington",
},
],
},
],
当然,实际上这里的树结构的数据可能会有很多层,这里笔者定义的树结构数据的规则是,有children
就说明有子节点,没有children
字段就说明没有子节点。有的后端会定义无论是否有树节点,都会返回children
字段,只不过值(数组)是否为长度为0
罢了,这个注意一下即可。
首先定义两个组件
myTree
组件treeItem
组件
大家想要递归的话,可以考虑定义两个组件,一个是主组件用于暴露给外部使用如(myTree
),另外一个是用来递归的组件(如treeItem
)
myTree
中引用treeItem
组件
用于递归的treeItem
组件一定要写组件名name
哦,因为查找的时候,就是去通过这个组件名查找的
<my-tree :treeData="treeData"></my-tree>
组件要接收树结构的数据,treeData
即上方的数据,工作中是后端返回的数据(有时可能需要做一些加工才能使用)
然后在my-tree
组件中接收treeData
数据用于初次循环,为什么说是初次循环呢?我们先假设treeData
数组中只有两项,只有中国
和美国
且都没有子节点,那么直接一个循环就行了,如下:
<div
v-for="(item, index) in treeData"
:key="index"
>
{{item.name}}
</div>
name:"myTree",
props:{
treeData:Array
}
这样的话,中国
和美国
就直接展示出来了,但是,因为其有子节点,我们可以考虑将循环的dom元素再做一个拆分,拆分成另一个用于递归循环的子组件treeItem
,最后的一些细节都写在这个子组件中,如下:
<tree-item
v-for="(item, index) in treeData"
:key="index"
:item="item" // 因为子组件需要接收数据进行展示,所以我们可以将循环出来的每一项,依旧作为参数,继续传递给每一个tree-item
>
</tree-item>
name:"myTree",
props:{
treeData:Array
}
所以,接下来,问题转换成,如何写tree-item
组件了。
在写treeItem
组件之前,我们就要分清楚树结构的dom
层级大概划分,如下:
所以我们的dom
结构大致可以设计成如下
<!-- 每一个树节点包含:图标部分、名字部分 -->
<div class="treeNodeItem">
<div class="iconAndName" tabindex="-1" @click="clickTree">
<i class="el-icon-caret-right"></i>
<span>{{ item.name }}</span>
</div>
</div>
name:"treeItem" // 组件名一定要加哦!!!
props: {
// 每一个节点的数据,每一项
item: Object
},
这样设计的dom
和上方是一样的。但是这样拆分依旧是无法渲染子节点项,所以这里我们可以首先做一个判断:
- 如果有子节点,有
children
字段,那就再引入这个treeItem
组件。 - 注意:组件除了可以引入外部别的组件值之外,也可以引入自身组件
- 同函数递归一样,需要加上停止条件,很显然这里的停止条件就是是否有有
children
字段 - 是否有子节点,换句话说:
- 如果有
children
字段,我才去引入这个treeItem
组件做渲染,否则利用v-if
去除掉。
经过这样一波的分析,我们的代码,就变成了这个样子:
<div class="treeNodeItem">
<div class="iconAndName" tabindex="-1" @click="clickTree">
<i class="el-icon-caret-right"></i>
<span>{{ item.name }}</span>
</div>
<div class="childrenTreeNode">
<!-- 存在子节点就遍历并递归调用自身这个组件 -->
<template v-if="item.children">
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite" // 注意,因为每一个都需要的props数据,所以这里再次传递
></tree-item>
</template>
</div>
</div>
name:"treeItem" // 组件名一定要加哦!!!
props: {
// 每一个节点的数据,每一项
item: Object
},
至此,组件递归调用,就实现了,如果读者们还有一些不太清楚,可以参考笔者之前的文章:
加上这两篇文章,基本上就没啥问题了
2.点击树的某个节点给树节点加上一个聚焦状态
我们知道,说道聚焦,大家常常会想到input
输入框之类的表单元素去聚焦,去:focus
之类的,实际上普通的dom元素也可以聚焦。
普通的dom元素想要聚焦,需要使用tabindex
属性。
关于tabindex
属性,官方文档有很多说明,大家只需要记住以下几点常用的即可:
tabindex
值为整数,有正数、负数、0三种情况- 若是设置负值,一般设置负一,让元素可以用聚焦(不能通过键盘导航来访问到该元素),某些情况下,特别好用
tabindex
值设置正数,按下tab
键就可以访问了
一般不去设置tabindex
,除非需要设定按下tab键盘切换的顺序,设置为负值也只是为了让这个dom
可以聚焦,可以:focus
。另附传送门官方文档:https://developer.mozilla.org...
复习了这个知识点,我们就可以去做点击dom(聚焦)加上背景色,点击别的地方(失焦)恢复成原样。代码如下:
<div class="tabC" tabindex="-1">tabindex="-1"可通过焦点访问到</div>
.tabC {
width: 320px;
height: 120px;
line-height: 120px;
text-align: center;
border: 2px solid #333;
}
// 搭配tabindex就可以使用:focus选择器了
.tabC:focus {
background-color: pink;
}
对应效果图:
- 笔者的树组件中实现,点击树节点加上选中效果,就是通过这种方式,css方式去实现的。
- 实际上也可以通过
js
的方式去控制,思路:就是递归treeData
数据,给每一个节点都加上一个布尔值,isFocus
变量,通过点击控制这个变量,从而控制点击选中的样式效果(大家可以尝试一下这种实现方式)
至此,点击树的某个节点给树节点加上一个聚焦状态的功能就完成了
3.点击小图标收起树的子节点(展开与折叠)
这个就简单了,控制子节点的展开和折叠,笔者使用:style的方式,控制display属性为none还是block,如下代码:
<div
class="childrenTreeNode"
:style="{
display: isFold ? 'none' : 'block',
}"
>
<template v-if="item.children">
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
></tree-item>
</template>
</div>
data() {
return {
isFold: true, // 默认有子节点都折叠起来
};
},
当点击小图标的时候,控制isFold
的值取反即可,就不停的显示和隐藏了,即展开和折叠了。
别忘了,给小图标加上一个旋转变换,这样看着自然一些:transform: rotate(90deg); transition: all 0.3s;
(笔者是通过动态class
加的,最后方的完整代码中,可以看到)
4.统一控制树的展开或者收起(统一展开与统一折叠)
既然是统一控制折叠和收起,就是统一控制isFold
的属性值。我们在props中定义一个变量:
proos:{
expandTree: Boolean, // 默认把树折叠起来
}
然后监听这个变量的变化,控制fold
变量的值,就可以做到统一折叠和展开了。如下代码
watch: {
// 监听布尔值变量变化,统一折叠或者展开
expandTree(newVal) {
this.isFold = !newVal; // 是否要取反取决于大家定义的变量的布尔值
},
},
注意,光这样写,还不够,因为是递归组件,所以数据也要传递哦,无论是myTree
组件还是treeItem
组件中的tree-item
,都得传递,举例如下:
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
:expandTree="expandTree" // 传递...
></tree-item>
至此,统一控制树的展开或者收起功能完成
5.树组件小图标的更改
这里就和上方传递expandTree
字段一样的了,props
中定义接收,循环递归中写,如下:
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
:expandTree="expandTree"
:iconName="iconName" // 传递...
></tree-item>
props:{
iconName: String, // 树组件的小图标
}
6.没有数据时,树组件做一个提示没数据(加个判断即可)
这个简单啦,判断treeData
即可,搭配`v-if
和v-else。
笔者暂无数据中的emoji是从这个网站中的,推荐一下:https://getemoji.com/
😀 😃 😄 😁 😆 😅 😂 🤣`
7.事件的传递(数据的传递)
这里主要讲的是$attr
和$listeners
的用法,这里笔者就不赘述了,笔者之前写过一篇文章,各位读者可以瞅瞅:
https://segmentfault.com/a/11...
$attr
数据兜底、$listeners
事件桥梁...
8. 关于树节点的缩进问题
笔者在这里使用的是给每一个子节点都加上pandding-left
,这样的话:
- 比如一级节点左边距
12px
- 二级节点左边距
24px
- 三级节点左边距
36px
- ...
这样的话,也能实现树结构数据的右侧缩进,如下图:
再把第一级节点的左边距清除掉,即可:
.my-tree-wrap > .treeNodeItem {
padding: 0; // 优化树节点缩进效果,可注释掉看效果
}
完整代码
单看文章,不太够,代码贴出来也有注释,方便读者们调试,完整代码在github上哦,如果对各位读者有一点点帮助的话,可以给咱的github
仓库一个star
哦😄
使用树组件的代码
<template>
<div>
<button @click="expandTree = !expandTree">统一展开折叠</button>
<br />
<br />
<button @click="treeData = []">清空数据(刷新恢复)</button>
<br />
<br />
<button @click="changeIcon">更改图标</button>
<br />
<br />
<div class="treeBox">
<my-tree
:treeData="treeData"
:expandTree="expandTree"
@fold="fold"
@clickTree="clickTree"
clickNameClose
:iconName="iconName"
></my-tree>
</div>
</div>
</template>
<script>
export default {
/**
* 简约树组件需要实现的效果
* 1. 点击focus节点设置背景色,失去焦点背景色消失(tabindex设置)
* 2. 点击树节点的小箭头图标旋转效果,同时折叠或者展开树子节点
* (再添加一个变量,控制点击名字也可以达到同样的效果)
* 3. 可通过传参方式更改小图标
* 4. 层级递归关系
* 5. 点击小图标(折叠树)事件和点击树节点事件
* */
data() {
return {
treeData: [
{
name: "中国",
eng: "China",
children: [
{
name: "北京",
eng: "Beijing",
},
{
name: "上海",
eng: "Shanghai",
children: [
{
name: "闵行区",
eng: "Minhang",
},
{
name: "静安区",
eng: "Jingan",
},
],
},
],
},
{
name: "美国",
eng: "American",
children: [
{
name: "纽约",
eng: "NewYork",
children: [
{
name: "曼哈顿区",
eng: "ManHattan",
},
{
name: "皇后区",
eng: "Queen",
},
{
name: "布鲁克林区",
eng: "Brooklyn",
},
],
},
{
name: "华盛顿",
eng: "Washington",
},
],
},
],
expandTree: true,
iconName: "el-icon-arrow-right",
};
},
methods: {
fold(params, key) {
console.log("fold", params, key);
},
clickTree(params) {
console.log("clickTree", params);
},
changeIcon() {
this.iconName =
this.iconName == "el-icon-caret-right"
? "el-icon-right"
: "el-icon-caret-right";
},
},
};
</script>
<style lang="less" scoped>
.treeBox {
width: 240px;
border: 1px solid pink;
box-sizing: border-box;
padding: 4px 0;
}
</style>
myTree组件代码
<template>
<div class="my-tree-wrap">
<!-- 有数据渲染没数据提示暂无数据 -->
<div v-if="treeData.length > 0">
<!-- 初次循环,组件内再次递归循环,即可实现递归树效果 -->
<tree-item
v-for="(item, index) in treeData"
:key="index"
:item="item"
:expandTree="expandTree"
:iconName="iconName"
:clickNameClose="clickNameClose"
v-on="$listeners"
v-bind="$attrs"
>
<!-- 关于$listeners和$attrs的详细用法,可以看笔者的这篇文章:https://juejin.cn/post/6982727094937583647 -->
</tree-item>
</div>
<span class="noData" v-else>😭暂无数据😭</span>
</div>
</template>
<script>
import treeItem from "./treeItem.vue";
export default {
name: "myTree",
components: { treeItem },
props: {
// 树组件需要的数据数组
treeData: {
type: Array,
default: () => {
return [];
},
},
expandTree: Boolean, // 是否统一展开或关闭
iconName: String, // 树组件的小图标
clickNameClose: Boolean, // 点击树节点名字也能关闭树节点菜单
},
};
</script>
<style lang="less" scoped>
.my-tree-wrap {
width: 100%;
.noData {
color: #666;
font-size: 14px;
}
}
// 通过css解决树结构缩进问题,且要搭配.my-tree-wrap > .treeNodeItem { padding: 0; } (子元素选择器,并非后代元素选择器)
.my-tree-wrap > .treeNodeItem {
padding: 0; // 优化树节点缩进效果,可注释掉看效果
}
</style>
treeItem组件代码
<template>
<!-- 每一个树节点包含:图标部分、名字部分,因为考虑到递归,所以再拆分一个部分,即有树子节点部分childrenTreeNode -->
<div class="treeNodeItem">
<!-- 图标和名字部分,设置tabindex="-1"就可设置:focus的样式了 -->
<div class="iconAndName" tabindex="-1" @click="clickTree">
<!-- 有树子节点才去渲染图标 -->
<i
v-if="item.children"
@click.stop="clickIconFold"
:class="[
'treeNodeItemIcon',
iconName ? iconName : 'el-icon-caret-right',
isFold ? 'iconLeft' : 'iconDown',
]"
></i>
<span
:class="['treeNodeItemName', item.children ? '' : 'noChildrenIcon']"
>{{ item.name }}</span
>
<!-- 注意上方几个动态样式的使用,可以去掉看效果,更加直观 -->
</div>
<!-- 展开折叠通过display: none来控制,进一步延伸为通过变量isFold来控制 -->
<div
class="childrenTreeNode"
:style="{
display: isFold ? 'none' : 'block',
}"
>
<!-- 存在子节点就遍历并递归调用自身这个组件 -->
<template v-if="item.children">
<tree-item
v-for="(ite, ind) in item.children"
:key="ind"
:item="ite"
:expandTree="expandTree"
:iconName="iconName"
:clickNameClose="clickNameClose"
v-on="$listeners"
v-bind="$attrs"
></tree-item>
</template>
</div>
</div>
</template>
<script>
export default {
name: "treeItem",
props: {
// 每一个节点的数据
item: {
type: Object,
default: () => {
return {};
},
},
expandTree: Boolean, // 默认把树折叠起来
iconName: String, // 自定义图标名
clickNameClose: Boolean, // 点击树节点名字,也可以折叠展开菜单(本来设置只能点击图标)
},
watch: {
// 监听布尔值变量变化,统一折叠或者展开
expandTree(newVal) {
this.isFold = !newVal; // 是否要取反取决于大家定义的变量的布尔值
},
},
data() {
return {
isFold: true, // 默认有子节点都折叠起来
};
},
mounted() {
this.isFold = !this.expandTree; // 是否要取反取决于大家定义的变量的布尔值
},
methods: {
clickIconFold() {
this.isFold = !this.isFold;
this.$emit("fold", this.item, this.isFold ? "折叠咯" : "展开啦");
},
clickTree() {
// 默认是点击小图标才能关闭,加上clickNameClose为true属性,设置点击树节点name也能关闭
if (this.clickNameClose) {
this.clickIconFold();
}
this.$emit("clickTree", this.item);
},
},
};
</script>
<style lang="less" scoped>
.treeNodeItem {
width: 100%;
height: auto;
// 通过css解决树结构缩进问题,且要搭配.my-tree-wrap > .treeNodeItem { padding: 0; } (子元素选择器,并非后代元素选择器)
padding-left: 12px;
.iconAndName {
width: 100%;
height: 24px;
display: flex;
align-items: center;
cursor: pointer;
transition: all 0.3s;
box-sizing: border-box;
.treeNodeItemIcon {
margin-right: 4px;
}
// 点击图标旋转一下
.iconDown {
transform: rotate(90deg);
transition: all 0.3s;
}
.iconLeft {
transition: all 0.3s;
}
.treeNodeItemName {
color: #666;
word-break: keep-all; // 不换行
}
// 位置对齐一下
.noChildrenIcon {
margin-left: 20px;
}
}
.iconAndName:hover {
background-color: #f5f7fa;
}
// 搭配tabindex='-1'设置选中聚焦时的背景色
.iconAndName:focus {
background-color: #f5f7fa;
}
}
</style>
总结
A good memory is not as good as a bad pen, record it
**粗体** _斜体_ [链接](http://example.com) `代码` - 列表 > 引用
。你还可以使用@
来通知其他用户。