You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于递归组件渲染实现菜单按钮点击禁用逻辑

多级菜单点击逻辑问题排查与修复

我正在实现一个React多级菜单,现有MenuItems组件和Dropdown组件,类型定义及菜单数据如下:

组件代码

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>
    );
}
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);
}

问题原因与解决方案

核心问题

  1. 事件冒泡:li元素绑定了onClick事件,点击子元素时事件会向上冒泡,导致父层级的closeDropdown也被触发,递归组件的状态全部被修改。
  2. 逻辑判断缺失:当前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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.15 22:05:30