vue实现支持插槽的Tree组件__Vue.js
发布于 4 年前 作者 banyungong 1241 次浏览 来自 分享
粉丝福利 : 关注VUE中文社区公众号,回复视频领取粉丝福利

写在前面

近期在做移动端项目,用的 UI 框架是 Vant,然后有个需求是只需要我这边渲染出一个部门列表,看了下 vant 好像没有可用的 Tree 组件,于是就自己封装了个

常见的 Tree 组件

确定基本功能

首先确定下 Tree 组件具有的基本功能:

  • [ ] 递归组件显示子节点
  • [ ] 支持 expand 展开功能
  • [ ] 支持 select 点击当前节点返回当前节点信息功能
  • [ ] 点击勾选时联动勾选功能
  • [ ] 获取当前所有勾选的节点
  • [ ] 支持异步拉取数据渲染

构建文件目录

新建一个 components 目录放我们的组件,Node.vue,Tree.vue,组件函数方法库util.js

    |-- app.vue
    |-- components
        |-- tree.vue
        |-- node.vue
        |-- util.js

数据格式转换

通常来讲,后端传给前端的部门信息数据一般都不会是树形,都是要前端转换,所以我们要先把这些数据进行转换

假设我们拿到手的数据,有个parentId 和当前Id,我们可以根据这两个字段来进行递归

当然啦,每个项目他的字段名都不一样,根据自己的项目来,如果叫babaIderziId也没人管你,只要有对应关系就行。

1. 定义一个 formatTree 方法用来格式化数据,把它变成树形结构

function formatTree(arr){  //主函数


 function findParents(arr){
     // 查找根节点父级元素方法,有可能存在多个最外层的父级节点,先把他们找出来
 }

 function findChildren(parents){
   // 递归查找每个parents父级节点对应的子孙节点
 }
}

2. 编写 findParents 方法,找到所有最外层父级节点数据

我们先写下第一个函数方法,findParents,找爸爸,爸爸的爸爸叫什么?

function findParents(arr){
    // arr为原数组

    //通过reduce方法把数组转换成对象,作为一个哈希表(说白了就是个对象)存储他们的parentId
  const map =  arr.reduce((obj,cur)=>{

        let parentId = cur['parentId'] // 获取每一项的parentId

        obj[parentId] = parentId // 把他的parentId作为key值

        return obj
    },{})

    // 最后做一次筛选,找出最外层的父级节点数据
    return arr.filter(item=>!map[item.id])
}

filter 那一步可能有点花里胡哨,map 存储的是每一个节点的id

所以我们只要用原数组arr进行一次 filter,把每一个项的parentId都放进去 map 去匹配,如果找不到,说明他是最外层根节点的数据

3. 编写 findChildren 方法,找出父级节点的所有子节点

function findChildren(parents){
    if(!parents.length) return
    parents.forEach(p=>{
        arr.forEach(item=>{
        // 如果原数组arr里面的每一项中的parentId等于父级的某一个节点的id,则把它推进父级的children数组里面
            if(p.id === item.parentId){
                if(!p.children){
                    p.children = []
                }
                p.children.push(item)
            }
        })
        // 最后进行一次递归,找儿子们的儿子们
        findChildren(p.children)
    })
}

值得注意的是,这里我是通过修改传进来的原数据 parents,给他添加 children 属性

4.完整的代码

 function formatTree(arr) {
    // 有可能存在多个最外层的父级节点,先把他们找出来
    function findParents(arr) {
      // arr为原数组
      //通过reduce方法把数组转换成对象,作为一个哈希表(说白了就是个对象)存储他们的id
      const map = arr.reduce((obj, cur) => {
        let id = cur['id'] // 获取每一项的id
        obj[id] = id
        return obj
      }, {})

      // 最后做一次筛选,找出最外层的父级节点数据
      return arr.filter(item => !map[item.parentId])
    }

    let parents = findParents(arr) // 获取最外层父级节点
    // 查找每个parents 对应的子孙节点,此处开始递归

    function findChildren(parents) {
      if (!parents) return
      parents.forEach(p => {
        arr.forEach(item => {
          // 如果原数组arr里面的每一项中的parentId恒等于父级的某一个节点的id,则把它推进父级的children数组里面
          if (p.id === item.parentId) {
            if (!p.children) {
              p.children = []
            }
            p.children.push(item)
          }
        })
        // 最后进行一次递归,找儿子们的儿子们
        findChildren(p.children)
      })
    }
    findChildren(parents)
    return parents
  }

app.vue页面,虽然还没有写Tree组件,但是可以先把他引进来,根据上述的功能清单,Tree组件有几个属性:

属性名 作用 类型 默认值
data 可嵌套的节点属性的数组,生成 tree 的数据 Array(详细数据格式参考 data 格式表) []
showCheckBox 是否显示多选框 Boolean false

事件:

事件名 作用 返回值
onChecked 勾选时触发 tree 的数据 当前已选中的节点数据
onExpand 点击展开时触发 当前已选中的节点数据
onSelect 点击节点时触发 当前已选中的节点数据

另外需要注意的是,传进去 data数组元素格式表:

属性 作用 类型
title 节点显示的文字内容 String
expand 是否展开状态 Boolean
checked 是否勾选状态 Boolean
children 子节点内容 Array

我们定义一个假数据叫list的数组,为了防止数据污染,先把他深拷贝一次,赋值给depDataTree组件通过:data进行接收

然后使用上面的formatTree 把它转换成树形结构数据,传入到Tree组件里面

<!--app.vue-->
<template>
  <div id="app">
    <Tree
      :data="depData"
      :showCheckBox="true"
      @onChecked="handleChecked"
      @onExpand="handleExpand"
      @onSelect="hanldeSelect"
    />
  </div>
</template>

