前言

Tree组件在实际应用中非常广泛,例如省市县地域的展现.一般一些包含从属关系的数据都可以使用Tree组件来进行展示,下面通过一个实战的demo来深入学一下实现一个Tree组件所要了解的原理和实现细节.本文实现的功能包含以下三点.

  • 实现一个基础版可以显示嵌套数据的Tree组件
  • 点击Tree组件的某一级标签它的下一级数据支持异步加载
  • Tree组件的节点支持拖拽

最终Demo的效果图如下.

基础版的Tree

实现一个基础版的Tree组件十分简单,原理就是掌握组件嵌套的使用方法.

外部调用

首先设置外部调用Tree组件的模板如下.Tree组件只需要传入一个data属性,就可以将data数据渲染成相应的树形结构.

<template>
  <Tree
    :data="data"
  />
</template>
<script>
import Tree from "../../components/Tree/index.vue";
export default {
  data() {
    return {
      data: [
        {
          label: "一级",
          children: [
            {
              label: "二级1",
              children: [
                {
                  label: "三级1",
                },
              ],
            }
          ],
        }
      ],
    };
  },
  components: {
    Tree,
  }
 }
 </script>

Tree组件的实现

Tree组件包含两个文件,一个是index.vue,另一个文件是Tree.vue.

index.vue

index.vue内容如下,它是作为Tree组件与外部衔接的桥梁,很多扩展功能都可以在这个中间层进行处理,而Tree.vue只需要处理数据展现相关的逻辑.

<template>
  <div class="tree">
    <template v-for="(item, index) in data_source">
      <Tree :item="item" :key="index" class="first-layer" />
    </template>
  </div>
</template>
<script>
import Tree from "./Tree";
import { deepClone } from "../util/tool.js"; //深度克隆函数
export default {
  props: {
    data: Object | Array,
  },
  data() {
    return {
      tree_data: deepClone(this.data),
    };
  },
  computed: {
    data_source() {
      if (Array.isArray(this.tree_data)) {
        return this.tree_data;
      } else {
        return [this.tree_data];
      }
    },
  },
  components: {
    Tree,
  }
 }
 </script>

上方代码将外部传入的data数据做了深度克隆赋值给tree_data.如果组件内部存在一些改变数据的功能,那么就可以直接操作tree_data,而不会影响外界传入的数据.

为了让Tree组件支持数组渲染,也支持对象渲染,新增了一个计算属性data_source将数据源最终都转化成数组,再遍历渲染Tree组件;

Tree.vue

Tree.vue文件里面包含具体渲染树形结构数据的具体代码.它的模板内容分为两部分,一个是渲染label对应的标题内容,另外一个渲染子级.

在组件中设置一个状态is_open用来控制它的下一级是处于打开还是关闭状态.

getClassName通过is_open可以渲染出相应的类名来显示三角形的图标是向下还是向右显示.

Tree.vue中设置一个name属性Tree,接下来就可以在模板中嵌套调用自己了.通过遍历item.children,将每一层级的数据复制给Tree组件,就达到了渲染树形结构数据的目的.

<template>
  <div class="tree">
    <div
      class="box"
      @click="toggle()"
    >
      <div :class="['lt', getClassName]"></div>
      <div class="label lt">{{ item.label }}</div>
    </div>
    <div class="drop-list" v-show="is_open">
      <template v-for="(child, index) in item.children">
        <Tree :item="child" :key="index" />
      </template>
    </div>
  </div>
</template>

<script>
export default {
  name: "Tree",
  props: {
    item: Object
  },
  data() {
    return {
      is_open: false, //是否打开下一级
    };
  },
  computed: {
    getClassName(){
        return this.is_open ? "down" : "right";
    }
  },
  methods:{
     toggle() {
       this.is_open = !this.is_open;
    },
  }
};
</script>

渲染结果如下:

异步加载

上面的基础版Tree组件只支持基本的数据渲染,外部需要先把数据准备好,再扔给它就能将对应的数据渲染出来.

假设数据是保留在服务器端的,我希望点击label的时候它会去请求服务器,在等待期间会将Tree组件点击的那一级显示loading...的字样,等到数据完全返回再渲染子级.如下图所示.

