两棵el-tree的节点跨树拖拽实现

在使用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-startnode-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组件实例),而不是节点对象nodetreeNode.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,重新插入左侧树上的空节点后面,最后清除空节点即可。

  • 24
    点赞
  • 80
    收藏
    觉得还不错? 一键收藏
  • 38
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 38
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值