导读:本期聚焦于闲进程创作的《Vue 3 中如何实现穿梭框组件的数据筛选与批量转移?》,敬请观看详情。在权限分配、角色配置、菜单授权等后台页面里,经常需要把一批数据从左侧候选区搬到右侧已选区,同时还要支持按关键字快速定位数据。Vue 3 的组合式 API 让这类穿梭框组件的状态管理清晰不少,但筛选与批量转移之间有几个容易踩的坑:筛选后的全选只应作用于当前可见项,已选数据在筛选变化时不能被意外清空,批量添加还需要做去重处理。本文以一个可复用的穿梭框组件为例,拆解数据筛选、跨状态批量移动、受控 v-model 绑定的实现思路,并给出完整代码示例。同时也会讨论大数据量下如何借助 Set 提升查找效率,以及遇到跨页选择时如何设计内部状态。帮助你在项目中快速落地一个稳定可靠的穿梭框交互。

穿梭框在后台管理系统中非常常见,例如给某个角色分配菜单权限、给用户批量添加标签、从长列表中选择需要导出的字段。它的交互本质是维护两个集合:左侧候选集合和右侧已选集合。Vue 3 中使用组合式 API 可以把穿梭框封装成一个受控组件,由父组件传入完整数据源和当前已选值,组件内部只负责筛选和临时勾选状态。这样做的好处是选择结果可以很方便地持久化到服务端,单测也更容易覆盖。

Vue 3 中如何实现穿梭框组件的数据筛选与批量转移?

基础结构与状态划分

设计穿梭框组件时,建议先明确内部状态。一个典型的实现包含以下几个 ref:左侧筛选关键字 leftFilter、右侧筛选关键字 rightFilter、左侧临时勾选数组 leftChecked、右侧临时勾选数组 rightChecked。数据源 data 和已选值 modelValue 作为 props 从父组件传入。其中 modelValue 是右侧已选 key 的数组,这样可以避免把完整对象传来传去而产生额外耦合。

组件模板可以拆成左右两个面板,加上中间的操作按钮区域。左侧面板只展示尚未被选中的数据,右侧面板展示 modelValue 对应的数据。搜索框使用 v-model 绑定各自的关键字,列表项用 checkbox 绑定临时勾选数组。这样用户在左侧勾选几项,点击“移动到右侧”,这些 key 就会被合并到 modelValue 中。同时,左侧列表会自动刷新,因为这些 key 已经不在左侧候选范围内。

下面给出一个基础模板结构。为了保持代码简洁,样式类名省略了具体实现,只保留结构层级。注意模板中的标签在代码块中已经做了转义处理,方便阅读源码。

<template>
  <div class="transfer">
    <div class="transfer-panel">
      <div class="panel-header">
        <span>待选择</span>
        <input v-model="leftFilter" type="text" placeholder="搜索关键字" />
      </div>
      <ul class="panel-list">
        <li v-for="item in leftList" :key="item.key">
          <label>
            <input type="checkbox" :value="item.key" v-model="leftChecked" />
            {{ item.label }}
          </label>
        </li>
      </ul>
      <div class="panel-footer">
        <button @click="checkAllLeft">全选当前</button>
      </div>
    </div>
    <div class="transfer-actions">
      <button @click="addToRight">移动到右侧</button>
      <button @click="removeToLeft">移回左侧</button>
    </div>
    <div class="transfer-panel">
      <div class="panel-header">
        <span>已选择</span>
        <input v-model="rightFilter" type="text" placeholder="搜索关键字" />
      </div>
      <ul class="panel-list">
        <li v-for="item in rightList" :key="item.key">
          <label>
            <input type="checkbox" :value="item.key" v-model="rightChecked" />
            {{ item.label }}
          </label>
        </li>
      </ul>
      <div class="panel-footer">
        <button @click="checkAllRight">全选当前</button>
      </div>
    </div>
  </div>
</template>

这个模板里使用了 Vue 的模板插值语法,左右两侧结构基本对称。在实际项目中,可以把面板抽成一个子组件来减少重复代码,但为了讲解清晰,这里先保持完整结构。接下来需要补充对应的组合式 API 逻辑。

数据筛选与已选状态保持

筛选是穿梭框最容易出错的环节。假设左侧有 1000 条数据,用户输入关键字后只看到 20 条,此时点击全选,应该只勾选这 20 条可见数据,而不是把所有 1000 条全部勾选。这个逻辑必须放在计算属性中,基于过滤后的列表来生成全选集合。另一个要点是,筛选关键字变化时,不能影响 leftChecked 和 rightChecked 中已有的勾选状态,否则用户会丢失已经勾选的值。

