Dropdown下拉菜单的实现原理与前端交互优化实践

锦锦 Dev 组件 阅读 2,688
赞 23 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

上个月接手一个后台管理系统的重构,其中一个模块需要大量使用下拉菜单(Dropdown)。一开始觉得这不就是个基础组件嘛,随便找个 UI 库套一下就行。但产品提了几个需求让我有点头疼:支持动态加载选项、键盘导航、自动定位防止遮挡、还要能嵌套在表格里滚动时不乱跑。我试了几个现成的库,有的太重,有的定制性差,最后决定自己撸一个轻量级的 Dropdown。

Dropdown下拉菜单的实现原理与前端交互优化实践

技术栈是 React + TypeScript,没用 Tailwind,纯 CSS 控制样式。核心目标就两个:行为可控、性能别拖后腿。毕竟这个页面可能同时有几十个下拉框,要是每个都监听一堆事件,卡成 PPT 就不好看了。

最大的坑:滚动时定位错乱

写完基本功能测试时发现,当下拉菜单打开状态下,如果用户滚动页面或父容器,菜单位置就固定不动了,看起来像“漂”在页面上。这体验肯定不行。

开始我以为只要监听 scroll 事件重新计算位置就行,于是给每个 Dropdown 实例都加了 useEffect 监听 window 和最近的可滚动祖先:

useEffect(() => {
  const scrollableParents = getScrollableParents(triggerRef.current);
  const handlers = scrollableParents.map(parent => {
    const handler = () => reposition();
    parent.addEventListener('scroll', handler);
    return { parent, handler };
  });

  return () => {
    handlers.forEach(({ parent, handler }) => {
      parent.removeEventListener('scroll', handler);
    });
  };
}, []);

结果一测,页面稍微复杂点(比如表格里嵌套多个 Dropdown),性能直接崩了。每次滚动都触发几十次 reposition,CPU 占用飙到 80%。而且 getScrollableParents 这个函数本身也有开销,得遍历 DOM 树往上找。

折腾了半天,后来想到:其实不需要精确监听每个滚动容器,只要在菜单打开时用 requestAnimationFrame 持续检查位置变化就行。虽然不是最优雅,但简单有效,而且避免了大量事件监听器。

useEffect(() => {
  if (!isOpen) return;

  let frameId: number;
  const checkPosition = () => {
    reposition();
    frameId = requestAnimationFrame(checkPosition);
  };
  frameId = requestAnimationFrame(checkPosition);

  return () => cancelAnimationFrame(frameId);
}, [isOpen]);

实测下来,滚动流畅多了,虽然理论上每帧都算一次位置有点浪费,但实际影响微乎其微,毕竟菜单打开的时间通常很短。

键盘导航的细节折磨

产品经理说要支持键盘操作,我以为就是上下箭头切换、回车选中,结果测试时发现一堆边界情况:

  • 按 Tab 键应该关闭菜单并聚焦到下一个元素,而不是在菜单项里循环
  • Esc 要关闭菜单,但不能阻止默认的表单提交行为
  • 菜单项里如果有 input,键盘事件不能被 Dropdown 拦截

最烦的是焦点管理。Dropdown 打开时,必须把焦点锁定在菜单内部,否则用户按 Tab 会跳到页面其他地方。但关闭时又得把焦点还给 trigger 元素(比如那个按钮)。

我用了 useFocusTrap 的思路,但简化了:

useEffect(() => {
  if (!isOpen || !menuRef.current) return;

  const menu = menuRef.current;
  const firstItem = menu.querySelector('[data-focusable="true"]') as HTMLElement;
  if (firstItem) firstItem.focus();

  const handleKeyDown = (e: KeyboardEvent) => {
    if (e.key === 'Escape') {
      close();
      triggerRef.current?.focus(); // 还原焦点
      return;
    }
    if (e.key === 'Tab') {
      // 允许 Tab 跳出,不阻止默认行为
      close();
      return;
    }
    // 处理上下箭头...
  };

  document.addEventListener('keydown', handleKeyDown);
  return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen]);

