2
头图

本篇文章记录仿写一个树tree组件细节。源码在github上,也有演示效果的网址,大家可以拉下来,npm start运行跑起来,结合注释有助于更好的理解。

github仓库地址:https://github.com/shuirongsh...

网址演示效果地址:http://ashuai.work:8888/#/myTree

树组件说明

关于树结构形式的效果,在工作中很常见,如,菜单树、权限树、关系组织树等一些展示层级关系的功能。本文使用组件递归的思想,实现一个树组件,我们先看一下效果图:

树组件的功能

我们知道,树组件三大核心功能:

  • 基本功能
  • 勾选相关功能
  • 树节点懒加载功能

考虑到篇幅和阅读成本原因(作者懒),本文只讲述基本的树功能,后续树的勾选和懒加载以后,不忙了,再去发文章。

树的基本功能需要实现的效果

  1. 根据JSON树结构的数据递归组件展示层级关系(树节点要有缩进问题,树层级越深,越往右靠)
  2. 点击树的某个节点给树节点加上一个聚焦状态(点击加上背景色,点击别的地方背景色消失)
  3. 点击小图标收起树的子节点(展开与折叠)
  4. 统一控制树的展开或者收起(统一展开与统一折叠)
  5. 树组件小图标的更改(可以使用插槽,这里笔者使用的类名变量更改的,思想是一样的)
  6. 没有数据时,树组件做一个提示没数据(加个判断即可)
  7. 事件的传递(点击小图标折叠树节点、点击树节点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属性,官方文档有很多说明,大家只需要记住以下几点常用的即可:

  1. tabindex值为整数,有正数、负数、0三种情况
  2. 若是设置负值,一般设置负一,让元素可以用聚焦(不能通过键盘导航来访问到该元素),某些情况下,特别好用
  3. 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-ifv-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


水冗水孚
1.1k 声望588 粉丝

每一个不曾起舞的日子,都是对生命的辜负