在Vue3项目中,我们经常需要实现这样的交互:点击输入框时,显示一个可多选的下拉列表。这种组件在表单填写、筛选器、标签选择等场景非常常见。比如用户管理系统中选择用户权限、电商平台中筛选商品属性、内容管理系统中添加文章标签等。
传统实现方式往往需要引入第三方UI库,但通过Vue3的组合式API,我们可以用不到100行代码实现一个高性能的定制化组件。这个方案的优势在于:
javascript复制// 使用Vue3的ref管理组件状态
const dropdownVisible = ref(false)
const selectedValues = ref([])
// 点击外部关闭的逻辑
onClickOutside(dropdownRef, () => {
dropdownVisible.value = false
})
html复制<template>
<div class="multi-select">
<input
@click="toggleDropdown"
:value="displayText"
readonly
placeholder="请选择"
/>
<transition name="fade">
<div v-show="dropdownVisible" class="dropdown-menu">
<div
v-for="option in options"
:key="option.value"
class="dropdown-item"
@click="toggleSelect(option)"
>
<input
type="checkbox"
:checked="isSelected(option)"
readonly
/>
<span>{{ option.label }}</span>
</div>
</div>
</transition>
</div>
</template>
javascript复制<script setup>
import { ref, computed } from 'vue'
import { onClickOutside } from '@vueuse/core'
const props = defineProps({
options: {
type: Array,
required: true
},
modelValue: {
type: Array,
default: () => []
}
})
const emit = defineEmits(['update:modelValue'])
const dropdownVisible = ref(false)
const dropdownRef = ref(null)
const selectedValues = ref([...props.modelValue])
const displayText = computed(() => {
return selectedValues.value
.map(val => {
const option = props.options.find(opt => opt.value === val)
return option?.label || ''
})
.filter(Boolean)
.join(', ')
})
const toggleDropdown = () => {
dropdownVisible.value = !dropdownVisible.value
}
const toggleSelect = (option) => {
const index = selectedValues.value.indexOf(option.value)
if (index > -1) {
selectedValues.value.splice(index, 1)
} else {
selectedValues.value.push(option.value)
}
emit('update:modelValue', [...selectedValues.value])
}
const isSelected = (option) => {
return selectedValues.value.includes(option.value)
}
onClickOutside(dropdownRef, () => {
dropdownVisible.value = false
})
</script>
css复制<style scoped>
.multi-select {
position: relative;
width: 300px;
}
input[type="text"] {
width: 100%;
padding: 8px 12px;
border: 1px solid #dcdfe6;
border-radius: 4px;
cursor: pointer;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
width: 100%;
max-height: 200px;
overflow-y: auto;
border: 1px solid #dcdfe6;
border-radius: 4px;
background: white;
z-index: 1000;
box-shadow: 0 2px 12px 0 rgba(0,0,0,.1);
}
.dropdown-item {
display: flex;
align-items: center;
padding: 8px 12px;
cursor: pointer;
}
.dropdown-item:hover {
background-color: #f5f7fa;
}
.dropdown-item input[type="checkbox"] {
margin-right: 8px;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.2s;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style>
javascript复制const searchQuery = ref('')
const filteredOptions = computed(() => {
return props.options.filter(option =>
option.label.toLowerCase().includes(searchQuery.value.toLowerCase())
)
})
对于大量选项(超过1000条),建议使用虚拟滚动:
html复制<template>
<RecycleScroller
:items="filteredOptions"
:item-size="32"
key-field="value"
>
<template #default="{ item }">
<!-- 选项渲染 -->
</template>
</RecycleScroller>
</template>
javascript复制const isLoading = ref(false)
const loadOptions = async () => {
isLoading.value = true
try {
const res = await fetch('/api/options')
options.value = await res.json()
} finally {
isLoading.value = false
}
}
javascript复制const handleItemClick = (e) => {
const item = e.target.closest('.dropdown-item')
if (item) {
const value = item.dataset.value
// 处理选择逻辑
}
}
javascript复制import { debounce } from 'lodash-es'
const handleSearch = debounce((query) => {
searchQuery.value = query
}, 300)
javascript复制const displayText = computed(() => {
// 使用记忆化减少计算开销
})
当dropdown下方有其他可点击元素时,可能会出现点击穿透。解决方案:
css复制.dropdown-menu {
pointer-events: auto;
}
javascript复制// 配合vee-validate使用示例
const { value, errorMessage } = useField('tags', undefined, {
initialValue: []
})
html复制<input
aria-haspopup="listbox"
aria-expanded="false"
aria-controls="dropdown-menu"
>
<div
id="dropdown-menu"
role="listbox"
>
<!-- 选项 -->
</div>
javascript复制defineProps({
// 选项数据
options: {
type: Array,
validator: (val) => {
return val.every(item =>
item.value !== undefined &&
item.label !== undefined
)
}
},
// 最大选择数量
max: {
type: Number,
default: null
},
// 是否可清空
clearable: {
type: Boolean,
default: false
}
})
html复制<template #option="{ option, selected }">
<div class="custom-option">
<span>{{ option.label }}</span>
<span v-if="selected">✓</span>
</div>
</template>
css复制.multi-select {
--primary-color: #409eff;
--border-color: #dcdfe6;
/* 其他CSS变量 */
}
在实际项目中,我通常会把这个组件进一步抽象为BaseDropdown和MultiSelect两个组件,前者处理基础的下拉逻辑,后者专注于多选功能。这样可以在保持功能完整性的同时提高代码复用率。