<script>
import Tree from './components/Tree'
export default {
  components: {
    Tree,
  },
  data() {
    return {
      depData: [],
      list: [ // 定义初始数据
        {
          id: 1,
          parentId: 0,
          title: '公司',
        },
        {
          id: 4,
          parentId: 1,
          title: '开发一部',
        },
        {
          id: 2,
          parentId: 1,
          title: '开发二部',
        },
        {
          id: 5,
          parentId: 3,
          title: '前端组',
        },
        {
          id: 3,
          parentId: 1,
          title: '开发三部',
        },
        {
          id: 6,
          parentId: 3,
          title: '后端组',
        },
        {
          id: 7,
          parentId: 4,
          title: '爆破组',
        },
        {
          id: 8,
          parentId: 2,
          title: '测试组',
        },
        {
          id: 9,
          parentId: 2,
          title: '运维组',
        },
        {
          id: 10,
          parentId: 9,
          title: '西岚',
        },
        {
          id: 11,
          parentId: 9,
          title: '东岚',
        },
        {
          id: 12,
          parentId: 5,
          title: '南岚',
        },
      ],
    }
  },
  methods: {
    formatTree(arr) {
      // 有可能存在多个最外层的父级节点,先把他们找出来
      function findParents(arr) {
        // arr为原数组
        //通过reduce方法把数组转换成对象,作为一个哈希表(说白了就是个对象)存储他们的id
        const map = arr.reduce((obj, cur) => {
          let id = cur['id'] // 获取每一项的id
          obj[id] = id
          return obj
        }, {})
        // 最后做一次筛选,找出最外层的父级节点数据
        return arr.filter((item) => !map[item.parentId])
      }
      let parents = findParents(arr) // 获取最外层父级节点
      // 查找每个parents 对应的子孙节点,此处开始递归
      function findChildren(parents) {
        if (!parents) return
        parents.forEach((p) => {
          arr.forEach((item) => {
            // 如果原数组arr里面的每一项中的parentId恒等于父级的某一个节点的id,则把它推进父级的children数组里面
            if (p.id === item.parentId) {
              if (!p.children) {
                p.children = []
              }
              p.children.push(item)
            }
          })
          // 最后进行一次递归,找儿子们的儿子们
          findChildren(p.children)
        })
      }
      findChildren(parents)
      return parents
    },
    handleChecked(v) {
      console.log(v)
      // 定义handleChecked方法,当点击勾选的时候,接收Tree组件 $emit出来的值
    },
    handleExpand(v) {
      console.log(v)
      //当点击展开的时候,接收Tree组件 $emit出来的值
    },
    hanldeSelect(v) {
      console.log(v)
      //,当点击节点的时候,接收Tree组件 $emit出来的值
    },
    deepCopy(data) {
      //深拷贝原数据
      if (data == null) return // 如果为空则返回
      let typeOf = (d) => {
        return Object.prototype.toString.call(d)
      }
      let o = null
      if (typeOf(data) === '[object Object]') {
        o = {}
        for (let k in data) {
          o[k] = this.deepCopy(data[k])
        }
      } else if (typeOf(data) === '[object Array]') {
        o = []
        for (let i = 0; i < data.length; i++) {
          o.push(this.deepCopy(data[i]))
        }
      } else {
        return data
      }
      return o
    },
  },
  computed: {
    data() {
      return this.formatTree(this.depData)
    },
  },
  created() {
    // 把每个数组都添加两个属性,一个展开expand一个checked勾选
    this.depData = this.deepCopy(this.list).map((item) => {
      item.checked = false
      item.expand = true // 为了方便展示,这里先让他展开
      return item
    })
  },
}
</script>

开发 Tree 组件

我这里用的 Vue2.6 版本, webpack4 搭建项目

1. 组件拆分

把这个 Tree 组件拆分两部分:

  1. 一部分是 Tree.vue,负责获取数据,
  2. 另一部分是 Node.vue, 节点组件,负责递归渲染树形结构

首先开发Tree组件,Tree组件拿到的 props是个格式化后的树形数组

所以我们先进行一次循环,把 每一个数组元素 传进去子组件Node 里面,这时候 Node 拿到的是个对象

Tree.vue

<!--Tree.vue-->
<template>
  <div class="tree">
    <div class="tree-node" v-for="item in data" :key="item.id">
      <Node :data="item" :showCheckBox="showCheckBox" :loadData="loadData"/>
    </div>
  </div>
</template>

<script>
import Node from './Node';
export default {
name:"Tree",
  components: {
    Node
  },
  props: {
    data: {
      // 外部传入的数据
      type: Array,
      default() {
        return [];
      }
    },
    showCheckBox: {
      // 配置项,是否显示checkbox
      type: Boolean,
      default: false
    },
    loadData: {
      // 配置项,异步加载数据的回调方法,
      type: Function
    }
  },
  // 先定义几个方法,这些方法最终要执行的是把Node组件传过来的数据抛到调用Tree的组件里面
  methods: {
    handleCheck() {
      // 定义勾选方法
    },
    handleSelect() {
      // 定义点击节点方法
    },
    handleExpand() {
      // 定义点击展开方法
    },
    getCheckedNodes() {
      // 获取勾选的节点
    },
    getCheckedChildrenNodes() {
      // 仅获取勾选的子节点
    }
  }
};
</script>

<style>
.tree-node {
  font-size: 30px;
  width: 90%;
  margin: 0 auto;
}
</style>

Node.vue

下面就是重头戏也就是需要递 🐢 的 Node 节点,

<!--Node.vue-->
<template>
  <ul class="tree-ul">
    <!--tree的每一行-->
    <li class="tree-li">
      <van-checkbox class="checkbox"  icon-size="18px" v-if="showCheckBox" :value="data.checked" @input="handleCheck"></van-checkbox>
      <span>{{ data.title }}</span>
      <span class="tree-expand" @click="handleExpand">
          <!--展开箭头组件-->
          <van-icon v-if="showArrow" :name="arrowType" />
            </span>
      <!--node组件递归-->
      <Node v-show="data.expand" :showCheckBox="showCheckBox" :loadData="loadData" v-for="(item, index) in data.children" :key="index" :data="item" />
    </li>
  </ul>
</template>

<script>
  export default {
    name: 'Node', // 这个很关键,递归组件必须有name
    props: {
      data: {
        type: Object,
        default () {
          return {}
        }
      },
      showCheckBox: {
        type: Boolean,
        default: false,
      },
      loadData:{
          type:Function
      }
    },
    computed: {
      showArrow() {
    return this.data.children&&this.data.children.length
      },
      arrowType() {
        //箭头方向,van组件提供的属性
        return this.showArrow && this.data.expand ? 'arrow-down' : 'arrow';
      }
    },
    methods: {
      handleCheck() {}, //勾选方法
      handleExpand() {},//展开的方法
      handleSelect() {}, //点击列表
    },
    mounted(){
    }
  }
</script>

<style>
  .tree-ul,
  .tree-li {
    font-size: 20px;
    list-style: none;
    margin-left: 10px;
    position: relative;
    height: auto;
  }
  .tree-ul {
    margin: 0 auto;
    box-sizing: border-box;
  }
  .tree-li {
    position: relative;
    width: 100%;
    box-sizing: border-box;
    margin: 6px 3px;
    padding-right: 3px;
    padding-left: 10px;
  }
  .tree-expand {
    height: 20px;
    cursor: pointer;
    position: absolute;
    top: 4px;
    right: 0;
    margin: auto;
  }
  .checkbox {
    display: inline-block!important;
    vertical-align: middle;
    margin-right: 4px;
  }
  .tree-loading {
    width: 20px;
    height: 20px;
    position: absolute;
    top: 0;
    right: 0;
    margin: auto;
  }
</style>

定义三个方法用来接收Tree传过来的值,然后跑下我们的生成树形结构数据的代码,可以看到基本的 tree 组件已经基本跑起来了。

但是现在的 tree 没有点击展开和勾选等事件,这也是最核心的一部分内容, 目前我们的目录结构如下:

