Vue实现一个Tree组件
前言Tree组件在实际应用中非常广泛,例如省市县地域的展现.一般一些包含从属关系的数据都可以使用Tree组件来进行展示,下面通过一个实战的demo来深入学一下实现一个Tree组件所要了解的原理和实现细节.本文实现的功能包含以下三点.实现一个基础版可以显示嵌套数据的Tree组件点击Tree组件的某一级标签它的下一级数据支持异步加载Tree组件的节点支持拖拽最终Demo的效果图如下.基础版的Tree实
前言
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
中新增两个属性lazy
和load
.
lazy
传递给子组件指定数据渲染为异步,load
属性对应的函数loadNode
为获取数据的函数,传递给Tree组件使用.
<template>
<Tree
:data="data"
:load="loadNode"
:lazy="true"
/>
</template>
loadNode
函数我们设计时设定会返回两个参数node
对象和resolve
函数.node
对象又包含两个属性layer
和children
.
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
文件中新增两个属性loading
和loaded
,用来指示加载的状态.当loading
为true时,模板就会渲染加载中...
的字样.
一旦接受的到的lazy
为true时,通过执行外部定义的数据加载函数this.load
来获取异步数据.this.load
接受两个参数data
和resolve
函数.
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组件的所有节点全部绑定dragstart
和drop
事件,一旦移动某个节点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
,drop
和dragover
.
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>
更多推荐
所有评论(0)