Tree组件本身是无法知晓去请求何处的数据,因此请求获取数据的逻辑属于自定义内容,需要用户自己编写.最好这部分逻辑封装成一个函数传给Tree组件.当Tree组件的label被点击时,Tree组件检测到需要异步请求就会直接调用传递过来的函数,请求成功后再将数据添加到自己的tree_data数据源上,让页面重新渲染.将步骤拆分如下.

  • 外部定义数据加载函数传递给Tree组件.
  • Tree组件的label点击时,触发数据加载函数,并将状态更新为loading...,等待响应结果.
  • 响应数据返回后再更新整个tree_data触发界面重新渲染.

外部定义数据加载函数

模板template中新增两个属性lazyload.

lazy传递给子组件指定数据渲染为异步,load属性对应的函数loadNode为获取数据的函数,传递给Tree组件使用.

<template>
  <Tree
    :data="data"
    :load="loadNode"
    :lazy="true"
  />
</template>

loadNode函数我们设计时设定会返回两个参数node对象和resolve函数.node对象又包含两个属性layerchildren.

layer是点击label标签时该标签位于第几级的级数,而children是下一级的数据.如下代码所示data第一级的children有数据,用户点击第一级的label时,第一级的children数据就能通过node对象获取到.

resolve函数执行会将最终结果传递给Tree组件.下面的代码可以描述为当用户点击第一级的标签时,直接返回data中定义的初始数据,而点击其他层级标签时,会执行定时器里面的异步操作,将resolve包裹的数据传递给Tree组件渲染.

外部调用文件

 data(){
   return {
     label:"一级数据"
     children:[{
       label:"二级数据"
     }]
   }
 },
 methods: {
    loadNode(node, resolve) {
      const { layer, children } = node;
      if (layer <= 1) {
        resolve(children);
      } else {
        setTimeout(() => {
          resolve([
            {
              title: `第${layer}层`,
            },
          ]);
        }, 1500);
      }
    },
 }

Tree组件处理加载函数

Tree.vue文件中新增两个属性loadingloaded,用来指示加载的状态.当loading为true时,模板就会渲染加载中...的字样.

一旦接受的到的lazy为true时,通过执行外部定义的数据加载函数this.load来获取异步数据.this.load接受两个参数dataresolve函数.

Tree.vue文件

 props: {
    item: Object 
 },
 data() {
    return {
      is_open: false, //是否打开下一级
      loading: false, //是否加载中
      loaded: false, //是否加载完毕
    };
 },
 methods:{
    toggle(){ //点击label时触发
       if(this.lazy){ //异步请求数据
        if (this.loading) {
          //正在加载中
          return false;
        }
        this.loading = true;
        const resolve = (data) => {
          this.is_open = !this.is_open;
          this.loading = false;
          this.loaded = true;
          this.updateData({ data, layer: this.layer });
        };
        const data = { ...this.item, layer: this.layer.length };
        this.load(data, resolve);//执行数据加载函数
       }else{
         ...
       }
     }	
 }

const data = { ...this.item, layer: this.layer.length };

this.item存储了当前级的数据.this.layer存储了当前级的索引数组,它的数组长度就是对应的层级数.this.layer详细描述如下.

假设数据源data如下.用户点击了2-2级标签,this.layer值为[0,1].通过this.layer可以追踪到该级数据处于数据源的索引集合.

  data = [{
    label:"1级",
    children:[{
       label:"2-1级"
    },{
       label:"2-2级"
    }}]
  }]

this.load会将resolve函数作为参数传递进去,一旦异步数据加载完毕resolve函数就会执行.将loaded状态更新为true,loading更新为false.随后执行祖先传递过来的this.updateData函数,并传入异步返回的结果data.this.updateData执行会更新Tree组件的根级数据tree_data,从而重新渲染组件树.

更新tree_data

updateData函数获取到子级传递过来的异步响应的数据data和索引数组layer.通过这两个参数就可以将data更新到根节点的数据源上.

getTarget函数的作用就是根据索引数组找到数组对应的最后一级的对象.例如layer的值为[0,0],而result的值为

