基于递归组件渲染实现菜单按钮点击禁用逻辑
多级菜单点击逻辑问题排查与修复
我正在实现一个React多级菜单,现有MenuItems组件和Dropdown组件,类型定义及菜单数据如下:
组件代码
MenuItems组件
export default function MenuItems({ items, depthLevel }: { items: MenuItemsI, depthLevel: number }): ReturnType<FC> { const [ dropdown, setDropdown ] = useState<boolean>(false); const ref = useRef<HTMLLIElement>(); useEffect(() => { const handler = (event: MouseEvent) => { if ( dropdown && ref.current && !ref.current.contains(event.target as Node) ) { setDropdown(false); } }; document.addEventListener('mousedown', handler); document.addEventListener('touchstart', handler); return () => { document.removeEventListener('mousedown', handler); document.removeEventListener('touchstart', handler); }; }, [dropdown]); function onMouseEnter() { if (depthLevel > 0) { setDropdown(true); } } function onMouseLeave() { if (depthLevel > 0) { setDropdown(false); } } function closeDropdown() { setDropdown(!dropdown); } return ( <li className={`${styles["menu-items"]} ${depthLevel === 0 ? styles.main : styles.sub}`} ref={ref} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave} onClick={closeDropdown} > {items.submenu ? ( <> <div aria-haspopup="menu" aria-expanded={dropdown ? 'true' : 'false'} className={`${styles["menu-item"]}`} > <span>{items.title}</span> <span className={`${styles.arrow}`} > {((depthLevel === 0 && !dropdown) || depthLevel > 0) ? ( <BiCaretRight size="20px" /> ) : ( <BiCaretDown size="20px" /> )} </span> </div> <Dropdown depthLevel={depthLevel} submenus={items.submenu} dropdown={dropdown} /> </> ) : ( <Link className={`${styles["menu-item"]}`} to="/">{items.title}</Link> )} </li> ); }
Dropdown组件
export default function Dropdown({ submenus, dropdown, depthLevel }: any): ReturnType<FC> { const [localDepthLevel, setLocalDepthLevel] = useState(depthLevel + 1); useEffect(() => { setLocalDepthLevel(depthLevel + 1); }, [depthLevel]); // eslint-disable-next-line no-param-reassign depthLevel += 1; return ( <ul className={`${styles.dropdown} ${depthLevel > 1 ? `${styles["dropdown-submenu"]}` : ``} ${ dropdown ? `${styles.show}` : '' }`} > {submenus.map((submenu: any, index: number) => ( <MenuItems items={submenu} key={index} depthLevel={localDepthLevel} /> ))} </ul> ); }
类型定义与菜单数据
export interface MenuItemsI { title: string, submenu?: Array<MenuItemsI> } export const menuItems: MenuItemsI[] = [ { title: "Rules", submenu: [ { title: "menu 1", submenu: [ { title: "submenu 1", submenu: [ { title: "subsubmenu 1" }, { title: "subsubmenu 2" } ] }, { title: "submenu 2", submenu: [ { title: "subsubmenu 3" }, { title: "subsubmenu 4" } ] } ] }, { title: "menu 2", submenu: [ { title: "submenu 3" }, { title: "submenu 4" } ] }, { title: "menu 3" } ] } ];
需求与问题
我需要实现closeDropdown()函数,满足:
- 叶子菜单项(无submenu):可点击跳转,同时关闭所有下拉菜单
- 有submenu的菜单项:点击本身不触发任何操作(不关闭/切换下拉),仅通过hover显示子菜单(hover功能已正常)
- 主菜单(depthLevel=0):点击可以切换下拉的显示/隐藏
我尝试了多种逻辑,但由于组件是递归渲染的,点击后调用栈会触发所有层级的逻辑,导致无论怎么写,点击都会触发下拉关闭。
已尝试的逻辑示例:
// 示例1 if (depthLevel === 0) { setDropdown(!dropdown); // 切换主菜单的下拉状态 } else if (depthLevel === 1 && items.submenu) { // 有子菜单的二级项,保持下拉打开 setDropdown(true); } else if (!items.submenu) { // 叶子项,关闭下拉 setDropdown(false); } // 示例2 if (depthLevel === 0) { setDropdown(!dropdown); } else if (!items.submenu) { setDropdown(false); }
问题原因与解决方案
核心问题
- 事件冒泡:
li元素绑定了onClick事件,点击子元素时事件会向上冒泡,导致父层级的closeDropdown也被触发,递归组件的状态全部被修改。 - 逻辑判断缺失:当前
closeDropdown没有针对「有submenu的非主菜单」做拦截,点击这类项时不该触发任何状态变更。
修复方案
1. 重构closeDropdown函数,添加事件参数与条件判断
修改closeDropdown,接收event参数,根据菜单项类型和层级做不同处理:
function closeDropdown(event: React.MouseEvent) { // 情况1:当前项有子菜单且不是主菜单(depthLevel>0),阻止事件冒泡并直接返回,不做任何状态变更 if (items.submenu && depthLevel > 0) { event.stopPropagation(); return; } // 情况2:主菜单(depthLevel=0),切换下拉状态 if (depthLevel === 0) { setDropdown(prev => !prev); event.stopPropagation(); return; } // 情况3:叶子菜单项(无submenu),关闭当前下拉,同时事件冒泡让父层级也关闭 setDropdown(false); }
2. 调整li的onClick绑定,优化叶子项逻辑
叶子项使用的<Link>点击后会触发跳转,同时li的onClick会触发关闭逻辑。可以保持现有绑定,利用事件冒泡让所有父层级的下拉都关闭,无需额外修改<Link>。
3. 移除Dropdown组件中不必要的状态
Dropdown里的localDepthLevel可以直接计算,不需要用useState和useEffect,简化代码:
export default function Dropdown({ submenus, dropdown, depthLevel }: any): ReturnType<FC> { const currentDepth = depthLevel + 1; return ( <ul className={`${styles.dropdown} ${currentDepth > 1 ? `${styles["dropdown-submenu"]}` : ``} ${ dropdown ? `${styles.show}` : '' }`} > {submenus.map((submenu: any, index: number) => ( <MenuItems items={submenu} key={index} depthLevel={currentDepth} /> ))} </ul> ); }
最终效果
- 主菜单点击:切换下拉显示/隐藏
- 有子菜单的非主菜单:点击无反应,hover正常显示子菜单
- 叶子菜单项:点击跳转,同时关闭所有层级的下拉菜单
内容的提问来源于stack exchange,提问作者Owen Murphy
相关产品推荐
相关产品推荐

