Select选择器组件开发中的性能优化与交互细节处理

ლ文阁 组件 阅读 695
赞 14 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

最近在重构一个后台系统,发现原来的 Select 用的是原生 <select>,样式丑得没法看,还不能自定义。老板说“能不能像 Ant Design 那样?”——行吧,那就自己撸一个。折腾了两天,踩了几个坑,今天把亲测有效的方案写下来。

Select选择器组件开发中的性能优化与交互细节处理

核心需求其实就三点:支持搜索、能多选、选项从接口动态加载。别小看这三点,组合起来坑不少。我一开始直接用现成的 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% 完美,先解决核心问题,再逐步优化。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如级联选择、分组选项、异步加载子选项),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流!

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

暂无评论