尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

Vue3自定义下拉选择器实现与优化指南

Vue3自定义下拉选择器实现与优化指南 1. Vue3中实现点击input显示下拉单选列表的核心思路在Vue3中实现点击input弹出下拉单选列表本质上是在构建一个自定义的选择器组件。这个功能看似简单但需要考虑以下几个关键点触发机制通过监听input元素的点击事件来切换下拉列表的显示状态数据绑定使用v-model实现input值与选中项的同步样式控制下拉列表的定位和显示/隐藏需要通过CSS精心设计交互体验点击外部区域关闭下拉、键盘导航等细节处理我最近在重构公司后台管理系统时就遇到了需要自定义样式选择器的需求。Element Plus的el-select虽然功能完善但在某些特定设计需求下还是需要自己实现才能完美匹配UI设计稿。2. 基础实现从零构建下拉单选组件2.1 组件结构与数据设计我们先创建一个基础的Vue组件框架template div classcustom-select input v-modelselectedLabel clicktoggleDropdown readonly placeholder请选择 classselect-input / div v-showisOpen classdropdown-menu div v-foroption in options :keyoption.value clickselectOption(option) classdropdown-item {{ option.label }} /div /div /div /template script setup import { ref } from vue; const props defineProps({ options: { type: Array, required: true, default: () [] }, modelValue: { type: [String, Number], default: } }); const emit defineEmits([update:modelValue]); const isOpen ref(false); const selectedLabel ref(); const toggleDropdown () { isOpen.value !isOpen.value; }; const selectOption (option) { selectedLabel.value option.label; emit(update:modelValue, option.value); isOpen.value false; }; /script这个基础版本已经实现了点击input显示/隐藏下拉列表点击选项更新input显示值通过v-model实现双向数据绑定2.2 样式设计与定位处理下拉列表的定位是个容易出问题的点。我们需要确保下拉菜单能正确显示在input下方并且不会被其他元素遮挡.custom-select { position: relative; width: 200px; } .select-input { 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; margin-top: 4px; border: 1px solid #dcdfe6; border-radius: 4px; background: #fff; box-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); z-index: 1000; } .dropdown-item { padding: 8px 12px; cursor: pointer; } .dropdown-item:hover { background-color: #f5f7fa; }关键点必须设置position: absolute和z-index否则下拉列表可能会被其他元素遮挡。top: 100%确保下拉菜单出现在input下方。3. 增强交互体验3.1 点击外部关闭下拉基础版本有个明显问题点击页面其他区域时下拉菜单不会自动关闭。我们需要监听document的点击事件来判断是否点击了组件外部script setup import { ref, onMounted, onUnmounted } from vue; // ...其他代码... const dropdownRef ref(null); const handleClickOutside (event) { if (dropdownRef.value !dropdownRef.value.contains(event.target)) { isOpen.value false; } }; onMounted(() { document.addEventListener(click, handleClickOutside); }); onUnmounted(() { document.removeEventListener(click, handleClickOutside); }); /script template div classcustom-select refdropdownRef !-- 原有模板内容 -- /div /template3.2 键盘导航支持为了更好的可访问性我们还需要添加键盘支持script setup // ...其他代码... const focusedIndex ref(-1); const handleKeydown (e) { if (!isOpen.value) { if (e.key Enter || e.key ) { e.preventDefault(); toggleDropdown(); } return; } switch (e.key) { case Escape: isOpen.value false; break; case ArrowDown: e.preventDefault(); focusedIndex.value Math.min(focusedIndex.value 1, props.options.length - 1); break; case ArrowUp: e.preventDefault(); focusedIndex.value Math.max(focusedIndex.value - 1, 0); break; case Enter: if (focusedIndex.value 0) { selectOption(props.options[focusedIndex.value]); } break; } }; /script template div classcustom-select refdropdownRef keydownhandleKeydown input !-- 其他属性 -- focusfocusedIndex -1 / div v-showisOpen classdropdown-menu div v-for(option, index) in options :keyoption.value clickselectOption(option) :class[dropdown-item, { focused: index focusedIndex }] {{ option.label }} /div /div /div /template添加对应的CSS样式.focused { background-color: #f5f7fa; }4. 性能优化与边界情况处理4.1 虚拟滚动优化当选项很多时比如超过100条直接渲染所有DOM节点会导致性能问题。我们可以使用虚拟滚动技术script setup import { computed, ref } from vue; const visibleCount 10; // 可视区域内显示的选项数量 const scrollTop ref(0); const itemHeight 36; // 每个选项的高度 const visibleOptions computed(() { const startIndex Math.floor(scrollTop.value / itemHeight); return props.options.slice(startIndex, startIndex visibleCount); }); const dropdownHeight computed(() { return Math.min(visibleCount * itemHeight, props.options.length * itemHeight); }); const handleScroll (e) { scrollTop.value e.target.scrollTop; }; /script template div classdropdown-menu scrollhandleScroll :style{ height: ${dropdownHeight}px } div classdropdown-scroller :style{ height: ${props.options.length * itemHeight}px } div v-foroption in visibleOptions :keyoption.value classdropdown-item :style{ transform: translateY(${Math.floor(scrollTop / itemHeight) * itemHeight}px) } {{ option.label }} /div /div /div /template style .dropdown-menu { overflow-y: auto; position: relative; } .dropdown-scroller { position: relative; } .dropdown-item { position: absolute; width: 100%; height: 36px; left: 0; } /style4.2 异步加载选项对于需要从接口获取选项的情况script setup import { watchEffect } from vue; const isLoading ref(false); const options ref([]); watchEffect(async () { if (!isOpen.value) return; try { isLoading.value true; const response await fetch(/api/options); options.value await response.json(); } catch (error) { console.error(加载选项失败:, error); } finally { isLoading.value false; } }); /script template div classdropdown-menu div v-ifisLoading classloading加载中.../div template v-else !-- 选项列表 -- /template /div /template5. 与Element Plus的el-select对比虽然我们实现了自定义下拉选择器但在实际项目中使用成熟的UI库通常是更高效的选择。以下是自定义实现与el-select的主要区别特性自定义实现Element Plus el-select样式定制完全可控需要通过CSS覆盖功能完整性需要自行实现开箱即用维护成本高低性能优化需要自行处理内置虚拟滚动可访问性需要自行实现符合WAI-ARIA标准测试覆盖需要自行编写经过充分测试在实际项目中我的经验法则是如果设计需求特殊且UI库无法满足才考虑自定义实现对于大多数常规场景优先使用UI库组件自定义组件要确保至少实现基本的可访问性6. 常见问题与解决方案6.1 下拉列表位置偏移问题这个问题在页面有滚动时尤为明显。解决方案是动态计算位置script setup import { watch } from vue; const dropdownStyle ref({}); watch(isOpen, (newVal) { if (newVal) { const inputRect inputRef.value.getBoundingClientRect(); dropdownStyle.value { top: ${inputRect.bottom window.scrollY}px, left: ${inputRect.left window.scrollX}px, width: ${inputRect.width}px }; } }); /script template div classdropdown-menu :styledropdownStyle !-- 选项列表 -- /div /template6.2 表单验证集成要让自定义组件支持表单验证需要实现类似原生input的行为script setup import { useAttrs } from vue; const attrs useAttrs(); // 在selectOption中触发验证 const selectOption (option) { // ...原有代码... if (attrs.onChange) { attrs.onChange(option.value); } }; /script template input !-- 其他属性 -- :nameattrs.name blurattrs.onBlur / /template6.3 多主题支持通过CSS变量实现主题切换.custom-select { --select-border-color: #dcdfe6; --select-bg-color: #fff; --select-hover-color: #f5f7fa; --select-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.1); } .select-input { border-color: var(--select-border-color); background: var(--select-bg-color); } .dropdown-menu { background: var(--select-bg-color); box-shadow: var(--select-shadow); } .dropdown-item:hover { background-color: var(--select-hover-color); } /* 暗色主题 */ .dark .custom-select { --select-border-color: #4c4c4c; --select-bg-color: #2d2d2d; --select-hover-color: #3d3d3d; --select-shadow: 0 2px 12px 0 rgba(0, 0, 0, 0.3); }7. 完整实现与使用示例最后我们来看一个完整的实现和使用示例!-- CustomSelect.vue -- template div classcustom-select refdropdownRef keydownhandleKeydown input v-modelselectedLabel clicktoggleDropdown readonly :placeholderplaceholder classselect-input :class{ is-open: isOpen } :disableddisabled refinputRef / div v-showisOpen classdropdown-menu :styledropdownStyle div v-for(option, index) in filteredOptions :keyoption.value clickselectOption(option) :class[dropdown-item, { selected: modelValue option.value, focused: index focusedIndex, disabled: option.disabled }] {{ option.label }} /div div v-iffilteredOptions.length 0 classempty-tip 无匹配选项 /div /div /div /template script setup import { computed, ref, watch, onMounted, onUnmounted } from vue; const props defineProps({ options: { type: Array, required: true, default: () [] }, modelValue: { type: [String, Number], default: }, placeholder: { type: String, default: 请选择 }, disabled: { type: Boolean, default: false }, filterable: { type: Boolean, default: false } }); const emit defineEmits([update:modelValue, change]); const isOpen ref(false); const selectedLabel ref(); const dropdownRef ref(null); const inputRef ref(null); const focusedIndex ref(-1); const dropdownStyle ref({}); const filteredOptions computed(() { if (!props.filterable || !selectedLabel.value) { return props.options; } return props.options.filter(option option.label.toLowerCase().includes(selectedLabel.value.toLowerCase()) ); }); watch(() props.modelValue, (newVal) { const selected props.options.find(option option.value newVal); selectedLabel.value selected ? selected.label : ; }, { immediate: true }); const toggleDropdown () { if (props.disabled) return; isOpen.value !isOpen.value; if (isOpen.value) { focusedIndex.value props.options.findIndex(option option.value props.modelValue); } }; const selectOption (option) { if (option.disabled) return; selectedLabel.value option.label; emit(update:modelValue, option.value); emit(change, option.value); isOpen.value false; }; const handleClickOutside (event) { if (dropdownRef.value !dropdownRef.value.contains(event.target)) { isOpen.value false; } }; const handleKeydown (e) { if (props.disabled) return; if (!isOpen.value) { if (e.key Enter || e.key ) { e.preventDefault(); toggleDropdown(); } return; } switch (e.key) { case Escape: isOpen.value false; break; case ArrowDown: e.preventDefault(); if (focusedIndex.value filteredOptions.value.length - 1) { focusedIndex.value; scrollToOption(focusedIndex.value); } break; case ArrowUp: e.preventDefault(); if (focusedIndex.value 0) { focusedIndex.value--; scrollToOption(focusedIndex.value); } break; case Enter: if (focusedIndex.value 0 filteredOptions.value[focusedIndex.value]) { selectOption(filteredOptions.value[focusedIndex.value]); } break; } }; const scrollToOption (index) { const dropdown dropdownRef.value.querySelector(.dropdown-menu); const item dropdown.querySelectorAll(.dropdown-item)[index]; if (item) { item.scrollIntoView({ block: nearest }); } }; watch(isOpen, (newVal) { if (newVal) { const inputRect inputRef.value.getBoundingClientRect(); dropdownStyle.value { top: ${inputRect.bottom window.scrollY}px, left: ${inputRect.left window.scrollX}px, width: ${inputRect.width}px }; } }); onMounted(() { document.addEventListener(click, handleClickOutside); }); onUnmounted(() { document.removeEventListener(click, handleClickOutside); }); /script style scoped .custom-select { position: relative; display: inline-block; width: 200px; } .select-input { width: 100%; padding: 8px 12px; border: 1px solid var(--select-border-color, #dcdfe6); border-radius: 4px; cursor: pointer; background-color: var(--select-bg-color, #fff); color: var(--select-text-color, #606266); font-size: 14px; transition: border-color 0.2s; } .select-input:focus { outline: none; border-color: var(--select-active-color, #409eff); } .select-input.is-open { border-color: var(--select-active-color, #409eff); } .select-input[disabled] { cursor: not-allowed; background-color: var(--select-disabled-bg, #f5f7fa); color: var(--select-disabled-color, #c0c4cc); } .dropdown-menu { position: absolute; max-height: 200px; overflow-y: auto; margin-top: 4px; border: 1px solid var(--select-border-color, #dcdfe6); border-radius: 4px; background: var(--select-bg-color, #fff); box-shadow: var(--select-shadow, 0 2px 12px 0 rgba(0, 0, 0, 0.1)); z-index: 1000; } .dropdown-item { padding: 8px 12px; cursor: pointer; color: var(--select-text-color, #606266); } .dropdown-item:hover { background-color: var(--select-hover-color, #f5f7fa); } .dropdown-item.selected { color: var(--select-active-color, #409eff); font-weight: 500; } .dropdown-item.focused { background-color: var(--select-hover-color, #f5f7fa); } .dropdown-item.disabled { cursor: not-allowed; color: var(--select-disabled-color, #c0c4cc); } .empty-tip { padding: 8px 12px; color: var(--select-disabled-color, #c0c4cc); text-align: center; } /style使用示例template div CustomSelect v-modelselectedValue :optionsoptions placeholder请选择城市 / p当前选择的值: {{ selectedValue }}/p /div /template script setup import { ref } from vue; import CustomSelect from ./CustomSelect.vue; const selectedValue ref(); const options [ { value: bj, label: 北京 }, { value: sh, label: 上海 }, { value: gz, label: 广州 }, { value: sz, label: 深圳 }, { value: cd, label: 成都 } ]; /script这个完整实现包含了完整的键盘导航支持禁用状态处理选项过滤功能动态定位主题支持表单验证集成丰富的状态样式在实际项目中使用时可以根据具体需求进一步扩展功能比如添加选项分组、多选支持、远程搜索等。
返回列表