我们新增一个util.js文件,用来存放我们的工具方法

2.完成 node 节点的点击,勾选,展开方法

首先我们先考虑下,无论是点击还是勾选等操作,都是在子节点上面进行,获取到的是值最终还是得把它抛到最外层组件,也就是调用 Tree 组件的app.vue页面上,所以我们得先来过下 vue 里面组件传值的方法

传值方式 优点 缺点
propsthis.$emit 常用方式支持父子组件传值 不支持直接的兄弟组件传值
props.sync 常用方式支持父子组件传值 不支持直接的兄弟组件传值
bus总线机制 可以实现全局通讯 需要再new Vue并且要把 bus 绑定在原型链,不适合单独的组件
provideinject 子组件可以获取父组件数据,支持跨级获取 数据并非响应式,子组件向父组件传值并不是很友好
vuex 最佳的全局通讯解决方案 需要引入第三方包,不适用于单独的组件
this.$children,this.$ref 支持获取父级或子组件的值 不支持跨级和兄弟组件传值
this.$attrsthis.$listener 支持获取父级或子组件的值,提高组件封装性 不支持跨级和兄弟组件传值

除此之外还有 vue 1.x 版本的时候提供的 api $dispatch$broadcast

在子组件调用 dispatch 方法,向上级指定的组件实例(最近的)上触发自定义事件,并传递数据,且该上级组件已预先通过 $on 监听了这个事件;

相反,在父组件调用 broadcast 方法,向下级指定的组件实例(最近的)上触发自定义事件,并传递数据,且该下级组件已预先通过 $on 监听了这个事件。

看起来好像很好用,但是却被废除了,官方的解释是

因为基于组件树结构的事件流方式有时让人难以理解,并且在组件结构扩展的过程中会变得越来越脆弱。

嗯…好吧,其实我参考了下其他 ui 框架的源码,他们差不多是自己实现了一个$dispatch$broadcast,然后在 mixins 到组件里面进行使用

3.使用发布订阅模式实现EventBus完成组件传值

我这里的话,考虑到 tree 是个递归组件,因此自己使用了发布订阅模式实现了一个类似于 bus 机制的传值方式,但是这个只在我组件内部使用,不会污染到外层业务组件,

// util.js
/**
 *  发布订阅模式,
 *  消息发布中心
 * @class BroadCast
 */
class BroadCast {
  constructor() {
    this.listMap = {}; // 存储监听者
  }
  emit(k, v) { // 发布消息函数,k为监听者的key,v为需要传值的value
    this.listMap[k] &&
      this.listMap[k].map((fn) => {
        fn.call(null, v);
      });
  }
  on(k, fn) { // 添加监听者,,k为监听者的key,fn为执行的回调函数
    if (!this.listMap[k]) {
      this.listMap[k] = [];
    }
    this.listMap[k].push(fn);
  }
}

const broadCast = new BroadCast();
export default { // 这部分是要Mixins到组件里面
  methods: {  // 定义broadcast发布消息方法
    broadCast(k, v) {
      broadCast.emit(k, v);
    },
    on(k, fn) { // 定义接收消息方法
      broadCast.on(k, fn);
    }
  }
};

使用的话就是通过mixinsapi 把它混入到需要传值和接收值的组件里面,通过this.broadCast传值,this.on接收值

Node.vue

// Node.vue
// ... 省略<template>代码
import { broadCastMixins } from './util'; // 引入broadCast传值的方法
export default {
  name: 'Node',
  props: {
    // ...省略props代码
  },
  mixins: [broadCastMixins],
  computed: {
  // ...省略computed代码
  },
  methods: {
      handleCheck() { //勾选方法
        this.$set(this.data, 'checked', !this.data.checked);
        this.broadCast('handleCheck', this.data);
      }, 
      
      handleExpand() {//展开的方法
        this.$set(this.data, 'expand', !this.data.expand);
        this.broadCast('handleExpand', this.data);
      }, 
      
      handleSelect() {//点击节点方法
        this.broadCast('handleSelect', this.data);
      } 
  }
};

我们在 Node 组件引入util.js里面的传值方法,当对节点进行操作的时候,把this.data值传出去,第一个参数就是this.on接收的 key 名,第二个参数就是需要传的值

另外这里我们并没有使用this.data.checked = !this.data.checked这种方式,而采用了this.$set,主要是前者设置值不是响应式,可能导致数据改变视图没有更新的情况

Tree.vue

// Tree.vue
// ... 省略<template>代码
import Node from './Node';
import { broadCastMixins } from './util'; // Tree组件也要引入传值方法,用来接收值
export default {
name:"Tree",
  components: {
    Node
  },
  mixins: [broadCastMixins],
  props: {
    // 隐藏props代码
  },
  // 这些方法最终要执行的是把Node组件传过来的数据抛到调用Tree的组件里面
  methods: {
    handleCheck(v) {
      // 定义勾选方法
      this.$emit('onChecked', v);
    },
    handleSelect(v) {
      // 定义点击节点方法
      this.$emit('onSelect', v);
    },
    handleExpand(v) {
      // 定义点击展开方法
      this.$emit('onExpand', v);
    },
    getCheckedNodes() {
      // 获取勾选的节点
    },
    getCheckedChildrenNodes() {
      // 仅获取勾选的子节点
    }
  },
  // 在组件created的时候,定义接收方法,this.broadCast方法传过来的值在这里进行接收
  created() {
    this.on('handleCheck', this.handleCheck);
    this.on('handleSelect', this.handleSelect);
    this.on('handleExpand', this.handleExpand);
  }
};

this.on方法定义在created生命周期函数里面,第一个参数就是跟 Node 组件约定好的接收名 key,第二个参数就是执行的回调函数

值得注意的是,只能在 created 里面定义,因为如果是在mounted里面定义的话,是子组件先渲染完,才到父组件渲染完毕,就无法接收到子组件传过来的值,详情可以去搜下父子组件生命周期

到这里点击勾选事件基本已经完成了,看下效果

看得出点击和展开功能都正常,目前我们已经实现的功能如下,还剩下三个功能,

  • [x] 递归组件显示子节点
  • [x] 支持 expand 展开功能
  • [x] 支持 select 点击当前节点返回当前节点信息功能
  • [x] 支持 checkbox 勾选功能
  • [ ] 点击勾选时联动勾选功能
  • [ ] 获取当前所有勾选的节点
  • [ ] 支持异步拉取数据渲染

,目前勾选只是单个勾选,没有实现联动勾选,联动勾选可以说是 Tree 组件最麻烦的一部分内容了…

4.开发联动勾选功能

分两部分:

  1. 勾选一个节点他的子孙节点全部被选中
  2. 如果同级所有节点被选中,则他的父级就被选中,如果父级节点也全部选中,同样层层递归直到根节点

1.勾选一个节点他的子孙节点全部被选中

