如何在递归渲染的 React 多级菜单中精准控制按钮点击行为

本文介绍如何基于菜单项数据结构(而非递归组件状态)判断是否可点击,通过 `submenu` 字段识别叶子节点,在多级下拉菜单中实现“有子菜单则仅展开、无子菜单则响应点击”的精准交互逻辑。

在 React 中实现多级递归菜单时,一个常见痛点是:如何让带子菜单的项仅响应悬停展开,而叶子节点(无子菜单)才真正响应点击跳转或触发操作? 你当前的 closeDropdown 逻辑失效,根本原因在于将交互控制权交给了组件层级状态(如 depthLevel 和 dropdown),而未回归数据本质——菜单项是否具备 submenu 属性,才是决定其可点击性的唯一可靠依据。

✅ 正确思路:以数据驱动交互,而非以递归深度驱动

React 组件的递归渲染本身不改变数据语义。每个 MenuItems 实例所接收的 items: MenuItemsI 对象已天然携带全部必要信息:

export interface MenuItemsI {
  title: string;
  submenu?: Array; // ← 关键!undefined 或空数组 = 叶子节点
}

因此,判断是否可点击只需一个纯函数:

const isLeaf = (item: MenuItemsI): boolean => !item.submenu || item.submenu.length === 0;
⚠️ 注意:item.submenu?.length === 0 比单纯 !item.submenu 更健壮(兼容 submenu: [] 场景)。

✨ 重构 MenuItems:解耦点击与展开逻辑

将 onClick 行为从统一的 closeDropdown 中剥离,改为按数据分支处理:

function MenuItems({ items, depthLevel }: { items: MenuItemsI; depthLevel: number }) {
  const [dropdown, setDropdown] = useState(false);
  const ref = useRef(null);

  // ✅ 悬停控制:仅对非顶层项启用(避免移动端误触)
  const handleMouseEnter = () => {
    if (depthLevel > 0 && items.submenu?.length) {
      setDropdown(true);
    }
  };

  const handleMouseLeave = () => {
    if (depthLevel > 0) {
      setDropdown(false);
    }
  };

  // ✅ 点击控制:仅叶子节点执行业务逻辑(如跳转、触发事件)
  const handleClick = (e: React.MouseEvent) => {
    e.preventDefault(); // 阻止默认行为(尤其对 Link 内部点击)
    if (isLeaf(items)) {
      console.log("✅ 叶子节点被点击:", items.title);
      // ? 此处替换为你的真实逻辑:history.push、openModal、dispatch 等
      // alert(`跳转到 ${items.title}`);
      return;
    }
    // 非叶子节点:点击仅切换自身展开状态(顶层仍可 toggle)
    if (depthLevel === 0) {
      setDropdown(prev => !prev);
    }
  };

  return (
    
  • {items.submenu?.length ? ( <> {items.title} {dropdown ? : } ) : ( // ✅ 叶子节点:直接渲染可点击 Link(无需包裹 div) {items.title} )}
  • ); }

    ? Dropdown 组件优化建议

    当前 Dropdown 中存在两个潜在问题:

    1. depthLevel += 1 是副作用,违反 React 函数式原则;
    2. localDepthLevel 状态冗余,depthLevel + 1 可直接在 map 中计算。

    优化后:

    export default function Dropdown({ 
      submenus, 
      dropdown, 
      depthLevel 
    }: { 
      submenus: MenuItemsI[]; 
      dropdown: boolean; 
      depthLevel: number; 
    }) {
      return (
        
      0 ? styles["dropdown-submenu"] : ""} ${dropdown ? styles.show : ""}`}> {submenus.map((submenu, index) => ( ))}
    ); }

    ? 关键总结与最佳实践

    • 拒绝“状态传递陷阱”:不要在递归组件间传递 dropdown、depthLevel 等状态来控制行为,这极易导致逻辑耦合与竞态。
    • 信任你的数据结构:submenu 字段是权威来源,用 isLeaf(item) 替代 depthLevel === X 判断可点击性。
    • 分离关注点
      • onMouseEnter/Leave → 控制视觉展开(hover 效果);
      • onClick → 控制业务动作(仅叶子节点生效);
      • aria-expanded → 同步无障碍状态。
    • 移动端友好:onMouseEnter/Leave 在触摸设备上可能不触发,生产环境建议补充 onFocus/onBlur 或使用 useEffect + matchMedia 检测指针类型。

    通过回归数据本质,你的多级菜单将变得更可预测、更易测试、更易维护——无论嵌套多少层,逻辑始终清晰如一:有子菜单?→ 展开;无子菜单?→ 点击。