[
  {
    label:"第一级",
    children:[{
      label:"第二级"
    }}]
  }
]

getTarget(layer,result)执行的结果就会返回label"第二级"的那个对象.一旦操作这个对象的数据,result的数据就会相应变化.

index.vue文件

 methods:{
    updateData(data) {
      const { data: list, layer } = data;

      let result = [...this.data_source];

      const tmp = this.getTarget(layer, result);//根据索引数组和数据源找到那一级的数据

      tmp.children = list;

      this.tree_data = result;
    }
 }

通过getTarget函数找到那一级的数据tmp,将其children更新为list,并将result重新赋值给tree_data.这样异步请求的数据就加到了数据源上.

节点拖拽

节点的拖拽可以借助HTML5中的拖拽API轻松实现.在dom元素上添加draggable="true"时,那么就表明该元素允许拖拽了.

HTML5中的拖拽API还包含几个事件监听函数,比如dragstart,drop,dragover等.

  • dragstart事件会在鼠标按住某dom元素即将拖拽时触发,它的事件对象e支持调用e.dataTransfer.setData函数来设置参数值.它是绑定在被按住的dom元素上的事件.
  • dragover是在鼠标按住某个dom元素后拖拽过程中触发的函数.
  • drop事件会在鼠标拖拽某个dom元素到另外一个dom元素上方释放时触发.它是绑定在另一个dom元素的监听事件,它的事件对象e可以通过e.dataTransfer.getData函数得到dragstart内部设置的参数值.

Tree组件的所有节点全部绑定dragstartdrop事件,一旦移动某个节点1到另外一个节点2上时.通过dragstart函数可以捕捉到节点1的所有数据信息,并通过e.dataTransfer.setData存储起来.

节点2监听到节点1在其上方释放,drop事件就会触发.在drop事件内部,它本身就可以得到当前节点(也就是节点2)的数据信息,另外还可以通过e.dataTransfer.getData获取到节点1的数据信息.

如果同时得到了节点1和节点2的数据信息时,那相当于清楚知道了在根数据源tree_data上需要将某个数据对象移到另一个数据对象的下面.最终移动dom节点的问题就转化成了操作tree_data的问题.

绑定拖拽事件

首先在模板上给每个dom节点设置draggable="true"属性,让所有节点都支持拖拽.同时绑定三个事件函数dragstart,dropdragover.

Tree.vue

<template>
...
<div
      class="box"
      @click="toggle()"
      @dragstart="startDrag"
      @drop="dragEnd"
      @dragover="dragOver"
      draggable="true"
>
...
</template>

startDrag事件中存储数组索引this.layer,由于e.dataTransfer.setData不支持存储引用型数据,因此要使用JSON.stringify转化一下.

dragOver事件里面必须要调用一下e.preventDefault(),否则dragEnd函数不会触发.

dragEnd函数得到了两个节点的数据,开始调用祖先的方法dragData更新tree_data,这里的祖先方法dragData是通过provide,inject机制传递给后代使用的,可在最后面全部代码中看到.

 methods: {
    dragOver(e) {
      e.preventDefault();
    },
    startDrag(e) {
      e.dataTransfer.setData("data", JSON.stringify(this.layer));
    },
    dragEnd(e) {
      e.preventDefault();
      const old_layer = JSON.parse(e.dataTransfer.getData("data"));
      this.dragData(old_layer, this.layer, this);
    }
 }   

更新Tree_data

dragData执行的过程就是将被拖拽节点的数据对象添加到新节点的数据对象的children数组中.

通过this.getTarget找到了两个节点的数据对象,运行new_obj.children.unshift(old_obj);,将旧数据对象添加到新对象的children数组下面.另外还要将原来位置下的旧数据对象删除,否则旧数据对象就会存在两份.

想删除原来位置下的旧数据对象就必须找到它的父级数据对象和它排在父级的子代数组下的索引值,找到后就可以将使用splice将原来位置的旧数据对象删掉.最终将修改过的数据赋值给tree_data.