第一个相对来说比较简单,我们在Tree.vue定义一个函数,

// Tree.vue
// ...省略template代码以及script代码
methods:{
//...省略其他methods代码
    handleCheck(v) {
      this.updateTreeDown(v, v.checked);
      // 定义勾选方法
      this.$emit('onChecked', v);
    },
// node为当前勾选的节点,checked为否勾选的值
    updateTreeDown(node, checked) {
      this.$set(node, 'checked', checked); // 设置勾选状态
      if (node['children']) { // 如果有子节点
        node['children'].forEach((child) => { //则进行一次递归,让子节点也设置同样的勾选值
          this.updateTreeDown(child, checked);
        });
      }
    }
}

// ...省略css代码

当我们点击勾选的时候,通过之前定义的broadCast传值方法,把勾选的节点信息传到Tree.vue,当我们调用updateTreeDown方法时,即可实现子节点勾选状态和当前节点node同步

看下效果,还可以

2.如果同级所有节点被选中,则他的父级就被选中

这一步就比较麻烦,思考一下,当前节点勾选,则需要遍历同级所有节点的状态,如果全部都是勾选,则让它的父级节点也勾选,父级节点同理,层层递归直到跟节点。

因此我们要找到节点之间的对应关系,目前我们这个数据是有parentIdid,因为我们是通过后端传过来的数据进行生成的树形结构。

但是要考虑到某些情况,比如某些需求不需要跟后端交互,只用前端自己写一个静态数据,生成一棵树,那样的话,前端在自定义数据的时候,他就不一定会再每个节点写上一个 id

因此为了解决这个需求场景,我们还得先定义一个方法:

  1. 给每个节点带上一个唯一的key
  2. 然后在生成一个对照表用来寻找他的parentKey

Tree.vue定义一个树形数组拍平方法

//Tree.vue
//...省略template代码和其他script代码
data(){
    return {
        flatTreeMap:{}//存放树形数组拍平后生成的对象
    }
},
    props: {
      data: {
        // 外部传入的数据
        type: Array,
        default () {
          return [];
        }
      },
//...省略其他props代码
    },
    watch: {
      data: { // 当data发生发变化时,在进行一次flatTreeMap更新
        deep: true,
        handler() {
          this.flatTreeMap = this.transferFlatTree();
        }
      }
    },
methods:{
//...省略其他methods代码
      transferFlatTree() {
        let keyCount = 0; // 定义一个key
        //treeArr为树形结构数组,parent为父级
        function flat(treeArr, parent = '') {
          //如果没有值,则返回空对象
          if (!treeArr && !treeArr.length) return {};
          // 因为我们要把数据格式化成一个哈希表结构,也就是对象,所以用了个reduce方便处理
          return treeArr.reduce((obj, cur) => {
            //cur就是数组当前的元素,是个对象,我们给他添加一个属性nodeKey,key的值为keyCount
            let nodeKey = keyCount
            cur.nodeKey = nodeKey;
            //插入obj对象
            obj[nodeKey] = {
              nodeKey: keyCount,
              parent,
              node: cur // 把当前节点赋值给属性node
            };
            // 为了保证每一个key都是唯一,每次都要累加
            keyCount++;
            if (cur.children && cur.children.length) {
              //如果有子节点 进行一次递归
              obj = { ...obj,
                ...flat(cur.children, cur)
              };
            }
            return obj;
          }, {});
        }
        return flat(this.data); // 返回出格式化后的对象
      },
},
    created() {
//...省略其他created里面的代码
   this.flatTreeMap= this.transferFlatTree(); // 执行拍平树形数据操作
  },

//...省略css代码

如果把它打印出来,可以看得到我们已经把格式化成了想要的对象结构

另外,需要注意的是,因为我们是直接对this.data进行修改,添加新的属性,因此Node.vue拿到的数据也是我们添加了nodeKey后的新数据

点击下节点,看见已经被添加上了nodeKey属性了


