1. 为什么需要自己封装这个组件
做前端三年多,表单场景见得最多。用户要求“点击输入框弹出一个下拉列表”,第一反应是用原生 select 或者现成的 UI 库。但实际业务往往没那么简单:下拉列表里的选项需要远程搜索过滤,选项数量可能几百上千条,选中后需要在页面上做复杂联动。
原生 select 有两个硬伤,第一是样式完全没法定制,第二是只支持静态加载,没法做异步搜索。第三方组件库比如 Element Plus 的 select 确实功能齐全,但体积大、样式定制成本高,而且很多组件库的视觉风格和业务系统不搭。这个标题里的需求看起来简单,但完整的核心逻辑其实包含:输入框显隐控制、选项渲染、点击外部关闭、键盘导航、异步过滤数据、选中回填。这几个能力拆解出来,就是一个典型的自研下拉单选组件。
那什么时候值得自己写?我的判断标准很简单:项目里只有一个页面用到,而且交互逻辑不复杂,没必要引一个庞大的组件库。但反过来,如果整个项目表单很多、交互场景复杂,那直接上组件库更稳妥。这个平衡点要根据团队维护能力去判断。
适合自研的典型场景有三类。第一类是搜索下拉,比如输入姓名,从后端接口拉取匹配用户列表;第二类是自定义样式的下拉,比如需要嵌入公司设计规范,UI 库改起来伤筋动骨;第三类是特定交互场景,比如下拉面板里需要放按钮、组合操作等,现成组件不支持。如果只是简单展示数据、而且视觉要求不高,那原生 select 或者 UI 库依然是最优先选择。
动手之前先明确组件边界:单选、支持键盘操作、支持远程搜索、支持点击外部关闭、支持初始默认值回填。这几个点定好,后面代码不会飘。
2. 核心交互设计与关键技术决策
2.1 显隐控制的三种方案
点击 input 显示下拉面板,核心是显隐状态的管理。我在实现中对比了三种方案。
第一种是 v-show 控制面板 DOM 的显示,适合选项数量固定、面板渲染成本低的情况。面板一直存在于 DOM 树中,只是通过 CSS 切换可见性,切换速度快。
第二种是 v-if 控制,适合路由级切换或大列表数据,因为每次显隐都会重新渲染 DOM。但注意,v-if 切换时组件内 iframe/视频这类复杂资源会重新加载。
第三种是懒渲染方案,只有首次打开面板时才渲染内容,之后通过 v-show 控制显隐。这个方案结合了两者优点,尤其适合从后端拉取接口数据的场景——避免每次打开都重新请求。
实际开发中我采用第三种方案。首次点击 input 时触发打开流程,面板渲染出来并请求数据;此后重复开关,数据缓存直接显示,不会重复请求。这里的关键在于将“打开事件”和“渲染完成”拆为两步,避免响应式更新和 DOM 渲染时间差导致的闪烁问题。
2.2 点击外部关闭的实现原理
这个需求看起来简单,但坑点不少。需要监听到用户的每一次点击,判断点击位置是否在下拉面板内。
我用的方案是 document 全局监听 mousedown 事件,通过事件回调中的 event.target 判断点击位置。核心代码如下:
function handleClickOutside(event) { const containerEl = containerRef.value const panelEl = panelRef.value if (!containerEl || !panelEl) return if (!containerEl.contains(event.target) && !panelEl.contains(event.target)) { visible.value = false } }这里有两个容易踩的坑,第一个是监听事件类型必须用 mousedown 而不是 click。click 事件在 mouseup 之后才触发,如果用户在 input 中选中文本然后拖拽到面板外释放,click 事件不会触发,但 mousedown 早已触发,导致面板意外关闭。第二个坑是移除事件监听,组件卸载时必须 removeEventListener,否则多个组件反复切换时,事件监听只增不减,最终导致内存泄漏。我封装了以下卸载逻辑:
onBeforeUnmount(() => { document.removeEventListener('mousedown', handleClickOutside) })2.3 键盘导航的索引管理策略
可以让用户直接用方向键和回车键完成选择,实现全程脱离鼠标操作。这个交互的核心是 activeIndex 的维护。
逻辑上,activeIndex 初始值为 -1,表示没有选项被高亮。按向下方向键时索引加一,按向上方向键减一,当索引超出数据范围时重新回到边界。边界处理如下:
function handleKeydown(event) { const key = event.key const dataLength = filteredOptions.value.length if (key === 'ArrowDown') { event.preventDefault() visible.value = true activeIndex.value = (activeIndex.value + 1) % dataLength } else if (key === 'ArrowUp') { event.preventDefault() activeIndex.value = activeIndex.value <= 0 ? dataLength - 1 : activeIndex.value - 1 } else if (key === 'Enter') { if (activeIndex.value >= 0 && visible.value) { selectOption(filteredOptions.value[activeIndex.value]) } } else if (key === 'Escape') { visible.value = false } }看起来简单,但有几个细节必须处理。按下方向键后需要 preventDefault,否则光标会在 input 里左右移动而不是遍历选项。打开面板的时机也很关键,在按下方向键时如果面板未打开,要立即打开。索引循环计算时,当 activeIndex 是 -1 时按上方向键,应该直接跳到最后一个选项而不是变成 -2,否则永远无法高亮到正常选项。
2.4 点击选项与选中状态回填
选项的选中状态需要与输入框的值保持同步。这里我用 v-model:组件内部接收到 modelValue,当用户点击某个选项时,通过 emit('update:modelValue', option.value) 更新父组件的值。同时把选中项的文本回填到输入框显示。
数据结构上,选项一般包含 value 和 label 两个字段,value 用于逻辑处理,label 用于展示。例如用户列表的 option 是{ id: 1001, name: '张三' },选中后输入框显示“张三”,但实际提交给后端的是 id 1001。这种设计可以让父组件拿到的数据是业务主键而不是显示文本。
2.5 防抖过滤与异步数据请求
远程搜索场景,输入框内容变化后需要重新请求后端接口。这里如果不做处理,每输入一个字符就发一次请求,用户体验极差,后端也扛不住。解决方案是防抖,核心代码如下:
let timer = null function handleInput() { clearTimeout(timer) timer = setTimeout(() => { fetchOptions() }, 200) }防抖时间选 200ms,这是经过权衡的。时间太短,用户输入中文时请求会连续触发,特别是拼音输入法整个过程都会触发;时间太长,下拉响应会显得迟钝。200ms 是个比较合适的中间值。
异步请求还需要处理一个竞态问题:用户输入“张”触发第一次请求,输入“张三”触发第二次请求,如果第二次请求先返回,第二次接口数据被覆盖,选项列表里就会显示错误数据。解决方案是记录请求序列号,只响应最后一次请求的结果:
let requestSeq = 0 async function fetchOptions(keyword) { const currentSeq = ++requestSeq const res = await fetchApi(keyword) if (currentSeq !== requestSeq) return options.value = res.data }3. 完整组件实现:从零到可用的 SelectInput
3.1 组件目录与基础结构
我把组件放在 src/components/SelectInput.vue,这个组件设计上只依赖 Vue3,不依赖额外第三方库。整个文件结构如下:
<template> <div ref="containerRef" class="select-input"> <div class="select-input__control"> <input ref="inputRef" v-model="keyword" type="text" placeholder="请输入姓名或工号" @focus="handleFocus" @input="handleInput" @keydown="handleKeydown" @blur="handleBlur" /> </div> <div v-show="isPanelVisible" ref="panelRef" class="select-input__dropdown" > <ul class="select-input__list"> <li v-for="(item, index) in options" :key="item.value" class="select-input__item" :class="{ 'is-active': activeIndex === index }" @mousedown.prevent="selectOption(item)" @mouseenter="activeIndex = index" > <span>{{ item.label }}</span> <span class="select-input__mark" v-if="modelValue === item.value">✓</span> </li> <li v-if="!options.length" class="select-input__empty"> 暂无匹配数据 </li> </ul> </div> </div> </template>模板中几个细节需要解释。整个下拉面板使用 v-show 控制显隐,同时维护了 panelRef 引用,这个引用用于点击外部判断。选项项采用 v-for 渲染,:key 使用 value 字段保证唯一性。is-active 类控制了键盘导航时的选中高亮样式。
3.2 核心 script 逻辑完整代码
<script setup> import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue' const props = defineProps({ modelValue: { type: String, default: '' }, options: { type: Array, default: () => [] }, placeholder: { type: String, default: '请输入' }, isRemote: { type: Boolean, default: false }, keywordQuery: { type: Function, default: null }, }) const emit = defineEmits(['update:modelValue', 'select']) const containerRef = ref(null) const panelRef = ref(null) const inputRef = ref(null) const visible = ref(false) const hasOpened = ref(false) const activeIndex = ref(-1) const keyword = ref('') const localOptions = ref([]) const requestSeq = ref(0) let debounceTimer = null const isPanelVisible = computed(() => { return visible.value && (hasOpened.value || props.options.length > 0) })上面定义的是组件基础状态。props 里 modelValue 负责与父组件的 v-model 进行双向绑定,options 传入的是下拉数据源,isRemote 标识是否远程搜索,keywordQuery 是外部传入的查询函数。
const currentOptions = computed(() => { if (props.isRemote) { return localOptions.value } if (props.options.length > 0) { return props.options } return localOptions.value }) watch(() => props.options, (newOptions) => { if (newOptions && newOptions.length) { localOptions.value = newOptions } }, { immediate: true })某条路径下,父组件会直接传入过滤后的完整选项列表。isRemote 为 true 时,父组件传空数组,组件通过 keywordQuery 自己请求数据。这里 watch 的作用是同步父组件传入的数据源,避免外部数据更新后内部还是旧数据。
function handleFocus() { visible.value = true if (!hasOpened.value) { hasOpened.value = true if (props.isRemote && !keyword.value) { fetchOptions('') } } } function handleInput() { if (props.isRemote) { clearTimeout(debounceTimer) debounceTimer = setTimeout(() => { fetchOptions(keyword.value.trim()) }, 200) } else { visible.value = true } } async function fetchOptions(searchText) { const currentSeq = ++requestSeq.value const result = await props.keywordQuery(searchText) if (currentSeq !== requestSeq.value) return localOptions.value = result || [] } function selectOption(item) { emit('update:modelValue', item.value) emit('select', item) keyword.value = item.label visible.value = false activeIndex.value = -1 }selectOption 里有一个细节:点击选项时,给 input 输入框回填的是 item.label,但 emit 给父组件的是 item.value。如果业务中需要同时回填 label 和 value,可以再把整个 item 对象通过 select 事件传出去,由父组件自行处理。
function handleKeydown(event) { const key = event.key if (key === 'ArrowDown' || key === 'ArrowUp') { event.preventDefault() visible.value = true if (!hasOpened.value) { hasOpened.value = true if (props.isRemote && !keyword.value) fetchOptions('') return } const len = currentOptions.value.length if (len === 0) return if (key === 'ArrowDown') { activeIndex.value = (activeIndex.value + 1) % len } else { activeIndex.value = activeIndex.value <= 0 ? len - 1 : activeIndex.value - 1 } } else if (key === 'Enter') { if (visible.value && activeIndex.value >= 0) { const target = currentOptions.value[activeIndex.value] if (target) selectOption(target) } } else if (key === 'Escape') { visible.value = false activeIndex.value = -1 } } function handleBlur() { setTimeout(() => { if (!containerRef.value.contains(document.activeElement)) { visible.value = false } }, 10) }handleBlur 里的延迟逻辑是为了兼容 mousedown 和 blur 的时序关系。鼠标点击下拉项时,input 会先触发 blur,随后才触发 mousedown。如果没有这个 setTimeout 的判断,面板会在 mousedown 还没执行时先关闭,导致选项无法被点击。
function handleClickOutside(event) { const containerEl = containerRef.value const panelEl = panelRef.value if (!containerEl || !panelEl) return if (!containerEl.contains(event.target) && !panelEl.contains(event.target)) { visible.value = false } } onMounted(() => { document.addEventListener('mousedown', handleClickOutside) }) onBeforeUnmount(() => { document.removeEventListener('mousedown', handleClickOutside) clearTimeout(debounceTimer) })事件监听在组件挂载时注册,卸载时移除。防抖定时器在卸载时也需要清理,避免组件已卸载但定时器还在回调里执行,导致报错。
3.3 样式实现要点
下拉面板定位用 absolute 定位,相对容器定位。注意设置好层级确保面板不被其他元素遮挡:
.select-input { position: relative; display: inline-block; } .select-input__dropdown { position: absolute; left: 0; right: 0; top: calc(100% + 4px); background: #fff; border: 1px solid #e4e7ed; border-radius: 4px; box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1); z-index: 1000; max-height: 220px; overflow-y: auto; }max-height 和 overflow-y 需要提前设置,否则选项过多时面板会超出视口范围。同时加一个 z-index 管理,防止和后续页面上其他弹层产生层级冲突。
4. 父组件如何调用这个组件
4.1 本地静态数据调用方式
使用本地数据时,组件完全透明,不需要额外处理:
<template> <div> <SelectInput v-model="selectedUserId" :options="userOptions" placeholder="请选择用户" @select="handleSelect" /> </div> </template> <script setup> import { ref } from 'vue' import SelectInput from './components/SelectInput.vue' const selectedUserId = ref('') const userOptions = [ { value: '1001', label: '张三' }, { value: '1002', label: '李四' }, { value: '1003', label: '王五' }, { value: '1004', label: '赵六' }, ] function handleSelect(item) { console.log('选中的完整对象:', item) console.log('选中的值:', item.value) } </script>父组件只需要管数据来源和业务处理。输入框内容的回填、选中状态、显隐控制全部由子组件内部完成。
4.2 远程搜索调用方式
远程搜索需要把 options 传一个空数组,同时配置 isRemote 和 keywordQuery。keywordQuery 是一个 Promise 函数,返回搜索后的选项列表:
<template> <SelectInput v-model="selectedUserId" :is-remote="true" :keyword-query="queryUsers" placeholder="输入关键词搜索用户" /> </template> <script setup> import axios from 'axios' async function queryUsers(keyword) { const res = await axios.get('/api/user/search', { params: { keyword } }) return res.data } </script>这种模式下组件内部的防抖逻辑会生效。如果接口返回数据格式和后端不一致,可以在 queryUsers 中做映射转换。
4.3 非脚本环境下的纯标记写法
如果你项目里没有用到<script setup>语法,也可以用传统的 script 选项式 API 封装同样的逻辑。核心原理一致,只是代码组织方式不同。
5. 常见问题排查与解决实录
5.1 点击下拉选项时面板闪退
这是最经典的坑。表现为点击选项时下拉面板先关闭,选项内容还没选上,用户必须点两下才能选中。
根本原因是 blur 与 mousedown 的触发顺序问题。鼠标点击面板时,input 先失焦触发 blur,面板设置 visible=false,随后 mousedown 触发,但面板 DOM 已经被关闭了。解决方法是把选项点击事件从 click 改成 mousedown,并且在 mousedown 回调中调用 preventDefault。mousedown 比 blur 先触发,preventDefault 阻止默认行为后,blur 会被跳过。
5.2 远程搜索时点击选项又触发了 input 事件
选项点击后回填 label 到输入框,回填动作本身的赋值会触发 input 事件。此时面板已经关闭,但 input 事件还是会触发远程搜索,造成多余的请求。
解决方法是回填时加一个标志位,标记这是程序赋值而非用户输入:
let isProgrammaticUpdate = false function selectOption(item) { isProgrammaticUpdate = true keyword.value = item.label emit('update:modelValue', item.value) visible.value = false requestAnimationFrame(() => { isProgrammaticUpdate = false }) } function handleInput() { if (isProgrammaticUpdate) return // 正常搜索逻辑 }5.3 键盘导航时列表没有滚动到可见区域
选项多的时候,高亮项跑到面板的可视区域之外,用户看不见被高亮的选项,只能靠记忆判断当前位置。
方案是给 activeIndex 加上 watch,在高亮索引变化时调用 scrollIntoView:
watch(activeIndex, (newIndex) => { const listItems = panelRef.value?.querySelectorAll('.select-input__item') if (listItems && listItems[newIndex]) { listItems[newIndex].scrollIntoView({ block: 'nearest' }) } })scrollIntoView 的 block: 'nearest' 很关键,它只在元素超出视口时滚动,不会每次高亮都对整个滚动容器做大幅滚动。
5.4 点击下拉面板内部的滚动条时面板关闭
用户拖动面板右侧滚动条时,滚动条本身不属于 option,事件 target 落在面板本身上。因为外层判断的是 containerRef 和 panelRef,滚动条是 panelRef 的子元素,循环往复时 handleClickOutside 判断会认为点击在 document 的其他区域,从而错误关闭面板。
排查这个问题,需要打印出事件 target 和它所在的父级判断:
mousedown target -> 滚动条 容器包含滚动条?容器包含滚动条? 是 面板包含滚动条? 是 结论:不关闭面板如果发现滚动条点击被判定为外部点击,检查 panelRef 是否被正确绑定到了面板最外层容器。
6. 交互细节打磨与扩展方向
6.1 失焦边缘场景处理
除了点击外部关闭,还需要处理 Tab 键失焦、输入框清空、以及异步请求未返回时组件卸载这几个场景。Tab 失焦时,handleBlur 中的 document.activeElement 是 body,会正确触发关闭。异步请求未返回时组件卸载,需要清理定时器并在请求回调里判断组件是否已卸载,最简单的方式是记录一个 unmounted 标志。
6.2 多选模式的扩展思路
这个组件目前是单选实现,如果业务升级为多选,核心要改四处。第一,keyword 变成数组维护;第二,选中项回填标签而不是文本;第三,emit 值变成选中项数组;第四,键值对比从 === 变为 includes。改起来工作量不大,但前提是最初的设计里把数据管理和 UI 渲染拆开。
6.3 移动端适配
移动端场景下,input 弹出原生键盘会导致固定定位面板位置计算错误。需要监听 resize 事件动态调整面板位置。点击外部关闭在移动端可能要改成 touchstart 监听,避免 click 300ms 延迟带来的交互卡顿。
提示:桌面端做键盘交互全覆盖,移动端只保留点击选择,键盘导航可以禁用。两种模式通过 UA 或 media query 判断,避免移动端误触方向键导致列表跳来跳去。
7. 踩过的坑与最终体会
这个组件前后迭代了三个版本,第一个版本只有点击态和关闭态,没有键盘操作;第二个版本加了键盘,但点击外部关闭的事件判断写错,反复修了好几次;第三个版本才补齐全交互。回顾整个开发过程,最大的体会是:一个看似简单的小组件,真正的难点不在代码量,而在于交互时序的理解。
比如 mousedown、blur、click 的事件顺序,比如 blur 与 document.activeElement 的关系,比如异步竞态和防抖的组合。这些知识点单拎出来都不难,但组合在一起时,如果没有完整的场景化理解,很容易写出看起来对但实际有 bug 的代码。
如果后续要在公司内部推广这个组件,建议先把完整的交互规范定下来,包括高亮态样式、空数据展示、键盘操作范围、移动端行为。这些规范比代码本身更能提高团队效率。
最后再分享一个小技巧:遇到点击下拉项和 input 失焦冲突的问题,不要急着加各种标志位,先梳理事件触发的先后顺序,在浏览器里打断点看清楚每个回调执行到了哪一步。时序关系理清了,问题自然就解决了。