
前段时间面试的时候,面试官看了我简历上一句“擅长组件封装”,就丢给我一个挑战:“那你来做一个可搜索、支持多选的下拉菜单组件吧,最好能加上键盘操作。”这个题目一下子点燃了我——它看起来简单,却能充分暴露一个人对事件控制、状态管理、DOM 操作乃至交互体验的理解。
我当场只写了个半成品,后来回家心有不甘,干脆用 Vue3 + Composition API 把它真正封装了一版。这篇文章,就来完整分享这个组件的设计过程、功能实现与细节打磨。
我思考了很久一个好用的下拉选择框到底长什么样。
从这五个角度出发,我开始设计整体逻辑。思维导图如下:

首先,我要设计几个状态变量,这些状态组成了组件的“大脑”。
const options = [
{ value: 'apple', label: '苹果' },
{ value: 'banana', label: '香蕉' },
{ value: 'cherry', label: '樱桃' },
{ value: 'durian', label: '榴莲' },
];组件内部我维护了三个关键的响应式变量:
visibleOptions:当前展示的候选项,是 options 经过关键词过滤后的结果;selectedValues:用户已经选中的选项数组;highlightIndex:当前键盘高亮项的索引,用来控制上下移动。除此之外,还有个 inputValue 控制输入框绑定的值。
下拉框的样式我决定做得清爽一些,采用浅灰背景、圆角边框、hover 高亮风格,再加上流畅的过渡动画。
HTML 结构如下:
<div class="dropdown" @click="openDropdown" @blur="handleBlur" tabindex="0">
<div class="tags">
<span v-for="item in selectedValues" class="tag">
{{ item.label }}
<span class="remove" @click.stop="removeTag(item)">×</span>
</span>
<input
v-model="inputValue"
@input="onInput"
@keydown="onKeyDown"
@focus="openDropdown"
placeholder="请输入关键词"
/>
</div>
<ul v-if="isDropdownOpen" class="dropdown-menu">
<li
v-for="(item, index) in visibleOptions"
:class="{ highlighted: index === highlightIndex }"
:data-index="index"
@mousedown.prevent="selectOption(item)"
>
{{ item.label }}
</li>
</ul>
</div>样式我使用了基本的 Tailwind 风格,也可以自定义 class 实现更细致控制。下面是简洁样式片段:
.dropdown {
position: relative;
border: 1px solid #ccc;
border-radius: 6px;
padding: 4px;
background: white;
max-width: 400px;
}
.dropdown-menu {
position: absolute;
top: 100%;
left: 0;
background: white;
max-height: 200px;
overflow: auto;
border: 1px solid #eee;
border-radius: 4px;
z-index: 10;
}
.highlighted {
background-color: #f0f0f0;
}
.tag {
background: #d9f7be;
padding: 2px 8px;
border-radius: 4px;
margin-right: 4px;
}
.remove {
margin-left: 4px;
cursor: pointer;
}为了避免每次键入都触发完整过滤逻辑,我加上了防抖函数:
const onInput = debounce(() => {
visibleOptions.value = options.filter(item =>
item.label.includes(inputValue.value)
);
}, 300);每次选中一个项时,我检查是否已在 selectedValues 中,若没有则加入:
const selectOption = (item) => {
if (!selectedValues.value.find(v => v.value === item.value)) {
selectedValues.value.push(item);
}
inputValue.value = '';
updateVisibleOptions(); // 重新过滤一遍
};删除标签也很简单:
const removeTag = (item) => {
selectedValues.value = selectedValues.value.filter(v => v.value !== item.value);
};const onKeyDown = (e) => {
if (e.key === 'ArrowDown') {
highlightIndex.value = (highlightIndex.value + 1) % visibleOptions.value.length;
} else if (e.key === 'ArrowUp') {
highlightIndex.value =
(highlightIndex.value - 1 + visibleOptions.value.length) % visibleOptions.value.length;
} else if (e.key === 'Enter') {
const item = visibleOptions.value[highlightIndex.value];
if (item) selectOption(item);
}
};这块我特意封装了 on() 和 off() 方法,用于绑定和解绑键盘事件,逻辑清晰:
onMounted(() => {
window.addEventListener('keydown', onKeyDown);
});
onUnmounted(() => {
window.removeEventListener('keydown', onKeyDown);
});blur 事件加延时是为了防止点击候选项时菜单提前关闭:
const handleBlur = () => {
setTimeout(() => {
isDropdownOpen.value = false;
}, 150);
};当初最开始只是写在页面里玩玩,随着功能越做越全,我就想把它抽象成 <SearchMultiSelect> 组件,做到“安装即用”。为此,我拆分出 Props、Emits、Slots,然后把所有内部逻辑都隐藏在 setup() 里,只给外部最精简的接口:
// SearchMultiSelect.vue
<script lang="ts" setup>
import { ref, computed, watch, onMounted, onUnmounted } from 'vue';
interface Option { value: string; label: string; }
const props = defineProps<{
options: Option[];
placeholder?: string;
}>();
const emit = defineEmits<{
(e: 'update:selected', values: Option[]): void;
}>();
// ---- 内部状态 ----
const inputValue = ref('');
const isDropdownOpen = ref(false);
const visibleOptions = ref<Option[]>([...props.options]);
const selectedValues = ref<Option[]>([]);
const highlightIndex = ref(0);
// ---- 核心逻辑函数 ----
function updateVisible() {
const keyword = inputValue.value.trim().toLowerCase();
visibleOptions.value = props.options.filter(item =>
item.label.toLowerCase().includes(keyword)
);
if (highlightIndex.value >= visibleOptions.value.length) {
highlightIndex.value = visibleOptions.value.length - 1;
}
}
const onInput = debounce(() => {
updateVisible();
}, 200);
function selectOption(item: Option) {
if (!selectedValues.value.find(v => v.value === item.value)) {
selectedValues.value.push(item);
emit('update:selected', selectedValues.value);
}
inputValue.value = '';
updateVisible();
}
function removeTag(item: Option) {
selectedValues.value = selectedValues.value.filter(v => v.value !== item.value);
emit('update:selected', selectedValues.value);
}
// 更多逻辑:键盘导航、聚焦/失焦……
</script>
<template>
<div class="sms-container" @click="isDropdownOpen = true" tabindex="0" @blur="closeWithDelay">
<div class="sms-tags">
<span v-for="tag in selectedValues" class="sms-tag">
{{ tag.label }}
<span class="sms-remove" @click.stop="removeTag(tag)">×</span>
</span>
<input
v-model="inputValue"
:placeholder="placeholder || '请选择...'"
@input="onInput"
@keydown="handleKeydown"
@focus="isDropdownOpen = true"
/>
</div>
<ul v-if="isDropdownOpen" class="sms-menu">
<li
v-for="(opt, idx) in visibleOptions"
:key="opt.value"
:class="{ 'sms-highlight': idx === highlightIndex }"
@mousedown.prevent="selectOption(opt)"
>
{{ opt.label }}
</li>
</ul>
</div>
</template>
<style scoped>
/* 精简样式,大家可以根据项目主题自定义 */
.sms-container { position: relative; border: 1px solid #ccc; border-radius: 8px; padding: 6px; }
.sms-tags { display: flex; flex-wrap: wrap; align-items: center; }
.sms-tag { background: #e6f7ff; padding: 2px 6px; border-radius: 4px; margin: 2px; }
.sms-remove { margin-left: 4px; cursor: pointer; }
.sms-menu { position: absolute; top: 100%; left: 0; width: 100%; max-height: 240px; overflow-y: auto; background: #fff; border: 1px solid #eee; border-radius: 4px; margin-top: 4px; }
.sms-menu li { padding: 8px; cursor: pointer; }
.sms-highlight { background-color: #fafafa; }
</style>我记得当时在写这个组件的时候,有个需求是能让父组件“受控”或“非受控”地使用它。也就是说,如果父组件给了 v-model:selected="foo",它就把 selectedValues 同步给 foo,否则自己在本地维护一套。为此,我加了这样的小技巧:
// props 中加一个 modelValue, emits 里加 update:modelValue
const innerSelected = ref(props.modelValue || []);
watch(() => props.modelValue, v => { innerSelected.value = v; });
watch(innerSelected, v => { emit('update:modelValue', v); });有了这个机制,父组件既可以写 <SearchMultiSelect :options="opts" v-model:selected="selList" />,也能不加 v-model:selected,组件自己管自己。
写完核心功能后,我逐条测试,确保不会出各种莫名其妙的 bug。比如:当筛选结果为空时,提示“无匹配项”,当然这是 UX 必不可少的;当所有选项都被选完,菜单里也要显示“All Selected”;当用户连续快速删除标签,焦点要回到输入框;当粘贴一大段文字,也会触发防抖过滤等等。
下面插入一个简化的流程图,帮你理清从用户输入到选项更新的完整流程:

有了功能,光有静态效果还不够。用户体验很大程度上取决于动画的细节。我给下拉面板加了渐变出现动画,给高亮项加了平滑背景过渡,给标签删除动作加了缩放淡出效果。Vue3 的 <Transition> 组件完美胜任:
<Transition name="fade-scale">
<ul v-if="isDropdownOpen" class="sms-menu">
<!-- 列表项 -->
</ul>
</Transition>配合 CSS:
.fade-scale-enter-active, .fade-scale-leave-active {
transition: all 0.2s ease;
}
.fade-scale-enter-from, .fade-scale-leave-to {
opacity: 0; transform: scaleY(0.8);
}这样,下拉面板从无到有时,会出现轻盈的缩放和渐显,看着就像翩翩起舞的纸片,极大提升了用户愉悦感。标签的删除我也加了个简单的过渡:
.sms-tag {
transition: all 0.15s ease;
}
.sms-tag.leave-active {
opacity: 0; transform: scale(0.7);
}当然,如果你想更专业,也可以换成 animate.css、或自己写更复杂的关键帧动画。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。