左侧列表的计算属性需要同时做两件事:排除已选数据,以及按关键字过滤。可以先构建一个已选 key 的 Set,然后对原始 data 调用 filter。右侧列表则要先根据 modelValue 从 data 中找到完整对象,再进行关键字过滤。使用 Map 可以提高从 key 映射到对象的速度,尤其在数据量较大时。

下面的代码给出了关键计算属性和全选逻辑。为了保证代码块中不出现未转义的 HTML 字符,示例使用普通函数而不是箭头函数,同时把比较和过滤逻辑写得更直白一些。

import { ref, computed } from 'vue'

const props = defineProps({
  data: {
    type: Array,
    default: function () {
      return []
    }
  },
  modelValue: {
    type: Array,
    default: function () {
      return []
    }
  }
})

const emit = defineEmits(['update:modelValue', 'change'])

const leftFilter = ref('')
const rightFilter = ref('')
const leftChecked = ref([])
const rightChecked = ref([])

const selectedSet = computed(function () {
  return new Set(props.modelValue)
})

const leftList = computed(function () {
  const keyword = leftFilter.value.trim().toLowerCase()
  return props.data.filter(function (item) {
    if (selectedSet.value.has(item.key)) return false
    if (keyword === '') return true
    return String(item.label).toLowerCase().indexOf(keyword) !== -1 ||
           String(item.key).toLowerCase().indexOf(keyword) !== -1
  })
})

const rightList = computed(function () {
  const keyword = rightFilter.value.trim().toLowerCase()
  const dataMap = new Map(props.data.map(function (item) {
    return [item.key, item]
  }))
  return props.modelValue
    .map(function (key) {
      return dataMap.get(key) || { key: key, label: key }
    })
    .filter(function (item) {
      if (keyword === '') return true
      return String(item.label).toLowerCase().indexOf(keyword) !== -1 ||
             String(item.key).toLowerCase().indexOf(keyword) !== -1
    })
})

function checkAllLeft() {
  const allKeys = leftList.value.map(function (item) {
    return item.key
  })
  const hasUnchecked = allKeys.some(function (key) {
    return leftChecked.value.indexOf(key) === -1
  })
  if (hasUnchecked) {
    leftChecked.value = Array.from(new Set(leftChecked.value.concat(allKeys)))
  } else {
    leftChecked.value = leftChecked.value.filter(function (key) {
      return allKeys.indexOf(key) === -1
    })
  }
}

可以看到,计算属性 leftList 和 rightList 都不直接修改 props.modelValue,而是通过 selectedSet 和 dataMap 派生。这样父组件传入的 modelValue 始终保持单向数据流,组件内部只在用户点击移动按钮时才通过 emit 请求更新。checkAllLeft 函数用 some 判断当前过滤列表是否还有未勾选项,如果有就合并,如果已经全部勾选则取消这些项的勾选。这种切换逻辑更符合用户的“全选/取消全选”预期。

右侧的全选逻辑与左侧类似,只需要把 leftList 换成 rightList,把 leftChecked 换成 rightChecked。为了避免重复代码,实际封装时可以将这个函数抽成一个通用函数,接收列表和勾选集合作为参数。此处为了演示原理,保留两个独立的函数。

批量转移与去重处理

批量转移的核心操作有两个:把左侧勾选数据加入已选集合,以及把右侧勾选数据移出已选集合。加入时必须做去重,因为用户可能在不同筛选条件下重复勾选同一个 key,虽然在 UI 上左侧已经看不到已选项,但一旦 modelValue 被外部修改或异步更新,就有可能出现重复。使用 Set 是最简单的去重方式。移出操作则只需要过滤掉被勾选的 key。

移动完成后,需要清空对应的临时勾选数组。如果不做清理,用户下一次打开筛选或切换列表时,可能会发现某些项仍然处于勾选状态,但实际上它们已经不在当前列表中了,这种不一致会让用户感到困惑。尤其是当数据源发生异步变化时,残留的勾选状态可能引发难以追踪的 bug。

下面给出批量转移的两个函数。同样地,代码块中使用了普通函数写法,避免出现需要转义的尖括号。注意在 addToRight 中,合并后的数组会通过 emit 同步给父组件,同时也触发 change 事件,方便父组件做额外处理。

function addToRight() {
  if (leftChecked.value.length === 0) return
  const merged = Array.from(new Set(props.modelValue.concat(leftChecked.value)))
  emit('update:modelValue', merged)
  emit('change', merged)
  leftChecked.value = []
}

