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

基础结构与状态划分
设计穿梭框组件时,建议先明确内部状态。一个典型的实现包含以下几个 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,并在移动时过滤掉禁用项。对于大数据量,可以考虑使用虚拟滚动来优化渲染性能,但这已经超出了基础穿梭框的范畴。掌握好集合状态与筛选逻辑之间的关系,就足以应对大多数实际业务需求。