vue实现 vxe-tree 树形穿梭框与跨树拖拽

后台管理系统中,树形结构的数据选择与分配是常见需求——例如角色权限分配、组织架构调整、分类迁移等。传统的穿梭框(Transfer)仅支持平铺列表,无法保留数据的层级关系。
本文将利用 vxe-tree(Vxe 组件库中的树形组件)快速构建一个支持勾选穿梭 + 跨树拖拽的双树穿梭组件,并讲解其核心配置与交互细节。

功能预览

  • 左右两棵独立树,支持点击复选框选中节点
  • 通过中间按钮(→ / ←)将选中节点整体移动到另一侧
  • 支持跨树直接拖拽节点(从左侧拖到右侧,或反之),无需点击按钮
  • 支持获取两棵树各自的新增/删除/现有数据统计,便于提交保存

核心配置解析

vxe-tree 提供了丰富的配置项,本方案中关键配置如下:

配置项 说明
transform true 自动将扁平数据转换为树形结构(依据 parentField)
titleField 'title' 节点显示文本字段
keyField 'id' 节点唯一标识字段(跨树拖拽时必须确保两棵树的主键不重复)
parentField 'parentId' 父节点关联字段
showCheckbox true 显示复选框
drag true 开启拖拽
dragConfig.isCrossDrag true 允许同树内拖拽调整层级
dragConfig.isCrossTreeDrag true 允许跨树拖拽(关键)

跨树拖拽核心前提:两棵树的 keyField 指定字段值必须全局唯一,否则拖拽后数据错乱。若数据来自不同源,需提前加工主键(如加前缀)。

穿梭逻辑

通过 ref 获取两个树实例,调用其内置方法:

  • getCheckboxRecords():获取所有勾选节点(含子节点)
  • clearCheckboxNode():清空勾选状态
  • remove(records):从当前树移除指定节点(含其子节点)
  • insertAt(records, index):在指定位置插入节点(-1 表示末尾)

跨树拖拽

开启 isCrossTreeDrag: true 后,用户可直接用鼠标将任意节点从一棵树拖拽到另一棵树。
拖拽过程中,组件会校验主键是否重复,并自动完成 remove + insertAt 操作。
开发者无需编写额外事件处理,仅需保证两棵树的 keyField 一致且值唯一。

数据变更追踪

业务场景中,穿梭后需要知道哪些节点是新增的、哪些被删除了,以便提交后台。
vxe-tree 的 getRecordset() 方法返回当前树相对于初始数据的变更集合:

const { insertRecords, removeRecords } = $tree1.getRecordset()
const treeData = $tree1.getFullData()   // 当前所有节点(树形结构)
  • insertRecords:本次操作中新增的节点(从另一侧移入)
  • removeRecords:本次操作中删除的节点(移出到另一侧)

用这些数据,可以准确生成操作日志或提交参数。

代码

Video_2026-08-20_135327-ezgif.com-video-to-gif-converter

<template>
  <div>
    <vxe-button status="success" @click="resultEvent1">获取数据1</vxe-button>
    <vxe-button status="success" @click="resultEvent2">获取数据2</vxe-button>
    <div class="my-tree-transfer">
      <div class="my-tree-transfer-left">
        <vxe-tree ref="treeRef1" v-bind="treeOptions1"></vxe-tree>
      </div>
      <div class="my-tree-transfer-handle">
        <vxe-button class="my-tree-transfer-btn" status="primary" icon="vxe-icon-arrow-double-right" style="width: 100%" @click="addEvent"></vxe-button>
        <vxe-button class="my-tree-transfer-btn" status="error" icon="vxe-icon-arrow-double-left" style="width: 100%" @click="delEvent"></vxe-button>
      </div>
      <div class="my-tree-transfer-right">
        <vxe-tree ref="treeRef2" v-bind="treeOptions2"></vxe-tree>
      </div>
    </div>
  </div>
</template>

<script setup>
import { ref, reactive } from 'vue'
import { VxeUI } from 'vxe-pc-ui'

const treeRef1 = ref()
const treeRef2 = ref()

