Select选择器组件开发中的坑与优化实践

慕容雅涵 组件 阅读 2,719
赞 29 收藏
二维码
手机扫码查看
反馈

先上代码,能跑再说

我写 Select 组件从来不是从“什么是下拉框”开始的,而是直接撸代码。项目 deadline 压着,哪有时间看文档?下面这个基础版是我亲测有效、在多个项目里复用过的:

Select选择器组件开发中的坑与优化实践

<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 组件的一些实战总结,肯定还有没覆盖到的场景(比如移动端适配、触屏长按、国际化等),但核心思路就这些:先跑起来,再补细节,重点防坑。

这个组件的拓展玩法其实很多,比如结合虚拟滚动处理上万条数据、做成可拖拽排序的多选、甚至嵌入富文本编辑器里当格式选择器。后续我会继续分享这类实战技巧。

以上是我踩坑后的总结,希望对你有帮助。如果你有更好的实现方式,欢迎评论区交流!

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论