index.vue文件

 methods: {
    dragData(old_layer, new_layer, elem) {
     
      let result = [...this.data_source];

      const old_obj = this.getTarget(old_layer, result);

      const new_obj = this.getTarget(new_layer, result);
      
      //找到被拖拽数据对象的父级数据对象
      const old_obj_parent = this.getTarget(
        old_layer.slice(0, old_layer.length - 1),
        result
      );

      const index = old_layer[old_layer.length - 1]; //获取倒数第二个索引

      if (!new_obj.children) {
        new_obj.children = [];
      }

      if (Array.isArray(old_obj_parent)) {
        old_obj_parent.splice(index, 1);
      } else {
        old_obj_parent.children.splice(index, 1); //删掉原来位置的被拖拽数据
      }

      new_obj.children.unshift(old_obj); //将被拖拽的数据加到目标处

      this.tree_data = result;

    }
    ...
 }   

完整代码

index.vue

<template>
  <div class="tree">
    <template v-for="(item, index) in data_source">
      <Tree :item="item" :key="index" :layer="[index]" class="first-layer" />
    </template>
  </div>
</template>

<script>
import Tree from "./Tree";
export default {
  props: {
    data: Object | Array,
    label: {
      type: String,
      default: "label",
    },
    children: {
      type: String,
      default: "children",
    },
    lazy: {
      type: Boolean,
      default: false,
    },
    load: {
      type: Function,
      default: () => {},
    },
  },
  provide() {
    return {
      label: this.label,
      children: this.children,
      lazy: this.lazy,
      load: this.load,
      updateData: this.updateData,
      dragData: this.dragData,
    };
  },
  data() {
    return {
      tree_data: this.data,
    };
  },
  computed: {
    data_source() {
      if (Array.isArray(this.tree_data)) {
        return this.tree_data;
      } else {
        return [this.tree_data];
      }
    },
  },
  components: {
    Tree,
  },
  methods: {
    dragData(old_layer, new_layer, elem) {
      //数据拖拽
      const flag = old_layer.every((item, index) => {
        return item === new_layer[index];
      });
      if (flag) {
        //不能将元素拖拽给自己的子元素
        return false;
      }

      let result = [...this.data_source];

      const old_obj = this.getTarget(old_layer, result);

      const new_obj = this.getTarget(new_layer, result);

      const old_obj_parent = this.getTarget(
        old_layer.slice(0, old_layer.length - 1),
        result
      );

      const index = old_layer[old_layer.length - 1]; //获取倒数第二个索引

      if (!new_obj[this.children]) {
        new_obj[this.children] = [];
      }

      if (Array.isArray(old_obj_parent)) {
        old_obj_parent.splice(index, 1);
      } else {
        old_obj_parent[this.children].splice(index, 1); //原来位置的被拖拽数据删掉x
      }

      new_obj[this.children].unshift(old_obj); //将被拖拽的数据加到目标处

      this.tree_data = Array.isArray(this.tree_data) ? result : result[0];

      this.$nextTick(() => {
        !elem.is_open && elem.toggle(); //如果是关闭状态拖拽过去打开
      });
    },
    getTarget(layer, result) {
      if (layer.length == 0) {
        return result;
      }
      let data_obj;
      Array.from(Array(layer.length)).reduce((cur, prev, index) => {
        if (!cur) return null;
        if (index == 0) {
          data_obj = cur[layer[index]];
        } else {
          data_obj = cur[this.children][layer[index]];
        }
        return data_obj;
      }, result);
      return data_obj;
    },
    updateData(data) {
      const { data: list, layer } = data;

      let result = [...this.data_source];

      const tmp = this.getTarget(layer, result);

      tmp[this.children] = list;

      this.tree_data = Array.isArray(this.tree_data) ? result : result[0];
    },
  },
};
</script>

<style lang="scss" scoped>
.first-layer {
  margin-bottom: 20px;
}
</style>

Tree.vue

<template>
  <div class="tree">
    <div
      class="box"
      @click="toggle()"
      @dragstart="startDrag"
      @drop="dragEnd"
      @dragover="dragOver"
      draggable="true"
    >
      <div :class="['lt', getClassName()]"></div>
      <div class="label lt">{{ item[label] }}</div>
      <div class="lt load" v-if="loading_status">loading...</div>
    </div>
    <div class="drop-list" v-show="show_next">
      <template v-for="(child, index) in item[children]">
        <Tree :item="child" :key="index" :layer="[...layer, index]" />
      </template>
    </div>
  </div>