这里注意我踩过好几次坑:focus() 调用必须在 DOM 渲染之后,所以放在 useEffect 里;另外 Tab 事件不能 preventDefault(),否则用户没法用键盘导航到其他控件。

动态加载与防抖优化

有个下拉框要根据用户输入动态搜索,接口是 https://jztheme.com/api/search。第一次写的时候,用户每打一个字就发请求,结果服务器被打爆了(测试环境差点挂了)。赶紧加上防抖:

const [inputValue, setInputValue] = useState('');
const [options, setOptions] = useState<Option[]>([]);
const debouncedSearch = useRef(
  debounce(async (query: string) => {
    const res = await fetch(https://jztheme.com/api/search?q=${query});
    const data = await res.json();
    setOptions(data);
  }, 300)
).current;

useEffect(() => {
  if (inputValue.trim()) {
    debouncedSearch(inputValue);
  } else {
    setOptions([]);
  }
}, [inputValue]);

防抖时间设成 300ms 比较平衡,太快了还是可能频繁请求,太慢了用户会觉得卡。另外记得在组件卸载时取消防抖,避免内存泄漏:

useEffect(() => {
  return () => {
    debouncedSearch.cancel();
  };
}, []);

最终的解决方案

综合下来,我的 Dropdown 核心逻辑就这几块:

  • 点击 trigger 切换显隐,点击外部区域关闭
  • requestAnimationFrame 持续校正位置(解决滚动问题)
  • 键盘导航 + 焦点管理
  • 动态加载 + 防抖

完整代码太长,但关键部分贴一下:

function useDropdown() {
  const [isOpen, setIsOpen] = useState(false);
  const triggerRef = useRef<HTMLButtonElement>(null);
  const menuRef = useRef<HTMLDivElement>(null);

  const open = () => setIsOpen(true);
  const close = () => setIsOpen(false);

  // 点击外部关闭
  useEffect(() => {
    if (!isOpen) return;
    const handleClick = (e: MouseEvent) => {
      if (
        triggerRef.current?.contains(e.target as Node) ||
        menuRef.current?.contains(e.target as Node)
      ) return;
      close();
    };
    document.addEventListener('click', handleClick);
    return () => document.removeEventListener('click', handleClick);
  }, [isOpen]);

  // 滚动时重定位
  useEffect(() => {
    if (!isOpen) return;
    let frameId: number;
    const check = () => {
      // 这里调用计算位置的函数
      frameId = requestAnimationFrame(check);
    };
    frameId = requestAnimationFrame(check);
    return () => cancelAnimationFrame(frameId);
  }, [isOpen]);

  return { isOpen, open, close, triggerRef, menuRef };
}

样式部分用绝对定位 + transform,避免频繁触发重排:

.dropdown-menu {
  position: absolute;
  top: 0;
  left: 0;
  transform: translate(var(--x, 0), var(--y, 0));
  will-change: transform;
}

计算位置时,通过 style.setProperty('--x', ...) 动态设置 CSS 变量,比直接改 style.left/top 性能更好。

回顾与反思

这个 Dropdown 用在项目里快一个月了,整体表现还不错。键盘导航和滚动定位的问题基本解决了,动态加载也稳住了。但有两个小问题一直没动:

  1. 移动端 touch 事件没特别处理,偶尔会出现点击穿透(不过我们后台系统主要在桌面端用)
  2. 当页面 zoom 不是 100% 时,位置计算会有轻微偏移(但影响不大,用户几乎注意不到)

其实还有更优的方案,比如用 IntersectionObserver 监听容器可见性变化来触发重定位,或者用 Popper.js 这种成熟库。但考虑到项目工期和 bundle size,自己写的这个 200 行左右的 hook 已经够用了。

最大的收获是:别小看 Dropdown 这种“简单”组件,真要兼顾体验和性能,细节多到爆炸。下次再做类似需求,我可能会直接上 Radix UI 的 primitive,省心。

以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你们怎么处理滚动定位的?

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

暂无评论