vue实现 vxe-list 列表穿梭框与跨列表拖拽
在后台管理系统中,平铺数据的双向选择(如用户标签分配、商品分类、权限组管理等)是高频操作。vxe-list 作为轻量级列表组件,天然支持复选框、拖拽排序及跨列表拖拽,非常适合构建交互流畅、数据同步简洁的穿梭框方案。
本文将基于 vxe-list 实现一个支持勾选穿梭 + 直接拖拽的列表穿梭组件,并讲解其核心配置、数据追踪与性能考量。
功能预览
- 左右两个独立列表,左侧为待选数据源,右侧为已选集合
- 支持复选框勾选后通过中间按钮(→ / ←)批量迁移
- 支持跨列表直接拖拽(鼠标按住节点拖到另一侧),无需按钮操作
- 内置 getRecordset() 可精准追踪新增/删除记录,便于后端提交
核心配置解析
vxe-list 配置简洁,关键属性如下:
| 配置项 | 值 | 说明 |
|---|---|---|
| showCheckbox | true | 显示行复选框 |
| rowConfig.keyField | 'id' | 行唯一标识字段(跨列表拖拽时必须保证两表主键不重复) |
| dragConfig.isCrossListDrag | true | 允许跨列表拖拽(核心开关) |
| height | '100%' | 配合父容器高度自适应 |
关键约束:跨列表拖拽依赖 keyField 判断数据身份,若两侧数据存在相同主键,则拖拽时会认为引用同一对象,导致数据错乱。建议从不同数据源加载时,对主键添加前缀(如 left_10001 / right_10001)。
穿梭逻辑
- 通过 ref 获取两个列表实例,核心方法包括:
- getCheckboxRecords():获取当前勾选的行数据(数组)
- clearCheckboxRow():清空所有勾选状态
- remove(records):从列表中移除指定行
- insertAt(records, index):在指定位置插入行(-1 表示末尾)
左→右(addEvent):
const selectRecords = $list1.getCheckboxRecords()
if (selectRecords.length) {
$list1.clearCheckboxRow()
$list1.remove(selectRecords)
$list2.insertAt(selectRecords, -1)
} else {
// 提示勾选左侧数据
}
右→左(delEvent) 同理反向操作。
每次操作完成后,两边列表会自动重新渲染,无需手动刷新。
跨列表拖拽
仅需开启 dragConfig.isCrossListDrag: true,即可支持用户直接拖拽任意行到另一个列表。
- 拖拽过程中,组件会校验主键冲突并自动执行 remove + insertAt 逻辑
- 开发者无需绑定 drag-drop 事件即可完成数据迁移
- 若需监听拖拽完成,可添加 @drag-drop 事件,便于埋点或额外处理
数据变更追踪
穿梭结束后,常需知道哪些数据被移动,以便提交后台。
vxe-list 的 getRecordset() 返回当前列表相对于初始数据的差异:
const { insertRecords, removeRecords } = $list1.getRecordset()
const listData = $list1.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-list-transfer">
<div class="my-list-transfer-left">
<vxe-list ref="listRef1" v-bind="listOptions1"></vxe-list>
</div>
<div class="my-list-transfer-handle">
<vxe-button-group vertical>
<vxe-button status="primary" icon="vxe-icon-arrow-double-right" style="width: 100%" @click="addEvent"></vxe-button>
<vxe-button status="error" icon="vxe-icon-arrow-double-left" style="width: 100%" @click="delEvent"></vxe-button>
</vxe-button-group>
</div>
<div class="my-list-transfer-right">
<vxe-list ref="listRef2" v-bind="listOptions2"></vxe-list>
</div>
</div>
</div>
</template>
<script setup>
import { ref, reactive } from 'vue'
import { VxeUI } from 'vxe-pc-ui'
const listRef1 = ref()
const listRef2 = ref()
const listOptions1 = reactive({
height: '100%',
showCheckbox: true,
rowConfig: {
keyField: 'id'
},
dragConfig: {
isCrossListDrag: true
},
data: [
{ id: '10001', label: '数据10001' },
{ id: '10002', label: '数据10002' },
{ id: '10003', label: '数据10003' },
{ id: '10004', label: '数据10004' },
{ id: '10005', label: '数据10005' },
{ id: '10006', label: '数据10006' },
{ id: '10007', label: '数据10007' },
{ id: '10008', label: '数据10008' },
{ id: '10009', label: '数据10009' },
{ id: '10010', label: '数据10010' },
{ id: '10011', label: '数据10011' },
{ id: '10012', label: '数据10012' },
{ id: '10013', label: '数据10013' },
{ id: '10014', label: '数据10014' },
{ id: '10015', label: '数据10015' },
{ id: '10016', label: '数据10016' },
{ id: '10017', label: '数据10017' },
{ id: '10018', label: '数据10018' },
{ id: '10019', label: '数据10019' },
{ id: '10020', label: '数据10020' },
{ id: '10021', label: '数据10021' },
{ id: '10022', label: '数据10022' },
{ id: '10023', label: '数据10023' },
{ id: '10024', label: '数据10024' },
{ id: '10025', label: '数据10025' },
{ id: '10026', label: '数据10026' },
{ id: '10027', label: '数据10027' },
{ id: '10028', label: '数据10028' },
{ id: '10029', label: '数据10029' },
{ id: '10030', label: '数据10030' }
]
})
const listOptions2 = reactive({
height: '100%',
showCheckbox: true,
rowConfig: {
keyField: 'id'
},
dragConfig: {
isCrossListDrag: true
},
data: []
})
const addEvent = () => {
const $list1 = listRef1.value
const $list2 = listRef2.value
if ($list1 && $list2) {
const selectRecords = $list1.getCheckboxRecords()
if (selectRecords.length) {
$list1.clearCheckboxRow()
$list1.remove(selectRecords)
$list2.insertAt(selectRecords, -1)
} else {
VxeUI.modal.message({
content: '请勾选左侧数据',
status: 'warning'
})
}
}
}
const delEvent = () => {
const $list1 = listRef1.value
const $list2 = listRef2.value
if ($list1 && $list2) {
const selectRecords = $list2.getCheckboxRecords()
if (selectRecords.length) {
$list2.clearCheckboxRow()
$list2.remove(selectRecords)
$list1.insertAt(selectRecords, -1)
} else {
VxeUI.modal.message({
content: '请勾选右侧数据',
status: 'warning'
})
}
}
}
const resultEvent1 = () => {
const $list1 = listRef1.value
if ($list1) {
const { insertRecords, removeRecords } = $list1.getRecordset()
const listData = $list1.getFullData()
VxeUI.modal.message({
content: `新增:${insertRecords.length} 删除:${removeRecords.length} 现有:${listData.length}`,
status: 'success'
})
}
}
const resultEvent2 = () => {
const $list2 = listRef2.value
if ($list2) {
const { insertRecords, removeRecords } = $list2.getRecordset()
const listData = $list2.getFullData()
VxeUI.modal.message({
content: `新增:${insertRecords.length} 删除:${removeRecords.length} 现有:${listData.length}`,
status: 'success'
})
}
}
</script>
<style lang="scss" scoped>
.my-list-transfer {
display: flex;
height: 400px;
overflow: hidden;
}
.my-list-transfer-handle {
display: flex;
flex-direction: column;
justify-content: center;
flex-shrink: 0;
padding: 0 5px;
width: 50px;
}
.my-list-transfer-left,
.my-list-transfer-right {
width: 50%;
padding: 8px;
border-radius: 4px;
border: 1px solid #999999;
overflow: hidden;
}
</style>
注意点
- 主键唯一性(最重要)
- 若左右列表数据来自不同接口,请确保 keyField 值全局唯一。常见做法:
- 服务端返回时已有唯一 ID 则直接使用
- 若 ID 可能重复,在数据加载时进行改造:item.id = 'left_' + item.id
- 性能优化
- 当数据量较大(>500 条)时,启用虚拟滚动:scrollY:
- 避免在 data 中使用响应式包装过深,reactive 已足够
- 拖拽体验增强
- 可调整 dragConfig.dragDistance(默认 5px)降低误触
- 通过 drag-config.dragSort 控制是否允许同列表内排序(本方案未开启)
- 数据提交策略
- 穿梭完成后,推荐使用 getRecordset() 得到变更集合,而非全量数据,这样提交时只需传递 insertRecords 和 removeRecords,减少负载。
vxe-list 通过极简配置(showCheckbox + isCrossListDrag)即可实现功能完备的列表穿梭框,大幅降低开发成本。结合 getRecordset 数据追踪,开发者无需关心中间状态,专注业务逻辑。
该方案适用于标签管理、权限分配、产品选配等常见场景,且与 vxe-table / vxe-tree 的穿梭逻辑保持统一风格,方便团队复用。

浙公网安备 33010602011771号