//Tree.vue
//...
methods:{
//...
     transferFlatTree() {
 //...
   },
       //向上递归勾选函数
     updateTreeUp(nodeKey) {
       // 获取该节点parent节点的nodeKey
       const parentKey = this.flatTreeMap[nodeKey].parent.nodeKey;
       //如果没有则返回,递归停止判断,如果没有父级节点则不继续递归
       if (typeof parentKey == 'undefined') return;
       // 获取当前nodeKey的节点数据
       const node = this.flatTreeMap[nodeKey].node;
       // 获取parent的节点数据
       const parent = this.flatTreeMap[parentKey].node;
       // 如果勾选状态一样则返回,不用做任何操作
       if (node.checked == parent.checked) return;
       // 否则,当子节点有勾选时,判断他的同级节点是不是都是勾选状态,如果是,则父级节点勾选
       // 如果同级节点有些没有勾选,则返回falst,父级节点不勾选
       if (node.checked == true) {
         // 如果当前已勾选,则父几全部勾选
         this.$set(
           parent,
           'checked',
           parent['children'].every((node) => node.checked)
         );
       } else {
         // 如果当前节点不勾选则父级节点不勾选
         this.$set(parent, 'checked', false);
       }
       // 向上递归,直到根节点
       this.updateTreeUp(parentKey);
     },
     handleCheck(v) {
       this.updateTreeUp(v.nodeKey)
       this.updateTreeDown(v, v.checked);
       // 定义勾选方法
       this.$emit('onChecked', v);
     },
//...

我们定一个向上递归勾选的函数updateTreeUp接收一个参数nodeKey,也就是当前勾选节点的nodeKey,这函数它主要有几个功能:

  1. 根据之前我们生成好的对照表flatTreeMap找到他的 node 节点
  2. 根据 node 节点找到 parent 节点
  3. 判断 node 节点的checked是否和 parent 节点的checked一样,如果一样则不进行任何操作,如果不一样则判断同级节点的勾选情况,如果全部都是勾选,则 parent 节点checked为 true,勾选。否则不勾选。
  4. 层层往上递归判断,直到根节点

最后在handleCheck勾选函数里面执行updateTreeUp,把当前节点的nodeKey传进去,看下效果:

向上的联动勾选也实现了,另外还要实现一个跟勾选相关的功能,就是获取当前勾选的所有节点,供组件外部调用。

//Tree.vue
methods:{
  //....
    getCheckedNodes() {
      // 获取勾选的节点
      return Object.values(this.flatTreeMap).filter(
        (item) => item.node.checked
      );
    },
    getCheckedChildrenNodes() {
      // 仅获取勾选的子节点
      return Object.values(this.flatTreeMap).filter(
        (item) => item.node.checked && !item.node.children
      );
    },
}
//...

很简单,只要我们把对照表里面的数据拿出来,筛选出checked为 true 的数据就行,获取子节点的勾选数据在多加一个判断就是没有children属性即可,可以试验下:

 <!--app.vue-->
 <template>
 <div id="app">
   <Tree
     :data="data"
     :showCheckBox="true"
     @onChecked="handleChecked"
     @onExpand="handleExpand"
     @onSelect="hanldeSelect"
     ref="tree"
   />
   <button>获取checkedNodes</button>
   <button>获取checkedChildNodes</button>
 </div>
</template>

<script>
import Tree from './components/Tree';
export default {
   //...
 methods: {
//...
   getCheckedNodes() { // 获取所有勾选节点
     console.log(this.$refs.tree.getCheckedNodes());
   },
   getCheckedChildrenNodes() { //仅获取所有勾选的子节点
     console.log(this.$refs.tree.getCheckedChildrenNodes());
   },
 },

 components: {
   Tree
 }
};
</script>
<!--css代码...-->

看下功能清单,还剩下一个异步加载功能

  • [x] 递归组件显示子节点
  • [x] 支持 expand 展开功能
  • [x] 支持 select 点击当前节点返回当前节点信息功能
  • [x] 点击勾选时联动勾选功能
  • [x] 获取当前所有勾选的节点
  • [ ] 支持异步拉取数据渲染

5.异步加载功能

异步加载常见于树形结构数据不是一次性返回,而是分层级返回,点击父级节点就会请求接口拉取子节点数据。

嗯…改下数据结构先,因为真实开发项目时,后端返回的数据不可能只有这么点,像我们这边项目还会返回他下面有多少个部门和人数,那么我们可以添加一个属性叫depCount显示该节点有多少个子部门。

  data() {
   return {
     list: [
       {
         id: 1,
         parentId: 0,
         title: '公司',
         depCount:11,

       },
       {
         id: 4,
         parentId: 1,
         title: '开发一部',
              depCount:1,
       },
       {
         id: 2,
         parentId: 1,
         title: '开发二部',
              depCount:4,
       },
       {
         id: 5,
         parentId: 3,
         title: '前端组',
              depCount:1,
       },
       {
         id: 3,
         parentId: 1,
         title: '开发三部',

                   depCount:3,
       },
       {
         id: 6,
         parentId: 3,
         title: '后端组',
              depCount:0,
       },
       {
         id: 7,
         parentId: 4,
         title: '爆破组',
              depCount:0,
       },
       {
         id: 8,
         parentId: 2,
         title: '测试组',
              depCount:0,
       },
       {
         id: 9,
         parentId: 2,
         title: '运维组',
              depCount:2,
       },
       {
         id: 10,
         parentId: 9,
         title: '西岚',
       },
       {
         id: 11,
         parentId: 9,
         title: '东岚',
       },
       {
         id: 12,
         parentId: 5,
         title: '南岚',
       }
     ]
   };
 },
// ...
 created() {
   this.depData = this.deepCopy(this.list).map((item) => {
   if(item.depCount!=null){ // 如果该节点有depCount则给他添加一个children属性
     item.children = []
   }
     item.checked = false;
     item.expand = true;
     return item;
   });
 }

同时改下created,给含有depCount的数据加上children属性

打开node.vue添加一个loading状态组件,同时在computed里添加showLoading状态

<template>
  <ul class="tree-ul">
    <!--tree的每一行-->
    <li class="tree-li" @click.stop="handleSelect">
      <van-checkbox class="checkbox" icon-size="18px" v-if="showCheckBox" :value="data.checked" @click.native.stop="handleCheck"></van-checkbox>
      <span>{{ data.title }}{{data.key}}</span>
      <span class="tree-expand" @click.stop="handleExpand">
          <!--异步加载的时候展示loading的组件-->
          <van-loading v-if="showLoading" class="tree-loading" color="#1989fa" />
          <!--展开箭头组件-->
          <van-icon v-if="showArrow" :name="arrowType" />
        </span>
      <!--node组件递归-->
      <Node v-show="data.expand" :loadData="loadData" :showCheckBox="showCheckBox" v-for="(item, index) in data.children" :key="index" :data="item" />
    </li>
  </ul>
</template>
<script>
//...
export default{
        computed: {
      showArrow() {
        // 1.如果数据没有children,说明是子组件,就不用展示下拉箭头
        // 2.如果开启了异步加载,在loading的时候,不显示箭头
        return (
          (this.data.children &&
            this.data.children.length &&
            !this.showLoading) ||
          (this.data.children &&
            !this.data.children.length &&
            this.loadData &&
            !this.showLoading)
        );
      },
      showLoading() {
        // 判断是否有loading属性,并且判断你是否在开启状态
        return 'loading' in this.data && this.data.loading;
      }

        }
        //...
}
</script>
//...

好了,下面就可以开发异步加载方法了

在点击父级节点箭头的时候,才会去拉取异步数据来填充节点,因此这个功能是在handleExpand里面进行的

同时拉到数据后就添加到节点的children属性里面

最后在进行一次展开expand操作,这么想思路就清晰了。

打开Node.vue

methods:{
    //...
    handleExpand() {
        let node = this.data;
        if (node.children && node.children.length === 0) {
          if (this.loadData) {
            this.$set(node, 'loading', true); // 显示loading
            this.loadData(node, (arr, callback) => { // 这个loadData回调函数,由外部组件调用的时候传入arr和callback
              if (arr.length) { // 如果外部传入的数组不为空
                // 把arr作为当前父级节点的children
                this.$set(node, 'children', arr);
                this.$nextTick(() => {
                  //展开操作
                  this.handleExpand();
                  //执行外部传入的回调函数,成功的时候
                  callback('suc', node);
                });
              } else {
                // 如果返回的是为空数组,则执行失败的回调
                callback('fali')
              }
               //关闭loading
              this.$set(node, 'loading', false);
            });
          }
          return;
        }
        this.$set(this.data, 'expand', !this.data.expand);
        this.broadCast('handleExpand', this.data);
      },

}

最后我们在app.vue定义下一个函数把它赋值给loadData

<template>
  <div id="app">
  <!--定义一个名为handleLoadData的函数-->
    <Tree :data="data" :showCheckBox="true" @onChecked="handleChecked" @onExpand="handleExpand" @onSelect="hanldeSelect" :loadData="handleLoadData" ref="tree" />
    <button @click="getCheckedNodes">获取checkedNodes</button>
    <button @click="getCheckedChildrenNodes">获取checkedChildNodes</button>
  </div>
</template>
<script>
//...
methods:{
// 一般这是要拉取后台数据,我们这里只能通过setTimeout来模拟
     handleLoadData(node, callbacks) { //node为点前异步加载的父级节点,callbacks是回调函数,有两个参数,一个是arr,一个callback
//callback接收一个参数res,成功为suc,失败为fali
        setTimeout(() => {
          callback([{
            id: new Date().getTime(),
            parentId: node.id,
            title:['北岚','测试狗','尤玉溪门徒','狂躁的韭菜','前端bb机'] [Math.floor(Math.random()*5)]
          }], (res) => {
           if(res=='suc') {
             console.log('加载成功')
           }
          })
        }, 500);
      },


}

</script>

我这里为了方便演示,在app.vuecreated里面执行deepCopy的时候,修改了item.expand = false

看下效果,效果实现了:

但是有个问题,就是当我们是处于勾选状态的时候,新增加的节点并没有被勾选上,

所以还得对这部分进行处理,其实就是当我们监听到数据发生变化的时候,获取勾选的节点,遍历他们,在执行一次updateTreeDown

    watch: {
      data: {
        deep: true,
        handler() {
          this.stateTree = this.data
          this.flatTreeMap = this.transferFlatTree();
           this.updateCheckedNodesChildren() // 执行遍历勾选节点操作
        }
      }
    },
methods:{
    //...
          updateCheckedNodesChildren() {
        // 获取勾选的节点,
        const checkedNodes = this.getCheckedNodes();
        checkedNodes.forEach((node) => {
          // 勾选的节点的子节点也进行勾选
          this.updateTreeDown(node.node, true);
        });
      },

},
created(){
//...
          this.flatTreeMap = this.transferFlatTree();
            this.updateCheckedNodesChildren()
}

在把测试数据改极端点,每次点击生成 1-10 条数据

打开app.vue

methods:{
    //...
      handleLoadData(node, callback) {
        setTimeout(() => {
          callback([...new Array(Math.floor(Math.random() * 10)+1).keys()].map(() => {
            return {
              id: new Date().getTime(),
              parentId: node.id,
              title: ['北岚', '测试狗', '尤玉溪门徒', '狂躁的韭菜', '前端bb机','前端狂魔','鱿鱼溪','普通的章鱼🐙','狂暴🌲人','鱿鱼🦑冬'][Math.floor(Math.random() * 9)],
              children: []
            }
          }), (res) => {
            if (res == 'suc') {
              console.log('加载成功')
            }
          })
        }, 500);
      },

}

emmm…看下效果,没啥问题

完成基本的 Tree 组件

现在所有功能 都已经完成

  • [x] 递归组件显示子节点
  • [x] 支持 expand 展开功能
  • [x] 支持 select 点击当前节点返回当前节点信息功能
  • [x] 点击勾选时联动勾选功能
  • [x] 获取当前所有勾选的节点
  • [x] 支持异步拉取数据渲染

Tree 组件的插槽

嗯…最后,我们要完成一个插槽功能,起源是之前开会的时候,产品提了一句,后面有可能会改样式,就是说部门和用户的样式不一样,我寻思着,这不就是 parent 节点的样式和 node 节点不一样吗,如果我写死在组件里面的话,那后面要改只能跑到组件里面去修改了。

后面 灵鸡一动,灵机一动,用插槽不就行了吗,在外面自己自定义样式,不用修改组件的东西

但是有个问题,因为是递归组件,而且插槽在模板语法里面不能直接作为 props 传过去,

根据我的研究,有两种方法可以完成这个需求:

  1. 使用 render 函数+jsx 语法,把this.$scopedSlots.default传进去,这里有个前提,就是如果是 vuecli2.x 或者是自己起的脚手架的话,必须打一个babel-plugin-transform-vue-jsx包,如果是 vue-cli3.x 的话,可以直接使用 jsx 语法

  2. 貌似可以通过使用this.$parent层层网上找到Tree节点的插槽,然后渲染在 node 页面,但是我试了一波,发现并不好用

所以,我这里主要是讲第一种。

…其实就把模板语法改成 render 函数,因为我这里是没给他命名,所以默认插槽名是default

以下是完整代码

Tree.vue

//tree.vue

<script>
  import Node from './Node';
  import {
    broadCastMixins
  } from './util';
  export default {
    name:"Tree",
          render() {
    let data = this.stateTree;
    let showCheckBox = this.showCheckBox;
    let loadData = this.loadData
    return (
      <div class={'tree'}>
        {data && data.length
          ? data.map((item, index) => {
              return (
                <Node
                  class={'tree-node'}
                  key={index}
                  data={item}
                  showCheckBox={showCheckBox}
                  loadData={loadData}
                >

                           {/*这里是插槽*/}
                  {this.$scopedSlots.default}
                </Node>
              );
            })
          : ''}
      </div>
    );
  },
    components: {
      Node
    },
    mixins: [broadCastMixins],
    data() {
      return {
        stateTree: this.data,
        flatTreeMap: {}
      };
    },
    props: {
      data: {
        // 外部传入的数据
        type: Array,
        default () {
          return [];
        }
      },
      showCheckBox: {
        // 配置项,是否显示checkbox
        type: Boolean,
        default: false
      },
      loadData: {
        // 配置项,异步加载数据的回调方法,
        type: Function
      }
    },
    watch: {
      data: {
        deep: true,
        handler() {
          this.stateTree = this.data

          this.flatTreeMap = this.transferFlatTree();
           this.updateCheckedNodesChildren()
        }
      }
    },
    methods: {
      transferFlatTree() {
        let keyCount = 0; // 定义一个key
        //treeArr为树形结构数组,parent为父级
        function flat(treeArr, parent = '') {
          //如果没有值,则返回空对象
          if (!treeArr && !treeArr.length) return {};
          // 因为我们要把数据格式化成一个哈希表结构,也就是对象,所以用了个reduce方便处理
          return treeArr.reduce((obj, cur) => {
            //cur就是数组当前的元素,是个对象,我们给他添加一个属性nodeKey,key的值为keyCount
            let nodeKey = keyCount;
            cur.nodeKey = nodeKey;
            //插入obj对象
            obj[nodeKey] = {
              nodeKey: keyCount,
              parent,
              // ...cur
              node: cur // 把当前节点赋值给属性node
            };
            // 为了保证每一个key都是唯一,每次都要累加
            keyCount++;
            if (cur.children && cur.children.length) {
              //如果有子节点 进行一次递归
              obj = { ...obj,
                ...flat(cur.children, cur)
              };
            }
            return obj;
          }, {});
        }
        return flat(this.stateTree); // 返回出格式化后的对象
      },
      updateCheckedNodesChildren() {
        // 获取勾选的节点,
        const checkedNodes = this.getCheckedNodes();
        checkedNodes.forEach((node) => {
          // 勾选的节点的子节点也进行勾选
          this.updateTreeDown(node.node, true);
        });
      },
      updateTreeUp(nodeKey) {
        // 获取该节点parent节点的nodeKey
        const parentKey = this.flatTreeMap[nodeKey].parent.nodeKey;
        //如果没有则返回,递归停止判断,如果没有父级节点则不继续递归
        if (typeof parentKey == 'undefined') return;
        // 获取当前nodeKey的节点数据
        const node = this.flatTreeMap[nodeKey].node
        // 获取parent的节点数据
        const parent = this.flatTreeMap[parentKey].node
        // 如果勾选状态一样则返回,不用做任何操作
        if (node.checked == parent.checked) return;
        // 否则,当子节点有勾选时,判断他的同级节点是不是都是勾选状态,如果是,则父级节点勾选
        // 如果同级节点有些没有勾选,则返回falst,父级节点不勾选
        if (node.checked == true) {
          // 如果当前已勾选,则父几全部勾选
          this.$set(
            parent,
            'checked',
            parent['children'].every((node) => node.checked)
          );
        } else {
          // 如果当前节点不勾选则父级节点不勾选
          this.$set(parent, 'checked', false);
        }
        // 向上递归,直到根节点
        this.updateTreeUp(parentKey);
      },
      handleCheck(v) {
        if (!this.flatTreeMap[v.nodeKey]) return;
        const node = this.flatTreeMap[v.nodeKey]
        this.$set(node, 'checked', v.checked);
        this.updateTreeUp(v.nodeKey);
        this.updateTreeDown(v, v.checked);
        // 定义勾选方法
        this.$emit('onChecked', v);
      },
      handleSelect(v) {
        // 定义点击节点方法
        this.$emit('onSelect', v);
      },
      handleExpand(v) {
        // 定义点击展开方法
        this.$emit('onExpand', v);
      },
      getCheckedNodes() {
        // 获取勾选的节点
        return Object.values(this.flatTreeMap).filter(
          (item) => item.node.checked
        );
      },
      getCheckedChildrenNodes() {
        // 仅获取勾选的子节点
        return Object.values(this.flatTreeMap).filter(
          (item) => item.node.checked && !item.node.children
        );
      },
      // node为当前勾选的节点,checked为否勾选的值
      updateTreeDown(node, checked) {
        this.$set(node, 'checked', checked); // 先设置它的勾选状态
        if (node['children']) {
          // 如果有子节点
          node['children'].forEach((child) => {
            //则进行一次递归,让子节点也设置同样的勾选值
            this.updateTreeDown(child, checked);
          });
        }
      }
    },
    created() {
      this.on('handleCheck', this.handleCheck);
      this.on('handleSelect', this.handleSelect);
      this.on('handleExpand', this.handleExpand);

      this.flatTreeMap = this.transferFlatTree();
            this.updateCheckedNodesChildren()
    }
  };
</script>

<style>
  .tree-node {
    font-size: 30px;
    width: 90%;
    margin: 0 auto;
  }
</style>

Node.vue


<script>
import { broadCastMixins } from './util';
export default {
  name: 'Node', // 这个很关键,递归组件必须有name
  render() {
    let showCheckBox = this.showCheckBox;
    let data = this.data;
    let loadData = this.loadData
    return (
      <div>
        <ul class={'tree-ul'}>
          <li class={'tree-li'} onClick={(e) => this.handleSelect(e)}>
            {showCheckBox && (
              <van-checkbox
                class={'checkbox'}
                icon-size={'18px'}
                value={data.checked}
                onClick={(e) => this.handleCheck(e, data)}
              />
            )}

            {/*如果没有插槽则默认使用显示内容*/}

            {this.$scopedSlots.default ? (
              this.$scopedSlots.default({
                data: data
              })
            ) : (
              <span>{data.title}</span>
            )}
            <span class={'tree-expand'}>
              {this.showLoading ? (
                <van-loading class={'tree-loading'} color={'#1989fa'} />
              ) : (
                ''
              )}
              {this.showArrow ? (
                <van-icon
                  name={this.arrowType}
                  onClick={(e) => this.handleExpand(e)}
                />
              ) : (
                ''
              )}
            </span>
            {data.expand &&
              data.children.map((item, index) => {
                return (
                  <Node key={index} data={item} showCheckBox={showCheckBox} loadData={loadData}>
                    {this.$scopedSlots.default}
                  </Node>
                );
              })}
          </li>
        </ul>
      </div>
    );
  },
  props: {
    data: {
      type: Object,
      default() {
        return {};
      }
    },
    showCheckBox: {
      type: Boolean,
      default: false
    },
    loadData: {
      type: Function
    }
  },
  mixins: [broadCastMixins],
  computed: {
    showArrow() {
      // 1.如果数据没有children,说明是子组件,就不用展示下拉箭头
      // 2.如果开启了异步加载,在loading的时候,不显示箭头
      return (
        (this.data.children &&
          this.data.children.length &&
          !this.showLoading) ||
        (this.data.children &&
          !this.data.children.length &&
          this.loadData &&
          !this.showLoading)
      );
    },
    showLoading() {
      // 判断是否有loading属性,并且判断你是否在开启状态
      return 'loading' in this.data && this.data.loading;
    },
    arrowType() {
      //箭头方向,van组件提供的属性
      return this.showArrow && this.data.expand ? 'arrow-down' : 'arrow';
    }
  },
  methods: {
    handleCheck(e) {
          e.cancelBubble = true;
      this.$set(this.data, 'checked', !this.data.checked);
      this.broadCast('handleCheck', this.data);
    }, //勾选方法
    handleExpand(e) {
          e.cancelBubble = true;
      let node = this.data;
      if (node.children && node.children.length === 0) {
        if (this.loadData) {
          this.$set(node, 'loading', true); // 显示loading
          this.loadData(node, (arr, callback) => {
            // 这个loadData回调函数,由外部组件调用的时候传入arr和callback
            if (arr.length) {
              // 如果外部传入的数组不为空
              // 把arr作为当前父级节点的children
              this.$set(node, 'children', arr);
              this.$nextTick(() => {
                //展开操作
                this.handleExpand(e);
                //执行外部传入的回调函数,成功的时候
                callback('suc', node);
              });
            } else {
              // 如果返回的是为空数组,则执行失败的回调
              callback('falid');
            }
            this.$set(node, 'loading', false); //关闭loading
          });
        }
        return;
      }
      this.$set(this.data, 'expand', !this.data.expand);
      this.broadCast('handleExpand', this.data);
    },
    handleSelect(e) {
     e.cancelBubble = true;
      this.broadCast('handleSelect', this.data);
    } //点击列表
  },
};
</script>

<style>
.tree-ul,
.tree-li {
  font-size: 20px;
  list-style: none;
  margin-left: 10px;
  position: relative;
  height: auto;
}
.tree-ul {
  margin: 15px auto;
  box-sizing: border-box;
}
.tree-li {
  position: relative;
  width: 100%;
  box-sizing: border-box;
  margin: 6px 3px;
  padding-right: 3px;
  padding-left: 10px;
}
.tree-expand {
  height: 20px;
  cursor: pointer;
  position: absolute;
  top: 4px;
  right: 0;
  margin: auto;
}
.checkbox {
  display: inline-block !important;
  vertical-align: middle;
  margin-right: 4px;
}
.tree-loading {
  width: 20px;
  height: 20px;
  position: absolute;
  top: 0;
  right: 0;
  margin: auto;
}
</style>


用法的话也是一样,在app.vue里面使用插槽, 另外的话,我们可以根据 slotProps 传过来的值,也就是每一个节点的 data,根据他做判断是父级节点,还是子节点,像我们这里是根据他的depCount来区分

因此就可以自定义不同类型节点的样式的,我这里为了演示就用了一个AddrItem的组件用来接收子组件的内容作展示

app.vue

<template>
  <div id="app">
    <Tree
      :data="data"
      :showCheckBox="true"
      @onChecked="handleChecked"
      @onExpand="handleExpand"
      @onSelect="hanldeSelect"
      :loadData="handleLoadData"
      ref="tree"
    >
      <template v-slot="slotProps">
        <span v-if="slotProps.data.depCount >= 0"
          >{{ slotProps.data.title }}
        </span>
        <AddrItem
          v-else
          class="addrItem"
          :key="slotProps.data.id"
          :data="slotProps.data"
          sim
        />
      </template>
    </Tree>
<div class="flex">
    <van-button type="primary" @click="getCheckedNodes">获取checkedNodes</van-button>
    <van-button  type="primary" @click="getCheckedChildrenNodes">获取checkedChildNodes</van-button>

</div>
  </div>
</template>

<script>
import Tree from './components/Tree';
import AddrItem from './AddrItem';
export default {
  components: {
    AddrItem,
    Tree
  },
  data() {
    return {
      list: [
        {
          id: 1,
          parentId: 0,
          title: '公司',
          depCount: 11
        },
        {
          id: 4,
          parentId: 1,
          title: '开发一部',
          depCount: 1
        },
        {
          id: 2,
          parentId: 1,
          title: '开发二部',
          depCount: 4
        },
        {
          id: 5,
          parentId: 3,
          title: '前端组',
          depCount: 1
        },
        {
          id: 3,
          parentId: 1,
          title: '开发三部',
          depCount: 3
        },
        {
          id: 6,
          parentId: 3,
          title: '后端组',
          depCount: 0
        },
        {
          id: 7,
          parentId: 4,
          title: '爆破组',
          depCount: 0
        },
        {
          id: 8,
          parentId: 2,
          title: '测试组',
          depCount: 0
        },
        {
          id: 9,
          parentId: 2,
          title: '运维组',
          depCount: 2
        },
        {
          id: 10,
          parentId: 9,
          title: '西岚'
        },
        {
          id: 11,
          parentId: 9,
          title: '东岚'
        },
        {
          id: 12,
          parentId: 5,
          title: '南岚'
        }
      ]
    };
  },
  methods: {
    getCheckedNodes() {
      console.log(this.$refs.tree.getCheckedNodes());
    },
    getCheckedChildrenNodes() {
      console.log(this.$refs.tree.getCheckedChildrenNodes());
    },
    formatTree(arr) {
      console.log(arr);
      // 有可能存在多个最外层的父级节点,先把他们找出来
      function findParents(arr) {
        // arr为原数组
        //通过reduce方法把数组转换成对象,作为一个哈希表(说白了就是个对象)存储他们的id
        const map = arr.reduce((obj, cur) => {
          let id = cur['id']; // 获取每一项的id
          obj[id] = id;
          return obj;
        }, {});
        // 最后做一次筛选,找出最外层的父级节点数据
        return arr.filter((item) => !map[item.parentId]);
      }
      let parents = findParents(arr); // 获取最外层父级节点
      // 查找每个parents 对应的子孙节点,此处开始递归
      function findChildren(parents) {
        if (!parents) return;
        parents.forEach((p) => {
          arr.forEach((item) => {
            // 如果原数组arr里面的每一项中的parentId恒等于父级的某一个节点的id,则把它推进父级的children数组里面
            if (p.id === item.parentId) {
              if (!p.children) {
                p.children = [];
              }
              p.children.push(item);
            }
          });
          // 最后进行一次递归,找儿子们的儿子们
          findChildren(p.children);
        });
      }
      findChildren(parents);
      return parents;
    },
    handleChecked(v) {
      console.log(v, 'checked');
    },
    handleExpand(v) {
      console.log(v, 'expand');
    },
    hanldeSelect(v) {
      console.log(v, 'select');
    },
    deepCopy(data) {
      if (data == null) return;
      let typeOf = (d) => {
        return Object.prototype.toString.call(d);
      };
      let o = null;
      if (typeOf(data) === '[object Object]') {
        o = {};
        for (let k in data) {
          o[k] = this.deepCopy(data[k]);
        }
      } else if (typeOf(data) === '[object Array]') {
        o = [];
        for (let i = 0; i < data.length; i++) {
          o.push(this.deepCopy(data[i]));
        }
      } else {
        return data;
      }
      return o;
    },
    handleLoadData(node, callback) {
      setTimeout(() => {
        callback(
          [...new Array(Math.floor(Math.random() * 10) + 1).keys()].map(() => {
            return {
              id: new Date().getTime(),
              parentId: node.id,
              title: [
                '北岚',
                '测试狗',
                '尤玉溪门徒',
                '狂躁的韭菜',
                '前端bb机',
                '前端狂魔',
                '鱿鱼溪',
                '普通的章鱼🐙',
                '狂暴🌲人',
                '鱿鱼🦑冬'
              ][Math.floor(Math.random() * 9)],
              children: [],
              depCount: Math.floor(Math.random() * 10) + 1
            };
          }),
          (res) => {
            if (res == 'suc') {
              console.log('加载成功');
            }
          }
        );
      }, 500);
    }
  },
  computed: {
    data() {
      return this.formatTree(this.depData);
    }
  },
  created() {
    this.depData = this.deepCopy(this.list).map((item) => {
      if (item.depCount != null) {
        item.children = [];
      }
      item.checked = false;
      item.expand = false;
      return item;
    });
  }
};
</script>

<style>
html,
body {
  width: 100%;
  height: 100%;
  margin: 0;
}
#app {
  position: relative;
  width: 100%;
  height: 100%;
  padding-top: 100px;
}
.flex{
  display: flex;
  justify-content: space-around;
}
</style>

最后最后在看下整体效果,,

除了样式丑点之外都可以的,,

功能总览:

属性名 作用 类型 默认值
data 可嵌套的节点属性的数组,生成 tree 的数据 Array []
showCheckBox 是否显示多选框 Boolean false

事件:

事件名 作用 返回值
onChecked 勾选时触发 tree 的数据 当前已选中的节点数据
onExpand 点击展开时触发 当前已展开的节点数据
onSelect 点击节点时触发 当前点击的节点数据

methods

方法名 作用
getCheckedNodes 获取所有勾选的节点数据
getCheckedChildrenNodes 仅获取所有勾选的子节点数据

话说文章这么长估计没人看到这里了吧…

写在最后

代码已经上传仓库:github

参考资料:iview 源码 , 《Vue.js 组件精讲》

版权声明:著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。 作者: 西岚 原文链接:https://juejin.im/post/6860396460987875335

回到顶部