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:本次操作中删除的节点(移出到另一侧)
用这些数据,可以准确生成操作日志或提交参数。
代码

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

浙公网安备 33010602011771号