Select选择器组件开发中的性能优化与交互细节处理
先看效果,再看代码
最近在重构一个后台系统,发现原来的 Select 用的是原生 <select>,样式丑得没法看,还不能自定义。老板说“能不能像 Ant Design 那样?”——行吧,那就自己撸一个。折腾了两天,踩了几个坑,今天把亲测有效的方案写下来。
核心需求其实就三点:支持搜索、能多选、选项从接口动态加载。别小看这三点,组合起来坑不少。我一开始直接用现成的 UI 库(比如 Element Plus),但项目里已经引入了 Tailwind,再塞个重型组件库太重了,而且定制性差。所以决定手写一个轻量级的。
核心代码就这几行
先上最简版本,只实现单选 + 静态选项。重点是结构和交互逻辑:
<div class="relative w-64">
<div
class="border rounded px-3 py-2 cursor-pointer bg-white"
@click="toggleDropdown"
>
{{ selectedLabel || '请选择' }}
</div>
<ul
v-if="isOpen"
class="absolute z-10 w-full border rounded mt-1 bg-white max-h-60 overflow-auto"
>
<li
v-for="option in options"
:key="option.value"
class="px-3 py-2 hover:bg-gray-100 cursor-pointer"
@click="selectOption(option)"
>
{{ option.label }}
</li>
</ul>
</div>
// Vue 3 组合式 API 示例
import { ref } from 'vue'
export default {
setup() {
const isOpen = ref(false)
const selectedValue = ref(null)
const options = [
{ value: '1', label: '选项1' },
{ value: '2', label: '选项2' }
]
const selectedLabel = computed(() => {
return options.find(opt => opt.value === selectedValue.value)?.label || ''
})
const toggleDropdown = () => {
isOpen.value = !isOpen.value
}
const selectOption = (option) => {
selectedValue.value = option.value
isOpen.value = false
}
// 点击外部关闭
const handleClickOutside = (e) => {
if (!e.target.closest('.relative')) {
isOpen.value = false
}
}
onMounted(() => {
document.addEventListener('click', handleClickOutside)
})
onUnmounted(() => {
document.removeEventListener('click', handleClickOutside)
})
return {
isOpen,
selectedValue,
selectedLabel,
options,
toggleDropdown,
selectOption
}
}
}
这个基础结构跑起来没问题,但离生产环境还差得远。下面说说实战中真正要命的细节。
踩坑提醒:这三点一定注意
第一,滚动穿透问题。 下拉框打开时,页面背景还能滚动?用户一滑就关了下拉,体验极差。解决方法是在打开时给 body 加 overflow: hidden,但要注意:如果页面本身有 fixed 定位元素(比如 header),加 overflow 会导致布局抖动。我的妥协方案是只在移动端加,PC 端靠合理的 z-index 和点击外部关闭兜底。
第二,键盘导航支持。 别以为只有鼠标用户!无障碍访问要求能用方向键上下选择,回车确认。我一开始完全没考虑,直到测试提了 bug。加起来其实不难:
const handleKeyDown = (e) => {
if (!isOpen.value) return
if (e.key === 'ArrowDown') {
e.preventDefault()
// focusIndex 是当前高亮的索引
focusIndex.value = Math.min(focusIndex.value + 1, options.length - 1)
} else if (e.key === 'ArrowUp') {
e.preventDefault()
focusIndex.value = Math.max(focusIndex.value - 1, 0)
} else if (e.key === 'Enter') {
e.preventDefault()
if (focusIndex.value >= 0) {
selectOption(options[focusIndex.value])
}
} else if (e.key === 'Escape') {
isOpen.value = false
}
}
// 在 dropdown 打开时监听
onMounted(() => {
window.addEventListener('keydown', handleKeyDown)
})
第三,动态加载的防抖和 loading 状态。 如果选项从接口拉取(比如用户输入关键词搜索),一定要加防抖!否则每敲一个字就发一次请求,服务器直接炸。我用的是 lodash 的 debounce,但更简单的方案是用 setTimeout 手写:
let searchTimer = null
const handleSearch = (query) => {
clearTimeout(searchTimer)
searchTimer = setTimeout(async () => {
loading.value = true
try {
const res = await fetch(https://jztheme.com/api/options?q=${query})
options.value = await res.json()
} finally {
loading.value = false
}
}, 300)
}
这个场景最好用:虚拟滚动
当选项超过 100 项,直接渲染 DOM 会卡死。这时候必须上虚拟滚动。我试过两种方案:
- 用现成的
vue-virtual-scroll-list,但和下拉框的定位冲突,折腾半天没搞定 - 自己算 visible range,只渲染可视区域内的项
最后选了第二种,虽然代码多点,但可控。核心逻辑是:监听滚动事件,计算当前 scrollTop,然后 slice 出需要显示的 10~20 条数据。关键代码:
const visibleCount = 10 // 可视区域显示10条
const itemHeight = 36 // 每项高度
const totalHeight = options.value.length * itemHeight
const getVisibleRange = (scrollTop) => {
const start = Math.floor(scrollTop / itemHeight)
const end = Math.min(start + visibleCount, options.value.length)
return { start, end }
}
// 在 template 里用 transform 模拟滚动
// <div :style="{ height: totalHeight + 'px' }">
// <div
// :style="{ transform: translateY(${start * itemHeight}px) }"
// class="absolute top-0 left-0 w-full"
// >
// <!-- 渲染 visibleOptions -->
// </div>
// </div>
这个方案在 5000 条数据下依然流畅,亲测有效。不过要注意:itemHeight 必须固定,否则计算会乱。
多选怎么搞?别被 UI 带偏了
多选的难点不在 UI,而在状态管理。我见过有人把已选项存在一个数组里,每次渲染都遍历整个 options 数组去比对是否选中——数据量大时性能爆炸。
正确做法是:用 Set 或 Map 存储已选项,O(1) 查找。比如:
const selectedSet = ref(new Set())
const toggleSelect = (value) => {
if (selectedSet.value.has(value)) {
selectedSet.value.delete(value)
} else {
selectedSet.value.add(value)
}
}
// 渲染时直接判断
// <li :class="{ 'bg-blue-100': selectedSet.has(option.value) }">
另外,多选的标签展示也有讲究。如果选项太多,全部显示会撑爆容器。我的处理是:最多显示 3 个标签,超出部分用 “+2” 代替。用户 hover 时再 tooltip 显示完整列表。
最后唠叨两句
自己写 Select 看似简单,实则细节地狱。我上面写的方案也不是完美的——比如还没处理 i18n、还没做完整的 ARIA 标签支持。但在大多数业务场景下,够用、稳定、轻量。
如果你项目允许,其实直接用成熟组件库(如 Ant Design Vue、Naive UI)更省心。但当你需要深度定制,或者想压榨性能时,手写是唯一选择。记住:别追求 100% 完美,先解决核心问题,再逐步优化。
以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如级联选择、分组选项、异步加载子选项),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流!

暂无评论