Select组件开发中那些容易被忽略的细节和优化技巧

小风珍 组件 阅读 2,290
赞 185 收藏
二维码
手机扫码查看
反馈

又踩坑了,Select下拉菜单在iOS上点不中

今天上线前测到一个诡异问题:我们自研的 Select 组件,在 iOS Safari 里点开下拉后,选项列表明明渲染出来了,但点任何一项都没反应——既不触发 onChange,也不收起弹层。安卓、Mac Chrome、Windows Edge 全都正常,就 iOS 上不行。我第一反应是“又是 touch 事件那点破事”,结果这次还真不是 touchend 延迟的问题,折腾了快俩小时才定位清楚。

Select组件开发中那些容易被忽略的细节和优化技巧

先说结论:根本原因是我们用了 pointer-events: none 在下拉遮罩层(overlay)上,本意是让点击穿透到下面的选项上,结果 iOS Safari 对 pointer-events 的处理和桌面端不一样,它会直接忽略整个区域的点击事件,哪怕你下面的 li 有 pointer-events: auto 也白搭。这里我踩了个坑:以为“父级禁用 + 子级启用”能兜住,其实 iOS 不认这套。

排查过程挺典型的:先是 console.log 看事件有没有冒泡,发现 click 根本没进 li;然后加个 touchstart 监听,也没触发;再把 overlay 的 z-index 调低、调高、删掉……还是不行;后来试了下发现,只要把 overlay 的 pointer-events: none 换成 opacity: 0 + pointer-events: auto,再给它加个 background: transparent,选项就能点了。但这样有个副作用:用户如果点 overlay 区域,会意外收起下拉(因为 overlay 本身也响应了 click)。所以最后我改成了更稳妥的做法——不用 overlay 拦截点击,改用 document 级别的 click 处理器来收起弹层,而下拉列表自己管理聚焦和点击逻辑。

核心思路就一句:别依赖 pointer-events 做点击穿透,尤其 iOS 上它就是个玄学。改成“主动监听 + 显式判断点击目标”更可靠。

最终方案:用 document click + contains 判断收起时机

我们组件结构大概是这样:

  • 外层是 <div class="select-wrapper">
  • 触发按钮是 <button class="select-trigger">
  • 下拉面板是 <div class="select-dropdown">,初始 display: none
  • 里面是 <ul class="select-options"> 和一堆 <li>

原来靠一个透明的 <div class="select-overlay"> 铺满全屏,靠它 capture 点击并关闭面板。现在删了它,改用 JS 监听 document.click。

关键代码如下(Vue 3 setup script 风格,但逻辑通用):

const dropdownRef = ref(null)
const isOpen = ref(false)

// 点击触发按钮时切换状态
const toggleDropdown = () => {
  isOpen.value = !isOpen.value
  if (isOpen.value) {
    // 延迟一帧确保 DOM 渲染完成,再 focus 第一个选项(可选)
    nextTick(() => {
      const firstOption = dropdownRef.value?.querySelector('li')
      firstOption?.focus()
    })
    // 绑定 document 点击监听
    document.addEventListener('click', handleDocumentClick, true)
  } else {
    document.removeEventListener('click', handleDocumentClick, true)
  }
}

// 判断点击是否发生在 select 组件外部
const handleDocumentClick = (e) => {
  const target = e.target
  const wrapper = document.querySelector('.select-wrapper')
  const dropdown = dropdownRef.value

  // 如果点击的是 trigger、dropdown 自身、或它们的子元素,不收起
  if (
    wrapper?.contains(target) ||
    dropdown?.contains(target)
  ) {
    return
  }

  isOpen.value = false
}

// 选项点击处理
const handleOptionClick = (value) => {
  selectedValue.value = value
  isOpen.value = false
  // 触发自定义事件(比如 emit('change', value))
  emit('change', value)
}

HTML 结构精简版:

<div class="select-wrapper">
  <button class="select-trigger" @click="toggleDropdown">
    {{ selectedLabel }}
  </button>
  
  <div 
    ref="dropdownRef" 
    class="select-dropdown" 
    v-show="isOpen"
  >
    <ul class="select-options">
      <li 
        v-for="item in options" 
        :key="item.value"
        @click="handleOptionClick(item.value)"
        tabindex="0"
        @keydown.enter="handleOptionClick(item.value)"
      >
        {{ item.label }}
      </li>
    </ul>
  </div>
</div>

CSS 就很简单,重点是 dropdown 要有明确的 position 和 z-index,避免被其他元素盖住:

.select-dropdown {
  position: absolute;
  top: 100%;
  left: 0;
  width: 100%;
  background: #fff;
  border: 1px solid #ddd;
  border-top: none;
  border-radius: 0 0 4px 4px;
  box-shadow: 0 2px 8px rgba(0,0,0,0.1);
  z-index: 1000;
  max-height: 200px;
  overflow-y: auto;
}

.select-options li {
  padding: 8px 12px;
  cursor: pointer;
  user-select: none;
}

.select-options li:hover,
.select-options li:focus {
  background-color: #f5f5f5;
}

这里注意我踩过好几次坑:一开始没加 tabindex="0",键盘用户按方向键根本没法导航;后来加了,但没处理 keydown.enter,导致回车不能选中;还有一次忘了在 v-show 切换后移除 document listener,导致多个 Select 同时存在时互相干扰。现在这个版本亲测 iOS 16/17、Android Chrome、Safari Mac、Edge 都稳。

顺带提一句,如果你用的是 React,逻辑完全一样,只是生命周期钩子里绑定/解绑 document click 即可;如果是原生 JS,就手动维护一个全局的 isDropdownOpen 状态,然后统一处理。

还有个小尾巴没完美解决:iOS 上长按某个选项偶尔会唤出系统菜单(复制/搜索),虽然不影响功能,但体验有点怪。目前是加了 -webkit-user-select: none + user-select: none 抑制,基本能压住。如果真要 100% 干净,得上 preventDefault + touchstart,但代价是可能影响滚动,权衡之下我就没动——毕竟这不是核心路径。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多,比如结合 focusin/focusout 做键盘导航优化、加防抖防止快速连点、或者对接虚拟滚动处理几千条数据……后续会继续分享这类博客。如果你有更好的方案,比如用 MutationObserver 监听 DOM 变化来动态绑定 click、或者用 IntersectionObserver 判断是否在视口内再激活,欢迎评论区交流。

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

暂无评论