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

程序员玉淇 组件 阅读 1,929
赞 21 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接了个后台管理系统的活,需求里有个“地区选择器”,要支持省市区三级联动。一开始我想直接用 Ant Design 的 Select,但产品经理说:“用户反馈下拉太卡,而且选项太多时根本找不到。” 我一想也是,全国 3000 多个区县,全塞进一个下拉框,光渲染就可能卡死。

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

后来我决定自己写一个轻量级的 Select 组件,核心诉求就三点:支持虚拟滚动、能模糊搜索、还得能自定义选项样式。技术栈是 React + TypeScript,没打算用第三方 UI 库(因为项目整体风格偏定制化),所以得从零搞起。

最大的坑:性能问题

刚开始偷懒,直接用原生 select 元素包装了一下,结果数据一多,页面直接卡成幻灯片。后来换成 div 模拟下拉,把所有选项都 render 出来——更糟,浏览器内存直接飙到 1G。折腾了半天才发现,问题出在没做虚拟滚动。

我试过用 react-window,但发现它和下拉菜单的定位逻辑有点冲突(比如菜单高度动态变化时,滚动容器计算不准)。最后还是自己撸了一个简易版的虚拟滚动:只渲染可视区域内的选项,加上缓冲区。关键点在于监听滚动事件,动态计算当前应该显示哪些项。

这里注意我踩过好几次坑:滚动条高度算错、选项高度不一致导致错位、快速滚动时白屏…… 最后妥协方案是固定每个选项高度为 36px,虽然牺牲了灵活性,但稳了。

核心代码就这几行

下面是我最终用的虚拟滚动核心逻辑(简化版):

const VirtualList = ({ options, visibleCount = 10, itemHeight = 36 }) => {
  const containerRef = useRef(null);
  const [scrollTop, setScrollTop] = useState(0);

  const handleScroll = (e) => {
    setScrollTop(e.target.scrollTop);
  };

  const totalHeight = options.length * itemHeight;
  const startIndex = Math.floor(scrollTop / itemHeight) - 2;
  const endIndex = startIndex + visibleCount + 4;

  const visibleOptions = options.slice(
    Math.max(0, startIndex),
    Math.min(options.length, endIndex)
  );

  const offsetTop = Math.max(0, startIndex * itemHeight);

  return (
    <div
      ref={containerRef}
      onScroll={handleScroll}
      style={{ height: visibleCount * itemHeight, overflowY: 'auto' }}
    >
      <div style={{ height: totalHeight, position: 'relative' }}>
        <div style={{ position: 'absolute', top: offsetTop, width: '100%' }}>
          {visibleOptions.map((option, index) => (
            <div
              key={option.value}
              style={{ height: itemHeight, lineHeight: ${itemHeight}px }}
            >
              {option.label}
            </div>
          ))}
        </div>
      </div>
    </div>
  );
};

配合搜索功能,我加了个 debounce,避免频繁请求:

const [searchValue, setSearchValue] = useState('');
const debouncedSearch = useMemo(
  () => debounce((value) => fetchOptions(value), 300),
  []
);

useEffect(() => {
  if (searchValue.trim()) {
    debouncedSearch(searchValue);
  } else {
    // 加载默认选项
    loadDefaultOptions();
  }
}, [searchValue]);

另一个头疼的问题:键盘导航

产品经理突然提了个需求:“要用键盘上下键选择,回车确认。” 我当时就懵了——之前完全没考虑无障碍访问。赶紧补上键盘事件监听。

难点在于焦点管理和高亮同步。我给每个选项加了 tabIndex="-1",然后用状态记录当前高亮的索引。上下键时更新索引,同时滚动到对应位置(避免高亮项被滚出视口):

const handleKeyDown = (e) => {
  if (e.key === 'ArrowDown') {
    e.preventDefault();
    setActiveIndex((prev) => Math.min(prev + 1, filteredOptions.length - 1));
  } else if (e.key === 'ArrowUp') {
    e.preventDefault();
    setActiveIndex((prev) => Math.max(prev - 1, 0));
  } else if (e.key === 'Enter') {
    e.preventDefault();
    if (activeIndex >= 0) {
      onSelect(filteredOptions[activeIndex]);
    }
  }
};

// 滚动到高亮项
useEffect(() => {
  if (listRef.current && activeIndex >= 0) {
    const itemTop = activeIndex * itemHeight;
    const container = listRef.current;
    const scrollTop = container.scrollTop;
    const containerHeight = container.clientHeight;
    if (itemTop < scrollTop || itemTop > scrollTop + containerHeight) {
      container.scrollTop = itemTop - containerHeight / 2;
    }
  }
}, [activeIndex]);

这部分调了快一天,主要是不同浏览器对 scrollTop 的行为有细微差异,最后加了点容错判断才稳住。

最终的解决方案

整合下来,整个 Select 组件结构大概是这样:

  • 外层 input 触发下拉
  • 下拉面板用 Portal 渲染到 body,避免父级 overflow:hidden 被裁剪
  • 选项列表用上面的 VirtualList 实现
  • 支持异步搜索(调用 https://jztheme.com/api/regions?q=xxx 这类接口)
  • 键盘导航 + 点击选择双模式

为了兼容移动端,我还加了 touch 事件处理,不过因为项目主要是桌面端,这部分测试不够充分,偶尔在 iOS Safari 上有点小问题(比如点击穿透),但客户说“能用就行”,我就没深究。

回顾与反思

这个组件上线后,用户反馈确实比之前流畅多了,尤其在低配电脑上。我自己也觉得挺满意,毕竟没引入额外依赖,体积小,还能灵活定制样式。

但有几个地方其实还能优化:

  • 选项高度现在是固定的,如果未来需要支持富文本(比如带图标+描述),得重构虚拟滚动逻辑
  • 搜索时如果网络慢,loading 状态提示不够明显,用户容易以为没反应
  • 键盘导航在选项特别多时,滚动定位还是有点跳,可以加个平滑滚动

不过考虑到项目 deadline,这些都留着以后再说吧。反正目前跑着没问题,客户也验收了。

以上是我个人对这个 Select 组件的完整实战总结,核心就是:**别一次性渲染所有选项,虚拟滚动是救命稻草;键盘导航别等 QA 提出来再做,早点加上少返工**。有更优的实现方式欢迎评论区交流,比如怎么优雅地处理动态高度的虚拟列表?

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

暂无评论