首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >自定义下拉菜单封装

自定义下拉菜单封装

原创
作者头像
繁依Fanyi
发布2025-05-07 18:34:11
发布2025-05-07 18:34:11
4640
举报

前段时间面试的时候,面试官看了我简历上一句“擅长组件封装”,就丢给我一个挑战:“那你来做一个可搜索、支持多选的下拉菜单组件吧,最好能加上键盘操作。”这个题目一下子点燃了我——它看起来简单,却能充分暴露一个人对事件控制、状态管理、DOM 操作乃至交互体验的理解。

我当场只写了个半成品,后来回家心有不甘,干脆用 Vue3 + Composition API 把它真正封装了一版。这篇文章,就来完整分享这个组件的设计过程、功能实现与细节打磨。


一、灵感从“好用”开始

我思考了很久一个好用的下拉选择框到底长什么样。

  1. 输入时可以搜索关键词,快速过滤;
  2. 可以多选,已选项显示为标签,可移除;
  3. 鼠标点击某项可以选择,键盘也能上下切换、回车选中;
  4. 面板切换要自然,不能一失焦就突然关掉;
  5. 支持复用,可用作组件。

从这五个角度出发,我开始设计整体逻辑。思维导图如下:

二、数据结构设计

首先,我要设计几个状态变量,这些状态组成了组件的“大脑”。

代码语言:ts
复制
const options = [
  { value: 'apple', label: '苹果' },
  { value: 'banana', label: '香蕉' },
  { value: 'cherry', label: '樱桃' },
  { value: 'durian', label: '榴莲' },
];

组件内部我维护了三个关键的响应式变量:

  • visibleOptions:当前展示的候选项,是 options 经过关键词过滤后的结果;
  • selectedValues:用户已经选中的选项数组;
  • highlightIndex:当前键盘高亮项的索引,用来控制上下移动。

除此之外,还有个 inputValue 控制输入框绑定的值。


三、UI构造:现代风格,不将就

下拉框的样式我决定做得清爽一些,采用浅灰背景、圆角边框、hover 高亮风格,再加上流畅的过渡动画。

HTML 结构如下:

代码语言: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 实现更细致控制。下面是简洁样式片段:

代码语言:css
复制
.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;
}

四、功能实现与细节打磨

输入防抖 + 搜索过滤

为了避免每次键入都触发完整过滤逻辑,我加上了防抖函数:

代码语言:ts
复制
const onInput = debounce(() => {
  visibleOptions.value = options.filter(item =>
    item.label.includes(inputValue.value)
  );
}, 300);

多选逻辑 + 标签移除

每次选中一个项时,我检查是否已在 selectedValues 中,若没有则加入:

代码语言:ts
复制
const selectOption = (item) => {
  if (!selectedValues.value.find(v => v.value === item.value)) {
    selectedValues.value.push(item);
  }
  inputValue.value = '';
  updateVisibleOptions(); // 重新过滤一遍
};

删除标签也很简单:

代码语言:ts
复制
const removeTag = (item) => {
  selectedValues.value = selectedValues.value.filter(v => v.value !== item.value);
};

键盘导航:上下 + 回车选中

代码语言:ts
复制
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() 方法,用于绑定和解绑键盘事件,逻辑清晰:

代码语言:ts
复制
onMounted(() => {
  window.addEventListener('keydown', onKeyDown);
});
onUnmounted(() => {
  window.removeEventListener('keydown', onKeyDown);
});

blur 事件加延时是为了防止点击候选项时菜单提前关闭:

代码语言:ts
复制
const handleBlur = () => {
  setTimeout(() => {
    isDropdownOpen.value = false;
  }, 150);
};

六、封装成组件:易用而不臃肿

当初最开始只是写在页面里玩玩,随着功能越做越全,我就想把它抽象成 <SearchMultiSelect> 组件,做到“安装即用”。为此,我拆分出 Props、Emits、Slots,然后把所有内部逻辑都隐藏在 setup() 里,只给外部最精简的接口:

代码语言:ts
复制
// 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,否则自己在本地维护一套。为此,我加了这样的小技巧:

代码语言:ts
复制
// 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”;当用户连续快速删除标签,焦点要回到输入框;当粘贴一大段文字,也会触发防抖过滤等等。

下面插入一个简化的流程图,帮你理清从用户输入到选项更新的完整流程:

八、现代 UI 优化:动画与交互反馈

有了功能,光有静态效果还不够。用户体验很大程度上取决于动画的细节。我给下拉面板加了渐变出现动画,给高亮项加了平滑背景过渡,给标签删除动作加了缩放淡出效果。Vue3 的 <Transition> 组件完美胜任:

代码语言:html
复制
<Transition name="fade-scale">
  <ul v-if="isDropdownOpen" class="sms-menu">
    <!-- 列表项 -->
  </ul>
</Transition>

配合 CSS:

代码语言: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);
}

这样,下拉面板从无到有时,会出现轻盈的缩放和渐显,看着就像翩翩起舞的纸片,极大提升了用户愉悦感。标签的删除我也加了个简单的过渡:

代码语言:css
复制
.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 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 一、灵感从“好用”开始
  • 二、数据结构设计
  • 三、UI构造:现代风格,不将就
  • 四、功能实现与细节打磨
    • 输入防抖 + 搜索过滤
    • 多选逻辑 + 标签移除
    • 键盘导航:上下 + 回车选中
  • 五、事件处理统一管理
  • 六、封装成组件:易用而不臃肿
  • 七、边界情况与体验细节
  • 八、现代 UI 优化:动画与交互反馈
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档