</template>

<script>
export default {
  name: "Tree",
  props: {
    item: Object,
    layer: Array,
  },
  inject: ["label", "children", "lazy", "load", "updateData", "dragData"],
  data() {
    return {
      is_open: false, //是否打开下一级
      loading: false, //是否加载中
      loaded: false, //是否加载完毕
    };
  },
  computed: {
    show_next() {
      //是否显示下一级
      if (
        this.is_open === true &&
        (this.loaded === true || this.lazy === false)
      ) {
        return true;
      } else {
        return false;
      }
    },
    loading_status() {
      //控制loading...显示图标
      if (!this.lazy) {
        return false;
      } else {
        if (this.loading === true) {
          return true;
        } else {
          return false;
        }
      }
    },
  },
  methods: {
    getClassName() {
      if (this.item[this.children] && this.item[this.children].length > 0) {
        return this.is_open ? "down" : "right";
      } else {
        return "gap";
      }
    },
    dragOver(e) {
      e.preventDefault();
    },
    startDrag(e) {
      e.dataTransfer.setData("data", JSON.stringify(this.layer));
    },
    dragEnd(e) {
      e.preventDefault();
      const old_layer = JSON.parse(e.dataTransfer.getData("data"));
      this.dragData(old_layer, this.layer, this);
    },
    toggle() {
      if (this.lazy) {
        if (this.loaded) {
          //已经加载完毕
          this.is_open = !this.is_open;
          return false;
        }
        if (this.loading) {
          //正在加载中
          return false;
        }
        this.loading = true;
        const resolve = (data) => {
          this.is_open = !this.is_open;
          this.loading = false;
          this.loaded = true;
          this.updateData({ data, layer: this.layer });
        };
        const data = { ...this.item, layer: this.layer.length };
        this.load(data, resolve);
      } else {
        this.is_open = !this.is_open;
      }
    },
  },
};
</script>
<style lang="scss" scoped>
.lt {
  float: left;
}
.load {
  font-size: 12px;
  margin-left: 5px;
  margin-top: 4px;
}
.gap {
  margin-left: 10px;
  width: 1px;
  height: 1px;
}
.box::before {
  width: 0;
  height: 0;
  content: "";
  display: block;
  clear: both;
  cursor: pointer;
}
@mixin triangle() {
  border-color: #57af1a #fff #fff #fff;
  border-style: solid;
  border-width: 4px 4px 0 4px;
  height: 0;
  width: 0;
}
.label {
  font-size: 14px;
  margin-left: 5px;
}
.down {
  @include triangle();
  margin-top: 8px;
}
.right {
  @include triangle();
  transform: rotate(-90deg);
  margin-top: 8px;
}
.drop-list {
  margin-left: 10px;
}
</style>

外部调用Tree组件(测试文件)

<template>
  <Tree
    :data="data"
    label="title"
    children="childrens"
    :load="loadNode"
    :lazy="true"
  />
</template>

<script>
import Tree from "../../components/Tree/index.vue";
export default {
  data() {
    return {
      data: [
        {
          title: "一级",
          childrens: [
            {
              title: "二级1",
              childrens: [
                {
                  title: "三级1",
                },
              ],
            },
            {
              title: "二级2",
              childrens: [
                {
                  title: "三级2",
                },
              ],
            },
          ],
        },
        {
          title: "一级2",
          childrens: [
            {
              title: "二级2",
            },
          ],
        },
      ],
    };
  },
  components: {
    Tree,
  },
  methods: {
    loadNode(node, resolve) {
      const { layer, childrens } = node;
      if (childrens && childrens.length > 0) {
        resolve(childrens);
      } else {
        setTimeout(() => {
          resolve([
            {
              title: `第${layer}层`,
            },
          ]);
        }, 1500);
      }
    },
  },
};
</script>
<style>
</style>
Logo

前往低代码交流专区

更多推荐