const treeOptions1 = reactive({
  height: '100%',
  transform: true,
  titleField: 'title',
  keyField: 'id',
  parentField: 'parentId',
  showCheckbox: true,
  drag: true,
  dragConfig: {
    isCrossDrag: true,
    isCrossTreeDrag: true
  },
  data: [
    { title: '节点2', id: '2', parentId: null },
    { title: '节点3', id: '3', parentId: null },
    { title: '节点3-1', id: '31', parentId: '3' },
    { title: '节点3-2', id: '32', parentId: '3' },
    { title: '节点3-2-1', id: '321', parentId: '32' },
    { title: '节点3-2-2', id: '322', parentId: '32' },
    { title: '节点3-3', id: '33', parentId: '3' },
    { title: '节点3-3-1', id: '331', parentId: '33' },
    { title: '节点3-3-2', id: '332', parentId: '33' },
    { title: '节点3-3-3', id: '333', parentId: '33' },
    { title: '节点3-4', id: '34', parentId: '3' },
    { title: '节点4', id: '4', parentId: null },
    { title: '节点4-1', id: '41', parentId: '4' },
    { title: '节点4-1-1', id: '411', parentId: '42' },
    { title: '节点4-1-2', id: '412', parentId: '42' },
    { title: '节点4-2', id: '42', parentId: '4' },
    { title: '节点4-3', id: '43', parentId: '4' },
    { title: '节点4-3-1', id: '431', parentId: '43' },
    { title: '节点4-3-2', id: '432', parentId: '43' },
    { title: '节点5', id: '5', parentId: null }
  ]
})

const treeOptions2 = reactive({
  height: '100%',
  transform: true,
  titleField: 'title',
  keyField: 'id',
  parentField: 'parentId',
  showCheckbox: true,
  drag: true,
  dragConfig: {
    isCrossDrag: true,
    isCrossTreeDrag: true
  },
  data: []
})

const addEvent = () => {
  const $tree1 = treeRef1.value
  const $tree2 = treeRef2.value
  if ($tree1 && $tree2) {
    const selectRecords = $tree1.getCheckboxRecords()
    if (selectRecords.length) {
      $tree1.clearCheckboxNode()
      $tree1.remove(selectRecords)
      $tree2.insertAt(selectRecords, -1)
    } else {
      VxeUI.modal.message({
        content: '请勾选左侧数据',
        status: 'warning'
      })
    }
  }
}

const delEvent = () => {
  const $tree1 = treeRef1.value
  const $tree2 = treeRef2.value
  if ($tree1 && $tree2) {
    const selectRecords = $tree2.getCheckboxRecords()
    if (selectRecords.length) {
      $tree2.clearCheckboxNode()
      $tree2.remove(selectRecords)
      $tree1.insertAt(selectRecords, -1)
    } else {
      VxeUI.modal.message({
        content: '请勾选右侧数据',
        status: 'warning'
      })
    }
  }
}

const resultEvent1 = () => {
  const $tree1 = treeRef1.value
  if ($tree1) {
    const { insertRecords, removeRecords } = $tree1.getRecordset()
    const treeData = $tree1.getFullData()
    VxeUI.modal.message({
      content: `新增:${insertRecords.length} 删除:${removeRecords.length} 现有:${treeData.length}`,
      status: 'success'
    })
  }
}

const resultEvent2 = () => {
  const $tree2 = treeRef2.value
  if ($tree2) {
    const { insertRecords, removeRecords } = $tree2.getRecordset()
    const treeData = $tree2.getFullData()
    VxeUI.modal.message({
      content: `新增:${insertRecords.length} 删除:${removeRecords.length} 现有:${treeData.length}`,
      status: 'success'
    })
  }
}
</script>

<style lang="scss" scoped>
.my-tree-transfer {
  display: flex;
  height: 400px;
  overflow: hidden;
}
.my-tree-transfer-handle {
  display: flex;
  flex-direction: column;
  justify-content: center;
  flex-shrink: 0;
  padding: 0 5px;
  width: 50px;
}
.my-tree-transfer-btn {
  margin: 2px 0;
}
.my-tree-transfer-left,
.my-tree-transfer-right {
  width: 50%;
  padding: 8px;
  border-radius: 4px;
  border: 1px solid #999999;
  overflow: hidden;
}
</style>

注意事项与最佳实践

  • 主键唯一性
    • 若两侧数据来自不同数据库表,需在数据加载时对主键进行改造(如添加 left_ / right_ 前缀),避免跨树拖拽时因主键冲突导致节点被错误覆盖。
  • 拖拽灵敏度
    • vxe-tree 默认拖拽距离阈值为 5px,可自行调整 dragConfig.dragDistance 以适配手感。
  • 数据提交
    • 穿梭完成后,建议使用 getRecordset() 获取变更集合,而非直接取 data 全量,以减小传输负担。
  • 性能优化
    • 当单棵树节点数超过 1000 时,可启用虚拟滚动(scrollY.enabled: true),避免渲染卡顿。
  • 事件扩展
    • 可监听 drag-drop 事件(跨树拖拽完成时触发)来执行额外逻辑(如自动保存)。

利用 vxe-tree 的 内置拖拽系统 和 数据变更追踪,我们以极少的代码实现了一个功能完整、交互流畅的树形穿梭框。相比手写拖拽逻辑,该方案开发效率高、维护成本低,且天然支持层级数据。
若您的项目中需要树形结构的双向选择,不妨直接复用上述代码,并根据业务微调样式与交互提示。

https://vxeui.com

posted @ 2026-08-20 13:54  你个老六  阅读(6)  评论(0)    收藏  举报