Dropdown下拉菜单的实现原理与前端交互优化实践
项目初期的技术选型
上个月接手一个后台管理系统的重构,其中一个模块需要大量使用下拉菜单(Dropdown)。一开始觉得这不就是个基础组件嘛,随便找个 UI 库套一下就行。但产品提了几个需求让我有点头疼:支持动态加载选项、键盘导航、自动定位防止遮挡、还要能嵌套在表格里滚动时不乱跑。我试了几个现成的库,有的太重,有的定制性差,最后决定自己撸一个轻量级的 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 用在项目里快一个月了,整体表现还不错。键盘导航和滚动定位的问题基本解决了,动态加载也稳住了。但有两个小问题一直没动:
- 移动端 touch 事件没特别处理,偶尔会出现点击穿透(不过我们后台系统主要在桌面端用)
- 当页面 zoom 不是 100% 时,位置计算会有轻微偏移(但影响不大,用户几乎注意不到)
其实还有更优的方案,比如用 IntersectionObserver 监听容器可见性变化来触发重定位,或者用 Popper.js 这种成熟库。但考虑到项目工期和 bundle size,自己写的这个 200 行左右的 hook 已经够用了。
最大的收获是:别小看 Dropdown 这种“简单”组件,真要兼顾体验和性能,细节多到爆炸。下次再做类似需求,我可能会直接上 Radix UI 的 primitive,省心。
以上是我踩坑后的总结,希望对你有帮助。有更优的实现方式欢迎评论区交流,比如你们怎么处理滚动定位的?

暂无评论