资讯详情

Vue3 自定义下拉单选框:点击 input 弹出面板到搜索过滤的路

📅 2026/10/21 8:43:20 | 华诺云谱 👁 阅读
Vue3 自定义下拉单选框:点击 input 弹出面板到搜索过滤的路
如果你做过管理后台一定遇到过这种需求一个下拉选择框交互偏偏要求是“点击 input 才弹出面板弹出来之后还能继续输入过滤”。最近我在重写一个表单系统时就连续接到几个类似需求刚开始图省事直接用原生 select结果在验收环节被硬生生怼了回来。今天我把这套“Vue3 中点击 input 显示下拉列表单选框”的完整实现思路拆开讲清楚从最简单的显隐控制到数据流、事件坑位、键盘操作再到带搜索过滤的进阶版本一次性写完。1. 为什么我最终还是把原生 select 换成了自定义下拉组件先说结论不是所有下拉场景都要自定义但“点击 input 显示下拉列表”这个交互原生 select 确实很难做到位。我参与的内部信息录入系统表单字段特别多几乎每天都要跟下拉框打交道。需求方提的交互描述很简短“用户点一下输入框从下面弹出的列表里单选一个值。”原型图里画的就是一个普通的 input点击后展开一组选项可以高亮、滚动、选中、自动关闭。一开始我觉得原生 select 就够了顶多调调边框颜色。真正动手才发现三个问题绕不过去。1.1 原生下拉框的三个硬伤第一个是样式不可控。select 和 option 在不同浏览器里的渲染差异非常大。你要是想在选项里放一个小图标、加一个选中对勾、调整字体间距Chrome 和 Firefox 的表现完全不一样更别提还有一些嵌入式项目里内置的浏览器内核option 的样式几乎改不动。第二个是交互受限。产品要求的语义是“点击 input 弹出面板”同时希望在弹出后 input 仍可输入过滤。原生 select 虽然点击也能弹开但它弹开的是系统级列表input 的输入行为会完全失效。想做成“输入即过滤”的远程搜索下拉原生能力是零。第三个是视觉一致性。给 select 设置 padding 在部分内核里无效下拉箭头的替换很麻烦即使在样式上勉强统一了focus 状态、hover 状态也很难做到和项目里的其他控件保持一致。1.2 自定义组件的交互边界在哪里在动手之前我先把这些交互边界列清楚后面编码才不容易漏鼠标点击 input 时下拉面板显示再次点击 input 时面板切换显隐。鼠标点击面板外任意区域面板关闭。鼠标点击某个选项选中该值input 回显对应文案面板关闭。支持键盘ArrowDown 和 ArrowUp 移动高亮Enter 确认选中Escape 关闭面板。组件对外通过 v-model 双向绑定真实值。选项数据由父级传入组件自己不维护选项源。这套需求定义下来原生 select 确实没法覆盖。而且项目已经在用 Vue3组合式 API 封装这种组件成本很低。与其每个页面各写一套“点击显示、外部关闭”的零散逻辑不如抽成公共组件。这也是我会完整实现一遍它的主要原因。2. 点击 input 弹出面板显隐控制与外层点击关闭2.1 面板状态与基础结构整个组件最核心的状态只有一个panelVisible。它决定下拉面板在不在。基础模板结构是这样的template div classselect-wrapper refwrapperRef input typetext readonly :placeholderplaceholder :valuedisplayText clicktogglePanel / transition namedrop ul classselect-panel v-showpanelVisible li v-foritem in options :keyitem.value :class{ is-active: item.value modelValue } clickhandleSelect(item) {{ item.label }} /li /ul /transition /div /template这里有几个细节要说明input上加readonly是为了让基础版保持“只能点击选择不能手动输入”的语义。光标点进去不会弹出系统键盘也不会让用户随意改文本。这个属性到了后面可搜索版本会去掉。面板用v-show而不是v-if。如果选项不多两者差不多但v-show会把display: none直接挂在ul上频繁开关时不会反复销毁重建 DOM实测在几百个选项的场景下更稳。transition配合一个简单的淡入下移动画视觉上会比干巴巴的出现舒服很多。显隐切换的逻辑const togglePanel () { panelVisible.value !panelVisible.value }就这么简单。真正麻烦的是“点击外部关闭”那部分。2.2 document 级点击监听与 contains 判断点击外部关闭下拉最常见的做法是在onMounted里给document挂一个click监听在回调里判断点击目标是否落在组件根节点内部const wrapperRef ref(null) const panelVisible ref(false) const handleOuterClick (e) { if (!wrapperRef.value) return if (!wrapperRef.value.contains(e.target)) { panelVisible.value false } } onMounted(() { document.addEventListener(click, handleOuterClick) }) onUnmounted(() { document.removeEventListener(click, handleOuterClick) })关键就一行contains(e.target)。它能准确判断点击点是不是组件内部的元素包括 input、面板里的每一项、滚动条等。不在组件内部就强制关闭面板。这里要特别注意两个问题监听一定要挂在document上不要挂在window上。我见过有人写window.addEventListener(click, ...)大多数情况下也能用但某些文档流场景下两者捕获顺序和事件冒泡层级会有差异用document最稳妥。卸载时一定要移除监听。如果你的组件在弹窗里使用弹窗关闭时组件可能被销毁监听不清理会导致后续每次点击都执行一次无意义的判断严重时还会因闭包引用产生内存压力。2.3 为什么不用 input 的 blur 事件来关闭很多第一次写这类需求的人会尝试用blur关闭面板思路是“输入框失去焦点就收起列表”。这个思路在纯桌面端看着没问题但实际落地时会踩一个非常隐蔽的坑点击下拉面板中的选项时事件触发顺序是mousedown先触发。input 失去焦点blur事件触发面板此时就被关掉了。mouseup触发。click触发。也就是说当click到达选项 li 的时候面板已经因为blur被关了。你原本想在handleSelect里处理选中逻辑结果 li 都被销毁了click事件根本不会触发。表现就是用户点了选项列表消失值却没选中。这也是我在实际项目中踩过最典型的坑之一。解决的策略很明确不要用blur做关闭统一用document级click来做外部关闭判断。这样点击选项时事件源仍在组件内部contains判断为 true面板不会被关闭handleSelect可以正常执行。3. 单选数据流v-model 背后的绑定链路3.1 界面显示 label业务保存 value下拉单选组件最容易被新手搞错的地方是“显示值”和“真实值”的区分。业务后端通常只认value比如省份编码110000、状态码1、用户 id9527。但界面上要展示给用户看的是label比如“北京市”“已启用”“张三”。在组件内部input 显示的是label但对外绑定的 v-model 值应该是value。所以组件里会有一个displayText的计算属性const props defineProps({ modelValue: { type: [String, Number], default: }, options: { type: Array, default: () [] }, placeholder: { type: String, default: 请选择 } }) const displayText computed(() { const matched props.options.find(item item.value props.modelValue) return matched ? matched.label : })displayText做的事情很简单从options里找到value modelValue的那一项返回它的label。这里还有一个容易忽略的边界情况如果 modelValue 在选项里找不到对应项怎么办比如后端返回给回显接口的 value 因为数据变更失效了。此时displayText返回空字符串input 会表现得像没选中一样。从产品角度看这个交互其实是合理的因为旧数据确实没有合法显示项了。如果你希望在表单里提示用户“数据已失效请重新选择”可以额外加一个invalid计算属性在表单校验时做高亮。3.2 emit 更新与 edge case选中项的处理逻辑const emit defineEmits([update:modelValue]) const handleSelect (item) { emit(update:modelValue, item.value) panelVisible.value false }Vue3 的v-model本质是:model-value加上update:model-value的语法糖。组件内部不能直接改 prop必须通过emit(update:modelValue, 新值)向上通知父组件更新父组件绑定的数据一变再通过 props 传回来完成闭环。这个链路看起来绕但它是 Vue3 单向数据流规范的核心也是用defineModel宏出现之前的标准写法。如果你用的是 Vue 3.4 以上版本可以直接写const model defineModel()本质是一样的只是省掉了手动emit。基础学习阶段我建议还是把原生写法走一遍理解数据流之后再换宏不迟。几个 edge case 也要处理到位重复点击当前已选中的项不需要防抖直接关闭面板即可因为值没有变化父组件不会收到更新form 校验的 touched 状态不会被误触发。传入options为空数组面板打开后应该展示“暂无可选项”之类的空状态而不是渲染一个空列表。选项 value 为 0 或空字符串判断选中状态时不要用if (item.value)这种真值判断要严格用比较。4. 落地过程中绕不开的四个坑4.1 事件冒泡导致的面板闪开闪合如果 input 的clicktogglePanel和 document 的click监听同时存在会不会出现“刚打开就被关闭”的情况答案是如果contains判断写错了一定会出现。比如把handleOuterClick写成if (e.target ! wrapperRef.value) { panelVisible.value false }那点击 input 时事件冒泡到 document因为e.target是 input 而不是整个 wrapper面板就会被误关。紧接着togglePanel又因为同一个 click 把它打开视觉上就是一闪而过。实际调试时你甚至看不出是冒泡问题只会觉得面板“不稳定”。解决办法就是我前面说的统一用contains判断只要点击点落在组件根节点内部包括 input、面板、li 等都视为“内部点击”不执行关闭。这是最稳妥的方案。4.2 面板定位absolute 比 fixed 更省心下拉面板的定位方式直接影响你在哪些容器里敢用它。最省心的方案是组件根节点设置position: relative面板用position: absolute.select-wrapper { position: relative; display: inline-block; } .select-panel { position: absolute; left: 0; right: 0; top: calc(100% 4px); z-index: 999; max-height: 240px; overflow-y: auto; }这样面板会跟随组件根部自然定位不管外层是普通表单、弹窗还是页面滚动只要组件本身不被overflow: hidden裁掉面板位置基本不会错。为什么不用fixed如果页面里有滚动容器或者整个页面发生滚动fixed是相对于视口定位的面板不会跟着输入框一起动结果就是“输入框滚上去了下拉列表还留在原地”非常出戏。除非你的场景是类似顶部导航的浮层选择否则默认都用absolute。还有一个用户体验细节面板和 input 之间间距通常控制在 2px 到 8px 之间我一般用4px。间距太大会让用户觉得“点得不连贯”太小又容易和边框糊在一起。4.3 键盘导航与滚动跟随很多自定义下拉组件都只做了鼠标交互键盘操作被完全忽略。但对高频录入的表单场景来说键盘支持能让操作效率提升一大截。基本键盘逻辑有三个const handleKeydown (e) { if (!panelVisible.value e.key ArrowDown) { e.preventDefault() panelVisible.value true } if (panelVisible.value) { switch (e.key) { case ArrowDown: e.preventDefault() moveActiveIndex(1) break case ArrowUp: e.preventDefault() moveActiveIndex(-1) break case Enter: e.preventDefault() confirmSelect() break case Escape: panelVisible.value false break } } }moveActiveIndex要处理循环边界const activeIndex ref(-1) const moveActiveIndex (step) { const total options.value.length if (total 0) return activeIndex.value (activeIndex.value step total) % total scrollActiveIntoView() }边界处取模让用户按 ArrowDown 到末尾时可以循环回第一个。这个细节仁者见仁产品没有特殊要求时建议保留因为用户按了 8 次下键停在最后一个是常有的事循环回顶可以减少一次回翻操作。scrollActiveIntoView的作用是让高亮项始终在可视区域内。因为面板固定最大高度 240px选项多时内部滚动如果高亮项在可视区外用户只按键盘看不到当前聚焦到哪一档体验会非常差。const scrollActiveIntoView () { nextTick(() { const panel panelRef.value const activeItem panel?.querySelector(.is-highlight) activeItem?.scrollIntoView({ block: nearest }) }) }scrollIntoView配合block: nearest是这里最简洁的实现浏览器会自动决定往哪个方向滚动最少量。Enter 确认选中const confirmSelect () { if (activeIndex.value 0) { const wrapped filteredOptions.value const target wrapped[activeIndex.value] if (target) { emit(update:modelValue, target.value) } } panelVisible.value false }注意 Enter 确认后要把面板关上这个很自然。Escape 只关闭面板不改变当前值。4.4 选项高亮与 hover 状态的管理下拉列表里最好同时维护两种高亮状态is-selected当前已被选中项通常用主题色勾选标记。is-highlight键盘上下键移动到的临时聚焦项通常用浅色背景。这两种状态不要混用。如果你只用一个高亮类会出现“键盘移动高亮后分不清哪个是真正选中的”这种尴尬情况。实际交互里还有个容易忽略的细节鼠标 hover 事件会改变高亮位置吗我建议hover 和键盘高亮互相覆盖——鼠标移上去时把activeIndex同步成当前项索引键盘按下时忽略 hover 状态直接移动。这样保证用户无论用鼠标还是键盘看到的“当前焦点”都是同一个。const handleHover (index) { activeIndex.value index hoverMode.value true }如果你不处理 hover 同步用户先按了下键高亮停在第二项此时再用鼠标悬停到第五项视觉上会出现“键盘高亮在第二项、鼠标悬停在第五项”两个背景同时存在容易造成误选。5. 基础版完整代码与业务调用示例5.1 组件完整实现把前面所有逻辑整合起来一个基础版BaseSelect.vue大概是这样的template div classselect-wrapper refwrapperRef input typetext readonly :valuedisplayText :placeholderplaceholder clicktogglePanel keydownhandleKeydown / transition namedrop ul v-showpanelVisible classselect-panel refpanelRef li v-for(item, index) in options :keyitem.value :class{ is-selected: item.value modelValue, is-highlight: index activeIndex } clickhandleSelect(item) mouseenterhandleHover(index) span classselect-option-label{{ item.label }}/span span v-ifitem.value modelValue classselect-option-check✓/span /li li v-ifoptions.length 0 classselect-empty 暂无可选项 /li /ul /transition /div /template脚本部分script setup import { ref, computed, nextTick, onMounted, onUnmounted } from vue const props defineProps({ modelValue: [String, Number], options: { type: Array, default: () [] }, placeholder: { type: String, default: 请选择 } }) const emit defineEmits([update:modelValue]) const wrapperRef ref(null) const panelRef ref(null) const panelVisible ref(false) const activeIndex ref(-1) const displayText computed(() { const matched props.options.find(item item.value props.modelValue) return matched ? matched.label : }) const togglePanel () { panelVisible.value !panelVisible.value } const handleSelect (item) { emit(update:modelValue, item.value) activeIndex.value props.options.findIndex(opt opt.value item.value) panelVisible.value false } const handleHover (index) { activeIndex.value index } const moveActiveIndex (step) { const total props.options.length if (total 0) return activeIndex.value (activeIndex.value step total) % total nextTick(() { const activeItem panelRef.value?.querySelector(.is-highlight) activeItem?.scrollIntoView({ block: nearest }) }) } const confirmSelect () { if (activeIndex.value 0) { const target props.options[activeIndex.value] if (target) { emit(update:modelValue, target.value) } } panelVisible.value false } const handleKeydown (e) { if (!panelVisible.value e.key ArrowDown) { e.preventDefault() activeIndex.value 0 panelVisible.value true return } if (!panelVisible.value) return switch (e.key) { case ArrowDown: e.preventDefault() moveActiveIndex(1) break case ArrowUp: e.preventDefault() moveActiveIndex(-1) break case Enter: e.preventDefault() confirmSelect() break case Escape: panelVisible.value false break } } const handleOuterClick (e) { if (!wrapperRef.value) return if (!wrapperRef.value.contains(e.target)) { panelVisible.value false } } onMounted(() { document.addEventListener(click, handleOuterClick) }) onUnmounted(() { document.removeEventListener(click, handleOuterClick) }) /script样式部分style scoped .select-wrapper { position: relative; display: inline-block; width: 240px; } .select-wrapper input { width: 100%; height: 36px; padding: 0 32px 0 12px; border: 1px solid #d9d9d9; border-radius: 6px; background: #fff; cursor: pointer; outline: none; box-sizing: border-box; } .select-wrapper input:focus { border-color: #409eff; } .select-panel { position: absolute; left: 0; right: 0; top: calc(100% 4px); z-index: 999; max-height: 240px; overflow-y: auto; margin: 0; padding: 4px; list-style: none; background: #fff; border: 1px solid #e4e7ed; border-radius: 6px; box-shadow: 0 6px 16px rgba(0, 0, 0, 0.08); } .select-panel li { padding: 8px 12px; border-radius: 4px; display: flex; justify-content: space-between; align-items: center; cursor: pointer; font-size: 14px; color: #303133; } .select-panel li:hover, .select-panel li.is-highlight { background: #f5f7fa; } .select-panel li.is-selected { color: #409eff; background: #ecf5ff; } .select-empty { justify-content: center; color: #909399; cursor: not-allowed; } .drop-enter-active, .drop-leave-active { transition: opacity 0.15s ease, transform 0.15s ease; } .drop-enter-from, .drop-leave-to { opacity: 0; transform: translateY(-6px); } .drop-enter-to, .drop-leave-from { opacity: 1; transform: translateY(0); } /style5.2 页面里的调用与示例调用侧非常简单template BaseSelect v-modelprovince :optionsprovinceOptions placeholder请选择省份 / /template script setup import { ref } from vue import BaseSelect from /components/BaseSelect.vue const province ref(110000) const provinceOptions [ { label: 北京市, value: 110000 }, { label: 天津市, value: 120000 }, { label: 河北省, value: 130000 } ] /script页面里只需要维护province变量和选项数组。组件内部怎么展示、怎么关闭全部隔离在组件内部。5.3 几种典型设计稿的适配思路实际项目里设计稿不会都长一个样我总结几种常见变体带右侧箭头图标input 右侧加一个绝对定位的箭头箭头默认向下面板打开时旋转 180 度。实现方式和点击事件完全无关只是视觉反馈。宽度跟随内容如果希望面板和 input 同宽只要保持面板left:0; right:0即可。如果希望面板比 input 略宽可以在面板样式中加一个固定的width覆盖。空状态文案定制可以通过 prop 传入emptyText默认“暂无可选项”。禁用状态加一个disabledpropinput上加disabled属性同时禁止 toggle 和键盘操作。这些变体都不需要改动数据流逻辑只在模板和样式层叠加即可。6. 从单选到可搜索过滤式下拉的扩展方案6.1 过滤逻辑与输入框语义切换基础版做完之后最常见的扩展需求是“能不能在 input 里输入文字自动过滤选项”。这就要把 input 从readonly改成可输入同时增加一个keyword响应式变量input typetext v-modelkeyword :placeholderplaceholder focuspanelVisible true clickpanelVisible true keydownhandleKeydown /过滤选项用计算属性const filteredOptions computed(() { const kw keyword.value.trim().toLowerCase() if (!kw) return props.options return props.options.filter(item item.label.toLowerCase().includes(kw) ) })这里有几个体验层面的细节要处理好选中之后 keyword 要清空。否则用户选完一个值输入框里还残留刚才输入的过滤词下次打开下拉时看到的不是完整列表而是被过滤后的结果。选中后的回显不能只靠 keyword。因为 keynword 一旦清空input 展示的就是空。正确做法是不要用 v-model 绑定 Raw 数据而是用类似基础版的方式通过displayText控制显示值。可以把 input 拆成“真实输入源”和“展示值”两个维度用户输入时走keyword选中后回写显示值。一个比较简洁的实现是const inputValue ref() const handleSelect (item) { emit(update:modelValue, item.value) inputValue.value item.label panelVisible.value false keyword.value } const handleInput (e) { inputValue.value e.target.value keyword.value e.target.value panelVisible.value true }inputValue负责只管输入框里的显示文本keyword负责驱动过滤选中后把inputValue设置成label并清空keyword等下次打开时过滤条件重置为全部。6.2 异步加载、空态与防抖处理可搜索版的下拉通常还要面对一个场景选项不是一次性全量给到前端的而是根据输入关键字远程搜索。这种情况下要把filteredOptions改为异步占位。常见做法是组件内部维护一份remoteOptions父组件通过异步接口返回结果后通过 prop 传入组件内部把过滤逻辑简化成输入关键字触发emit(search, keyword)父组件请求接口后更新 options。防抖逻辑放在组件内部也可以但更推荐放到调用方的查询函数里。比如const handleSearch () { debounce(() { fetchOptions({ keyword: keyword.value }) }, 300) }空态处理也要考虑两种基础模式下 options 为空显示“暂无可选项”。搜索模式下 filteredOptions 为空显示“未找到匹配项”并提示用户尝试更换关键词。这两种文案可以放在同一块空态区域里通过一个表示“是否处于搜索状态”的布尔值区分。异步场景下还有一个隐藏问题延迟返回的旧数据可能覆盖新结果。简单规避方式是在请求回调用给每次请求打一个自增 id响应时只接受最新 id 的结果let requestSeq 0 const fetchOptions async () { const seq requestSeq const data await api.fetchOptions(keyword) if (seq requestSeq) { remoteOptions.value data } }这个技巧在选项异步场景里很实用比用 AbortController 更轻量。最后再提一个小建议如果你把基础版组件吃透了遇到带搜索、带分组、带远程加载的复杂下拉时别急着自己从头再写一遍。可以先用这个可搜索版作为底座再按需叠加分组标题、主题定制、虚拟滚动等能力。把这个组件的交互骨架想清楚后面所有“点击 input 弹出列表”类的需求你都会有一份足够打底的经验。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。

↑