在使用element-ui框架时,我们经常会用到el-tree
组件。该组件支持在树上任意拖拽节点,但默认不支持将节点拖拽到树的外部,如一个外部div内,或另一棵el-tree
上。
关于如何将树上的节点拖拽到一个外部容器内(如一个设置了draggable="draggable"的div),我在 HTML5之原生拖拽 这篇博客的最后面已经给出了实现方案,感兴趣的可以参考,这里不再赘述。
本文我们要探讨的是,如何将一个el-tree
上的节点拖拽到另一个el-tree
上。这需要依赖一些对el-tree源码的理解,我们先给出参考代码:
<template>
<div class="tree-drag">
<el-tree
:data="treeData1"
ref="tree1"
class="tree"
node-key="id"
draggable
default-expand-all
:allow-drop="returnFalse"
@node-drag-start="handleDragstart"
@node-drag-end="handleDragend"
></el-tree>
<el-tree
:data="treeData2"
ref="tree2"
class="tree"
node-key="id"
draggable
default-expand-all
:allow-drop="returnTrue"
></el-tree>
</div>
</template>
<script>
export default {
name: "HelloWorld",
data() {
return {
treeData1: [{
id: 1,
label: "一级 1",
children: [{
id: 4,
label: "二级 1-1",
children: [{id: 9,label: "三级 1-1-1"}]
}],
}],
treeData2: [{
id: 2,
label: "一级 1",
children: [{
id: 5,
label: "二级 1-1",
children: [{id: 8,label: "三级 1-1-1"}],
}],
}],
};
},
methods: {
handleDragstart (node, event) {
this.$refs.tree2.$emit('tree-node-drag-start', event, {node: node});
},
handleDragend (draggingNode, endNode, position, event) {
// 插入一个空节点用于占位
let emptyData = {id: (+new Date), children: []};
this.$refs.tree1.insertBefore(emptyData, draggingNode);
this.$refs.tree2.$emit('tree-node-drag-end', event);
this.$nextTick(() => {
// 如果是移动到了当前树上,需要清掉空节点
if (this.$refs.tree1.getNode(draggingNode.data)) {
this.$refs.tree1.remove(emptyData);
} else {
// 如果移动到了别的树上,需要恢复该节点,并清掉空节点
let data = JSON.parse(JSON.stringify(draggingNode.data));
this.$refs.tree1.insertAfter(data, this.$refs.tree1.getNode(emptyData));
this.$refs.tree1.remove(emptyData);
}
})
},
returnTrue () {
return true;
},
returnFalse () {
return false;
}
}
};
</script>
<style scoped>
.tree {
display: inline-block;
vertical-align: top;
width: 30%;
height: 400px;
border: 1px solid #999;
}
</style>
想要体验拖拽效果的可以新建一个空项目,使用如下命令安装element-ui:
npm install --save element-ui
然后在main.js中引入:
import ElementUI from 'element-ui'
import 'element-ui/lib/theme-chalk/index.css'
再把上述代码粘贴到一个vue文件中渲染到页面上即可,该例子可实现左侧树向右侧树拖拽节点。
本示例代码实现的是拖拽复制,如果想要直接移动节点则更简单,handleDragend
函数中只留下第三行的$emit
语句即可。
我们来分析一下如此简单的代码是如何实现把一棵树上的节点复制(或移动)到另一棵树上的。
首先我们来看两棵树的属性配置中与拖拽相关的部分:
<el-tree
ref="tree1"
draggable
:allow-drop="returnFalse"
@node-drag-start="handleDragstart"
@node-drag-end="handleDragend"
></el-tree>
<el-tree
ref="tree2"
draggable
:allow-drop="returnTrue"
></el-tree>
我们首先为两棵树定义了ref属性,这是为了方便我们引用两棵树。然后我们启用了两棵树的draggable属性,使它们都可拖拽。由于我们只需要从第一棵树向第二棵树拖拽,所以第一棵树的allow-drop
只返回false,而第二棵树的allow-drop
只返回true。
上述配置都是可拖拽树的常规配置。接下来真正实现了跨树拖拽的,其实就只是我们为第一棵树绑定的node-drag-start
和node-drag-end
这两个回调函数。现在让我们把目光聚集到这两个回调函数,结合el-tree源码,来看它们如何实现了跨树拖拽。
开始拖拽阶段
先来看node-drag-start
事件对应的回调函数handleDragstart
:
handleDragstart (node, event) {
this.$refs.tree2.$emit('tree-node-drag-start', event, {node: node});
}
我们知道,这个回调函数接收两个参数,分别是被拖拽节点和拖拽事件对象event。不同于常规处理,我们在这个函数里只做了一件事,就是手动触发了第二棵树的tree-node-drag-start
事件,并把事件对象和被拖拽节点都传递了过去。
这是个什么事件呢?让我们打开element-ui源码中用于定义树节点的element-ui\packages\tree\src\tree-node.vue
组件,由它创建的每个实例就是树上的一个节点,它内部有这样的逻辑:
<template>
<div
...
@dragstart.stop="handleDragStart"
>
...
</div>
</template>
<script>
...
methods: {
handleDragStart(event) {
if (!this.tree.draggable) return;
this.tree.$emit('tree-node-drag-start', event, this);
},
}
</script>
我们看到,当一个树节点上发生了原生的dragstart
事件时,这个节点就会向它的父组件(也就是它所在的el-tree)触发tree-node-drag-start
事件,并携带事件对象和当前节点实例,以此通知其父组件当前节点被拖拽了。
而我们的代码就是在手动触发这一过程,只不过目标树是右侧树:
this.$refs.tree2.$emit('tree-node-drag-start', event, {node: node});
经过这个移花接木的操作,右侧的树会误以为是自身的节点触发了tree-node-drag-start
事件,它会将被拖拽节点保存在组件内。我们来看右侧树在监听到tree-node-drag-start
事件时做了什么操作,我们只保留最关键的一句:
this.$on('tree-node-drag-start', (event, treeNode) => {
...
dragState.draggingNode = treeNode;
...
});
它会把tree-node-drag-start
传过来的节点实例保存在内部的dragState
对象内。注意,这里传过来的是节点实例treeNode
(也就是一个Vue组件实例),而不是节点对象node
,treeNode.node
就是该实例对应的node
节点对象。
由于拖拽只用到了节点实例的node属性,所以我们在手动触发这个事件时可以直接用对象{node: node}
来伪装节点实例。el-tree
并不关心这个节点是不是属于当前的树,它只关心传过来的第二个参数是不是有node
属性。通过这个巧妙的操作,我们就把被拖拽的节点保存在了右侧树内部的dragSatet
对象内,现在dragState
的结构如下:
// 右侧的el-tree
dragState: {
showDropIndicator: false,
draggingNode: { node },
dropNode: null,
allowDrop: true
}
鼠标滑过阶段
当鼠标拖拽着左侧树上的节点从右侧树上的节点划过(也就是触发dragover
事件)时,右侧树会误以为是在拖拽自己的节点,因此会在tree-node-drag-over
事件中自动执行位置计算,所以这一阶段无需我们干预。
鼠标释放阶段
尽管此时右侧树已经误以为被拖拽的是自身节点,但被拖拽的节点此时仍然是左侧树的子组件,所以当鼠标释放时,它只能向左侧树(即它的父组件)触发tree-node-drag-end
事件。由于左侧树不允许释放,所以此时节点并没有发生移动。
为了让右侧树收到鼠标释放的通知,我们开始进行第二次移花接木,即把左侧树上发生的tree-node-drag-end
事件以同样的方式触发给右侧树,这是通过以下代码实现的:
handleDragend (draggingNode, endNode, position, event) {
...
this.$refs.tree2.$emit('tree-node-drag-end', event);
...
},
这个事件更简单,在触发tree-node-drag-end
事件时只传了一个事件对象,其他的参数则是在鼠标滑过阶段就已经由右侧树自动计算出来了。
现在当释放鼠标时,右侧树又误以为是自身节点发生了tree-node-drag-end
事件,于是它会把之前tree-node-drag-start
事件中保存的被拖拽节点移动到目标位置,左侧树上的节点就这样被移动到了右侧树上!
如果只是做节点移动,上面的代码就足够了。但更常见的场景其实是拖拽复制,也就是要保留左侧树上的原节点。不过真正要保留原节点很难实现,所以我们选择在移动后恢复左侧树上的节点:
handleDragend (draggingNode, endNode, position, event) {
// 插入一个空节点用于占位
let emptyData = {id: (+new Date), children: []};
this.$refs.tree1.insertBefore(emptyData, draggingNode);
this.$refs.tree2.$emit('tree-node-drag-end', event);
this.$nextTick(() => {
// 如果是移动到了当前树上,需要清掉空节点
if (this.$refs.tree1.getNode(draggingNode.data)) {
this.$refs.tree1.remove(emptyData);
} else {
// 如果移动到了别的树上,需要恢复该节点,并清掉空节点
let data = JSON.parse(JSON.stringify(draggingNode.data));
this.$refs.tree1.insertAfter(data, this.$refs.tree1.getNode(emptyData));
this.$refs.tree1.remove(emptyData);
}
})
}
首先,我们在被拖拽节点的前面插入一个空节点用于占位。当节点移动完成后,我们在$nextTick()
中检查被移动的节点draggingNode
是否还在当前树上。如果仍然在,那么直接清除上述空节点即可;如果不在了,我们就把draggingNode.data
深拷贝一份,重新将其插入到上述空节点的后面(这其实就相当于将被拖拽节点拷贝了一次),这样就恢复了被拖拽的节点,随后仍然清除占位用的空节点。
总结
回顾一下整个过程。
首先,我们在左侧树发生node-drag-start
事件时,手动触发右侧树的tree-node-drag-start
事件,并把事件对象和被拖拽节点(在js中实际上只是地址)传递过去,目的是让右侧树误以为是自身节点发生了拖拽。这样它就会把被拖拽节点保存在组件内部,并在鼠标滑过时自动计算释放位置。
然后当我们释放鼠标时,虽然右侧树已经把拖拽相关的参数计算完毕,但是由于被拖拽节点是左侧树的子组件,所以它只会触发左侧树的tree-node-drag-end
事件,而右侧树并不会得到通知,所以我们再手动触发右侧树的tree-node-drag-end
,让右侧树把被拖拽节点移动过来。
要实现拖拽复制,需要在节点移动前,在它前面插入一个空节点占位。当节点移动之后,深拷贝该节点的data,重新插入左侧树上的空节点后面,最后清除空节点即可。