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:从当前列表移出到另一侧的记录
    利用这些信息,可直接生成提交参数,而无需对比全量数据,减少网络传输。

代码

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-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 的穿梭逻辑保持统一风格,方便团队复用。

https://vxeui.com

posted @ 2026-08-21 10:11  你个老六  阅读(7)  评论(0)    收藏  举报