三亩地 三亩地SAN MU DI · CODE DIARY
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 class="custom-select"> <input v-model="selectedLabel" @click="toggleDropdown" readonly placeholder="请选择" class="select-input" /> <div v-show="isOpen" class="dropdown-menu"> <div v-for="option in options" :key="option.value" @click="selectOption(option)" class="dropdown-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>

这个基础版本已经实现了:

  1. 点击input显示/隐藏下拉列表
  2. 点击选项更新input显示值
  3. 通过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: absolutez-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 class="custom-select" ref="dropdownRef"> <!-- 原有模板内容 --> </div> </template>

3.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 class="custom-select" ref="dropdownRef" @keydown="handleKeydown" > <input <!-- 其他属性 --> @focus="focusedIndex = -1" /> <div v-show="isOpen" class="dropdown-menu"> <div v-for="(option, index) in options" :key="option.value" @click="selectOption(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 class="dropdown-menu" @scroll="handleScroll" :style="{ height: `${dropdownHeight}px` }" > <div class="dropdown-scroller" :style="{ height: `${props.options.length * itemHeight}px` }"> <div v-for="option in visibleOptions" :key="option.value" class="dropdown-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; } </style>

4.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 class="dropdown-menu"> <div v-if="isLoading" class="loading">加载中...</div> <template v-else> <!-- 选项列表 --> </template> </div> </template>

5. 与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 class="dropdown-menu" :style="dropdownStyle"> <!-- 选项列表 --> </div> </template>

6.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 <!-- 其他属性 --> :name="attrs.name" @blur="attrs.onBlur" /> </template>

6.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 class="custom-select" ref="dropdownRef" @keydown="handleKeydown" > <input v-model="selectedLabel" @click="toggleDropdown" readonly :placeholder="placeholder" class="select-input" :class="{ 'is-open': isOpen }" :disabled="disabled" ref="inputRef" /> <div v-show="isOpen" class="dropdown-menu" :style="dropdownStyle" > <div v-for="(option, index) in filteredOptions" :key="option.value" @click="selectOption(option)" :class="['dropdown-item', { 'selected': modelValue === option.value, 'focused': index === focusedIndex, 'disabled': option.disabled }]" > {{ option.label }} </div> <div v-if="filteredOptions.length === 0" class="empty-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-model="selectedValue" :options="options" 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>

这个完整实现包含了:

  • 完整的键盘导航支持
  • 禁用状态处理
  • 选项过滤功能
  • 动态定位
  • 主题支持
  • 表单验证集成
  • 丰富的状态样式

在实际项目中使用时,可以根据具体需求进一步扩展功能,比如添加选项分组、多选支持、远程搜索等。

← 返回列表