Select选择器的实现原理与前端优化实战经验

皇甫美丽 组件 阅读 2,269
赞 16 收藏
二维码
手机扫码查看
反馈

先看效果,再看代码

做前端这些年,Select 组件看似简单,但真要写得顺手、兼容性好、还能支持搜索和异步加载,其实挺折腾的。我一开始也直接用原生 <select>,结果产品说“能不能加个搜索”“能不能支持多选”“能不能懒加载”,行吧,原生方案直接出局。

Select选择器的实现原理与前端优化实战经验

现在我基本都用 Ant Design 的 Select(React 项目)或者 Element Plus 的 ElSelect(Vue 项目),但有些轻量级场景,自己封装一个反而更灵活。下面这段代码是我最近在某个内部工具里写的,亲测有效,支持远程搜索、防抖、loading 状态,还带点小优化:

import { useState, useEffect, useMemo } from 'react';

function RemoteSearchSelect({ value, onChange, placeholder = '请选择' }) {
  const [options, setOptions] = useState([]);
  const [loading, setLoading] = useState(false);
  const [searchText, setSearchText] = useState('');

  const fetchData = async (keyword) => {
    setLoading(true);
    try {
      const res = await fetch(https://jztheme.com/api/users?keyword=${encodeURIComponent(keyword)});
      const data = await res.json();
      setOptions(data.map(item => ({ label: item.name, value: item.id })));
    } catch (err) {
      console.error('加载失败', err);
      setOptions([]);
    } finally {
      setLoading(false);
    }
  };

  // 防抖处理
  useEffect(() => {
    const timer = setTimeout(() => {
      if (searchText.trim() !== '') {
        fetchData(searchText);
      } else {
        setOptions([]);
      }
    }, 300);
    return () => clearTimeout(timer);
  }, [searchText]);

  const handleSearch = (text) => {
    setSearchText(text);
  };

  return (
    <Select
      showSearch
      value={value}
      placeholder={placeholder}
      onSearch={handleSearch}
      onChange={onChange}
      loading={loading}
      options={options}
      filterOption={false} // 关键!禁用默认过滤
      notFoundContent={loading ? '加载中...' : '无匹配数据'}
    />
  );
}

这里注意下,filterOption={false} 这一行很重要。如果不关掉,AntD 会用你本地 options 做二次过滤,而你 options 是空的(初始状态),就会搜不到东西。我踩过好几次坑,调试半天才发现是这个配置的问题。

这个场景最好用:动态联动 Select

比如省市区三级联动,或者“先选分类再选子项”这种需求。很多人一上来就搞三个 Select 嵌套,状态管理乱成一锅粥。我的做法是:用一个自定义 Hook 把逻辑抽出来,主组件只管渲染。

// useCascaderSelect.js
import { useState, useCallback } from 'react';

export const useCascaderSelect = (fetchLevel) => {
  const [level1, setLevel1] = useState([]);
  const [level2, setLevel2] = useState([]);
  const [selected1, setSelected1] = useState(null);
  const [selected2, setSelected2] = useState(null);

  const loadLevel1 = useCallback(async () => {
    const data = await fetchLevel(1);
    setLevel1(data);
  }, [fetchLevel]);

  const handleLevel1Change = useCallback(async (val) => {
    setSelected1(val);
    setSelected2(null);
    const data = await fetchLevel(2, val);
    setLevel2(data);
  }, [fetchLevel]);

  return {
    level1,
    level2,
    selected1,
    selected2,
    loadLevel1,
    handleLevel1Change,
    setSelected2,
  };
};

然后在组件里:

function CategorySelector() {
  const { 
    level1, level2, selected1, selected2,
    loadLevel1, handleLevel1Change, setSelected2 
  } = useCascaderSelect(async (level, parentId) => {
    const params = parentId ? ?parentId=${parentId} : '';
    const res = await fetch(https://jztheme.com/api/categories${params});
    return res.json();
  });

  useEffect(() => {
    loadLevel1();
  }, [loadLevel1]);

  return (
    <div>
      <Select
        value={selected1}
        options={level1}
        onChange={handleLevel1Change}
        placeholder="请选择一级分类"
      />
      <Select
        value={selected2}
        options={level2}
        onChange={setSelected2}
        placeholder="请选择二级分类"
        disabled={!selected1}
      />
    </div>
  );
}

这样拆开之后,逻辑清晰多了,测试也好写。而且如果以后要加第三级,直接在 Hook 里扩展就行,不用动 UI 组件。

踩坑提醒:这三点一定注意

  • 虚拟滚动别乱开:如果你选项超过 1000 条,确实该上虚拟滚动(比如 AntD 的 virtual 属性)。但要注意,开启后某些样式会失效,比如你自定义的 option 样式可能被覆盖。我上次为了加个图标,折腾了两小时才发现是虚拟滚动的容器限制了子元素高度。
  • 受控组件的 value 类型要一致:比如你后端返回的是数字 ID,但 Select 的 value 你传了字符串,那选中状态就对不上。这种情况我建议统一转成字符串处理,或者用严格相等判断。别问我怎么知道的,反正控制台没报错,UI 就是不显示选中,查到怀疑人生。
  • 移动端点击穿透问题:在某些低端安卓机上,Select 弹出层关闭后,底下的按钮会被误触发。解决方案是在弹层关闭时加个 setTimeout 延迟 300ms 再允许交互,或者用 CSS 的 pointer-events: none 临时禁用。虽然有点 hack,但有效。

高级技巧:自定义 Option 渲染

有时候产品要展示更多信息,比如用户头像+名字+部门。这时候就得用自定义 Option。AntD 和 Element 都支持,但写法略有不同。以 AntD 为例:

const CustomOption = ({ user }) => (
  <div style={{ display: 'flex', alignItems: 'center', padding: '5px 12px' }}>
    <img src={user.avatar} alt="" width="24" height="24" style={{ marginRight: 8 }} />
    <div>
      <div>{user.name}</div>
      <div style={{ fontSize: 12, color: '#999' }}>{user.department}</div>
    </div>
  </div>
);

// 在 Select 中使用
<Select
  options={users.map(u => ({
    label: <CustomOption user={u} />,
    value: u.id,
    // 注意:搜索时还是要提供纯文本
    title: ${u.name} - ${u.department}
  }))}
  showSearch
  filterOption={(input, option) =>
    option.title.toLowerCase().includes(input.toLowerCase())
  }
/>

关键点在于:自定义 label 用于展示,但搜索还得靠 title 或者其他纯文本字段。否则你搜“技术部”,根本搜不到,因为 React 元素没法被 indexOf 匹配。

最后说两句

Select 看似基础,但细节特别多。从性能优化到交互体验,再到各种边界情况(比如空数据、加载失败、键盘导航),每个点都能挖出坑来。我上面给的方案也不是完美的——比如远程搜索那个,其实应该加个缓存避免重复请求,但项目赶进度就先这么用了,反正用户搜同一个词的概率不高。

以上是我踩坑后的总结,希望对你有帮助。这个技巧的拓展用法还有很多(比如结合 Tag 实现标签选择、拖拽排序等),后续会继续分享这类博客。有更优的实现方式欢迎评论区交流,一起少走弯路。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
闲人文仙
写得太到位了,值得反复品读。
点赞 1
2026-02-28 11:25