function removeToLeft() {
  if (rightChecked.value.length === 0) return
  const next = props.modelValue.filter(function (key) {
    return rightChecked.value.indexOf(key) === -1
  })
  emit('update:modelValue', next)
  emit('change', next)
  rightChecked.value = []
}

如果数据规模很大,比如几千甚至上万条,上面的数组 filter 和 indexOf 操作可能会带来一些性能压力。此时可以继续优化:把 leftChecked 和 rightChecked 内部存储为数组,但在判断是否包含某个 key 时先构建 Set。不过对于大多数后台管理场景,几千条数据在现代浏览器上并不会成为瓶颈,优先保证代码可读性和可维护性更重要。

另一个容易忽略的场景是分页或异步加载数据。如果 data 是分页加载的,右侧已选项可能不在当前页的 data 中,此时右侧列表应该仍然能够展示已选项的 key 或 label。上面的 rightList 中使用 dataMap.get(key) || { key, label: key } 正是为了兼容这种情况,即使数据尚未加载,也能显示 key 本身,避免空白。

在父组件中集成与扩展

完成穿梭框组件后,父组件只需传入 data 和 v-model 即可使用。data 一般是接口返回的完整候选数据,v-model 绑定的是已选 key 数组。可以在父组件中监听 change 事件,把最新的已选 key 发送给服务端保存。如果需要在打开弹窗时回显已选数据,直接把服务端返回的 key 数组赋值给 v-model 对应的 ref 即可。

下面是一个最简单的父组件集成示例。模板中假设已经引入了 Transfer 组件,并通过 <script setup> 注册。

<template>
  <div class="page">
    <h3>分配权限</h3>
    <Transfer :data="allPermissions" v-model="selectedKeys" @change="handleChange" />
  </div>
</template>

<script setup>
import { ref } from 'vue'
import Transfer from './Transfer.vue'

const allPermissions = ref([
  { key: 'user:list', label: '用户列表' },
  { key: 'user:create', label: '新增用户' },
  { key: 'role:list', label: '角色列表' },
  { key: 'menu:manage', label: '菜单管理' }
])

const selectedKeys = ref(['user:list'])

function handleChange(nextKeys) {
  console.log('当前已选权限:', nextKeys)
  // 这里可以调用接口保存
}
</script>

在这个示例中,父组件把已选权限初始化为 ['user:list'],穿梭框右侧会直接显示“用户列表”。当用户批量移动数据后,handleChange 会收到最新数组。由于 v-model 是基于 update:modelValue 事件,子组件中的 emit 调用会直接更新 selectedKeys,父组件无需额外赋值。

除了基础的批量转移,还可以在这个组件上扩展拖拽排序、单个移动、数据禁用、自定义渲染等功能。对于禁用项,可以在 data 中添加 disabled 字段,在左侧列表渲染时禁用 checkbox,并在移动时过滤掉禁用项。对于大数据量,可以考虑使用虚拟滚动来优化渲染性能,但这已经超出了基础穿梭框的范畴。掌握好集合状态与筛选逻辑之间的关系,就足以应对大多数实际业务需求。

Vue 3穿梭框组件数据筛选修改时间:2026-09-18 13:49:25

免责声明:​ 已尽一切努力确保本网站所含信息的准确性。网站内容多为原创整理与精心编撰,观点力求客观中立。本站旨在免费分享,内容仅供个人学习、研究或参考使用。若引用了第三方作品,版权归原作者所有。如内容涉及您的权益,请联系我们处理。
内容垂直聚焦
专注技术核心技术栏目,确保每篇文章深度聚焦于实用技能。从代码技巧到架构设计,为用户提供无干扰的纯技术知识沉淀,精准满足专业提升需求。
知识结构清晰
覆盖从开发到部署的全链路。AI、前端、编程、数据库、服务器、建站、系统层层递进,构建清晰学习路径,帮助用户系统化掌握开发与运维所需的核心技术。
深度技术解析
拒绝泛泛而谈,深入技术细节与实践难点。无论是数据库优化还是服务器配置,均结合真实场景与代码示例进行剖析,致力于提供可直接应用于工作的解决方案。
专业领域覆盖
精准对应开发生命周期。从前端界面到后端编程,从数据库操作到服务器运维,形成完整闭环,一站式满足全栈工程师和运维人员的技术需求。
即学即用高效
内容强调实操性,步骤清晰、代码完整。用户可根据教程直接复现和应用于自身项目,显著缩短从学习到实践的距离,快速解决开发中的具体问题。
持续更新保障
专注既定技术方向进行长期、稳定的内容输出。确保各栏目技术文章持续更新迭代,紧跟主流技术发展趋势,为用户提供经久不衰的学习价值。