Select选择器组件开发中的坑与优化实践
先上代码,能跑再说
我写 Select 组件从来不是从“什么是下拉框”开始的,而是直接撸代码。项目 deadline 压着,哪有时间看文档?下面这个基础版是我亲测有效、在多个项目里复用过的:
<div class="select-wrapper" tabindex="0">
<div class="select-display">请选择城市</div>
<ul class="select-options" style="display: none;">
<li data-value="beijing">北京</li>
<li data-value="shanghai">上海</li>
<li data-value="shenzhen">深圳</li>
</ul>
</div>
.select-wrapper {
position: relative;
border: 1px solid #ccc;
padding: 8px 12px;
cursor: pointer;
user-select: none;
}
.select-options {
position: absolute;
top: 100%;
left: 0;
right: 0;
border: 1px solid #ccc;
background: white;
list-style: none;
margin: 0;
padding: 0;
z-index: 10;
}
.select-options li:hover {
background-color: #f0f0f0;
}
const wrapper = document.querySelector('.select-wrapper');
const display = wrapper.querySelector('.select-display');
const optionsList = wrapper.querySelector('.select-options');
wrapper.addEventListener('click', () => {
optionsList.style.display = optionsList.style.display === 'block' ? 'none' : 'block';
});
optionsList.addEventListener('click', (e) => {
if (e.target.tagName === 'LI') {
display.textContent = e.target.textContent;
optionsList.style.display = 'none';
// 这里可以触发 change 事件或回调
}
});
// 点击外部关闭
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target)) {
optionsList.style.display = 'none';
}
});
这段代码能跑,但别急着复制粘贴进生产环境——它有个致命问题:键盘操作完全没考虑。我第一次上线后被 QA 投诉说“Tab 键没法选”,折腾了半天才补上。
踩坑提醒:这三点一定注意
我写过不下五个自定义 Select,每次都会在这几个地方栽跟头:
- 焦点管理和键盘导航:用户按 Tab 聚焦到组件后,应该能用上下箭头选择选项,回车确认。很多开发者只处理鼠标,结果无障碍测试直接挂掉。
- 滚动穿透:下拉展开时,页面背景还在滚动?尤其是在移动端,手指一划整个页面动了,体验极差。解决方法是在展开时给 body 加
overflow: hidden,但记得收起时恢复,否则用户会以为页面卡了。 - 动态数据加载的时机:如果选项是从接口拉的(比如
fetch('https://jztheme.com/api/cities')),别在每次点击时都请求!我见过有人把 API 调用放在 click 回调里,结果点一次刷一次,网速慢的时候列表闪得人眼花。建议首次聚焦时加载,或者用防抖缓存。
特别说一下键盘支持。下面这段是我后来补的逻辑,虽然啰嗦但稳定:
let currentIndex = -1;
const items = Array.from(optionsList.querySelectorAll('li'));
wrapper.addEventListener('keydown', (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
currentIndex = (currentIndex + 1) % items.length;
highlightItem(currentIndex);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
currentIndex = (currentIndex - 1 + items.length) % items.length;
highlightItem(currentIndex);
} else if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (currentIndex >= 0) {
selectItem(currentIndex);
}
} else if (e.key === 'Escape') {
optionsList.style.display = 'none';
currentIndex = -1;
}
});
function highlightItem(index) {
items.forEach((item, i) => {
item.style.backgroundColor = i === index ? '#e6f7ff' : '';
});
}
function selectItem(index) {
const item = items[index];
display.textContent = item.textContent;
optionsList.style.display = 'none';
currentIndex = -1;
}
异步加载?这样处理最省事
现在很多 Select 都要支持远程搜索,比如输入关键词查用户。我试过几种方案,最后觉得最简单的做法是:把 loading 状态和空状态都内置到选项列表里。
比如,初始状态显示“点击加载”,用户点击后换成 loading 提示,数据回来后再渲染真实选项。代码结构大致这样:
<ul class="select-options" style="display: none;">
<li class="loading">加载中...</li>
</ul>
async function loadOptions() {
const loadingItem = optionsList.querySelector('.loading');
if (!loadingItem) return;
try {
const res = await fetch('https://jztheme.com/api/search?q=');
const data = await res.json();
optionsList.innerHTML = '';
data.forEach(item => {
const li = document.createElement('li');
li.dataset.value = item.id;
li.textContent = item.name;
optionsList.appendChild(li);
});
} catch (err) {
optionsList.innerHTML = '<li class="error">加载失败</li>';
}
}
wrapper.addEventListener('click', async () => {
if (optionsList.style.display !== 'block') {
// 如果还没加载过数据
if (optionsList.querySelector('.loading')) {
await loadOptions();
}
optionsList.style.display = 'block';
} else {
optionsList.style.display = 'none';
}
});
注意这里用了 await loadOptions(),确保数据加载完再展开。不然会出现“白屏一闪”的情况,用户以为没反应又点一次,结果重复请求。
要不要造轮子?我的建议
说实话,现在除非有非常定制化的需求(比如选项带树形结构、多级联动、或者要嵌入复杂表单控件),否则我建议直接用现成的 UI 库,比如 Ant Design 或 Element Plus 的 Select。它们已经处理了焦点、键盘、无障碍、虚拟滚动等细节,自己重写反而容易漏掉边缘情况。
但如果业务要求必须自研(比如设计系统不允许引入第三方),那我的经验是:先把核心交互做稳,再逐步加功能。别一上来就想支持搜索、多选、标签、分组……先保证一个静态下拉能正确打开、选择、关闭,再迭代。
另外,测试时一定要用键盘操作一遍。很多 bug 只有在不用鼠标的情况下才会暴露出来。
结尾碎碎念
以上是我这几年折腾 Select 组件的一些实战总结,肯定还有没覆盖到的场景(比如移动端适配、触屏长按、国际化等),但核心思路就这些:先跑起来,再补细节,重点防坑。
这个组件的拓展玩法其实很多,比如结合虚拟滚动处理上万条数据、做成可拖拽排序的多选、甚至嵌入富文本编辑器里当格式选择器。后续我会继续分享这类实战技巧。
以上是我踩坑后的总结,希望对你有帮助。如果你有更好的实现方式,欢迎评论区交流!

暂无评论