首页
/ 在 Ant Design 侧边栏导航中实现「只展开当前父级菜单」:受控 openKeys 与层级剪枝算法实战

在 Ant Design 侧边栏导航中实现「只展开当前父级菜单」:受控 openKeys 与层级剪枝算法实战

2026-09-07 18:34:38作者:劳婵绚Shirley

本文以 Ant Design 官方 Menu 示例 sider-current.tsx 为蓝本,深入讲解如何让 inline 模式下多级侧边菜单"点击一个父级、自动收起其他父级",也就是业界常说的手风琴(Accordion)互斥展开交互。读完你将掌握 openKeys/onOpenChange 受控用法、基于菜单层级做 key 剪枝的核心算法,并能直接把它落地到 Layout.Sider + Menu 的真实导航布局中。

一、需求背景:多级菜单打开太多会"失控"

Ant Design 的 Menu 组件在 mode="inline"(内嵌模式)下,天然支持多级子菜单展开。默认交互是各父级彼此独立:用户点开"A"父级后,再点开"B"父级,A、B 以及它们各自更深层的子菜单会同时保持展开状态。

对于层级多、菜单项多的后台系统侧边栏,这种"越点越乱"的堆叠会让导航变得冗长、难以聚焦。为此,官方提供了一个经典做法——点击新菜单时,把其他展开的菜单全部收起,仅保留当前操作路径,官方将其命名为 Open current submenu only(只展开当前父级菜单),其说明与代码分别位于:

在 Menu 主文档中,它被收录于示例列表(见 index.en-US.mdindex.zh-CN.md),是最常被引用的侧边菜单交互范例之一。其快照还被固定在组件测试中(components/menu/__tests__/__snapshots__/demo.test.tsx.snap),保证示例输出稳定可靠。

二、开箱即用的完整示例

下面的代码即官方示例的完整实现。它构造了三层嵌套的菜单结构,并通过"受控 openKeys + 自定义 onOpenChange"实现了互斥展开:

import React, { useState } from 'react';
import { AppstoreOutlined, MailOutlined, SettingOutlined } from '@ant-design/icons';
import type { MenuProps } from 'antd';
import { Menu } from 'antd';

type MenuItem = Required<MenuProps>['items'][number];

const items: MenuItem[] = [
  {
    key: '1',
    icon: <MailOutlined />,
    label: 'Navigation One',
    children: [
      { key: '11', label: 'Option 1' },
      { key: '12', label: 'Option 2' },
      { key: '13', label: 'Option 3' },
      { key: '14', label: 'Option 4' },
    ],
  },
  {
    key: '2',
    icon: <AppstoreOutlined />,
    label: 'Navigation Two',
    children: [
      { key: '21', label: 'Option 1' },
      { key: '22', label: 'Option 2' },
      {
        key: '23',
        label: 'Submenu',
        children: [
          { key: '231', label: 'Option 1' },
          { key: '232', label: 'Option 2' },
          { key: '233', label: 'Option 3' },
        ],
      },
      {
        key: '24',
        label: 'Submenu 2',
        children: [
          { key: '241', label: 'Option 1' },
          { key: '242', label: 'Option 2' },
          { key: '243', label: 'Option 3' },
        ],
      },
    ],
  },
  {
    key: '3',
    icon: <SettingOutlined />,
    label: 'Navigation Three',
    children: [
      { key: '31', label: 'Option 1' },
      { key: '32', label: 'Option 2' },
      { key: '33', label: 'Option 3' },
      { key: '34', label: 'Option 4' },
    ],
  },
];

interface LevelKeysProps {
  key?: string;
  children?: LevelKeysProps[];
}

const getLevelKeys = (items1: LevelKeysProps[]) => {
  const key: Record<string, number> = {};
  const func = (items2: LevelKeysProps[], level = 1) => {
    items2.forEach((item) => {
      if (item.key) {
        key[item.key] = level;
      }
      if (item.children) {
        func(item.children, level + 1);
      }
    });
  };
  func(items1);
  return key;
};

const levelKeys = getLevelKeys(items as LevelKeysProps[]);

const App: React.FC = () => {
  const [stateOpenKeys, setStateOpenKeys] = useState(['2', '23']);

  const onOpenChange: MenuProps['onOpenChange'] = (openKeys) => {
    const currentOpenKey = openKeys.find((key) => !stateOpenKeys.includes(key));
    // open
    if (currentOpenKey !== undefined) {
      const repeatIndex = openKeys
        .filter((key) => key !== currentOpenKey)
        .findIndex((key) => levelKeys[key] === levelKeys[currentOpenKey]);

      setStateOpenKeys(
        openKeys
          // remove repeat key
          .filter((_, index) => index !== repeatIndex)
          // remove current level all child
          .filter((key) => levelKeys[key] <= levelKeys[currentOpenKey]),
      );
    } else {
      // close
      setStateOpenKeys(openKeys);
    }
  };

  return (
    <Menu
      mode="inline"
      defaultSelectedKeys={['231']}
      openKeys={stateOpenKeys}
      onOpenChange={onOpenChange}
      style={{ width: 256 }}
      items={items}
    />
  );
};

export default App;

Menu 组件 API 可以看到,这个示例一共依赖四个关键入口:

属性 作用 在示例中的用法
mode 菜单形态,取 vertical / horizontal / inline 固定为 "inline"
items ItemType[] 描述菜单项内容(4.20.0 起推荐) 定义三级嵌套结构
openKeys 受控的当前展开子菜单 key 数组 stateOpenKeys 驱动
onOpenChange 子菜单展开/收起时的回调,参数为新 openKeys 执行层级剪枝算法
defaultSelectedKeys 默认选中的菜单项 key ['231'],三级叶子项

三、逐段拆解:互斥展开算法是如何工作的

3.1 第一步:预计算每个 key 的层级深度

const getLevelKeys = (items1: LevelKeysProps[]) => {
  const key: Record<string, number> = {};
  const func = (items2: LevelKeysProps[], level = 1) => {
    items2.forEach((item) => {
      if (item.key) {
        key[item.key] = level;
      }
      if (item.children) {
        func(item.children, level + 1);
      }
    });
  };
  func(items1);
  return key;
};

getLevelKeys 用一次深度优先遍历(DFS),把"key → 所在层级深度"存入一张哈希表。在本示例的数据结构下,结果近似为:

1 → 1, 2 → 1, 3 → 1
11/12/13/14 → 2
21/22/24 → 2, 23 → 2
231/232/233 → 3, 241/242/243 → 3

levelKeys 提取到组件外(模块顶层)计算,是因为它对同一次 items 定义而言是稳定不变的,无需每次渲染重新遍历。这里的层级深度就是后续"剪枝"的依据。

3.2 第二步:受控展开状态

const [stateOpenKeys, setStateOpenKeys] = useState(['2', '23']);

初始状态下,父级 2 及其子父级 23 保持展开,从而让默认选中的 231 在首屏即可见。这正是内嵌侧边导航常见的需求:进入页面时,当前路由所在路径上的每一层父级都要"点亮"

注意此时不能再用 defaultOpenKeys(非受控),因为 openKeys 已接管展开状态;若混用会导致状态源冲突。相关 props 定义可参见 Menu 主文档 API 表

3.3 第三步:onOpenChange 中的"展开 / 收起"判定

onOpenChange 每次触发都会拿到最新的 openKeys 数组。示例通过对比新旧状态来分辨本次动作是"展开"还是"收起":

const currentOpenKey = openKeys.find((key) => !stateOpenKeys.includes(key));
  • 新 openKeys 中存在旧状态里没有的 key,说明用户刚刚展开了一个子菜单(currentOpenKey !== undefined),进入互斥剪枝分支;
  • 找不到新增 key,说明用户执行的是收起操作,此时 Ant Design 已把目标 key 从 openKeys 中移除,直接原样同步即可:
} else {
  // close
  setStateOpenKeys(openKeys);
}

3.4 第四步:展开时的"同层互斥 + 深层收拢"(核心剪枝)

const repeatIndex = openKeys
  .filter((key) => key !== currentOpenKey)
  .findIndex((key) => levelKeys[key] === levelKeys[currentOpenKey]);

setStateOpenKeys(
  openKeys
    // remove repeat key
    .filter((_, index) => index !== repeatIndex)
    // remove current level all child
    .filter((key) => levelKeys[key] <= levelKeys[currentOpenKey]),
);

这段逻辑分两层含义:

  1. 同层互斥(remove repeat key):排除掉刚打开的 currentOpenKey 本身后,在剩余 key 中寻找与它处于同一层级的兄弟子菜单,找到就将其从展开列表剔除。效果即官方说明中的"收起其他展开的所有菜单"(原文档 sider-current.md)。
  2. 深层收拢(remove current level all child):把展开列表过滤为 levelKeys[key] <= levelKeys[currentOpenKey],即只保留不深于当前展开层级的祖先路径,任何"更深"的遗留子层级一律移除。

举个具体例子:假设当前展开 ['1', '11'],用户点开同层兄弟父级 2

  • 新旧对比发现新增 key 为 2(层级 1);
  • openKeys 此刻为 ['1', '11', '2'],过滤掉 2 后,同级(层级 1)命中 1repeatIndex 指向它;
  • 第一层 filter 移除 1,得到 ['11', '2']
  • 第二层 filter 保留 levelKeys[key] <= 1 的项,11(层级 2)被剔除,最终得到 ['2']

于是用户看到的视觉效果就是:旧的 Navigation One 连同其子项整体收起,只有新的 Navigation Two 保持展开——"保持菜单聚焦简洁"。

四、为什么是"受控 openKeys"?—— 从源码看 Menu 的展开机制

要理解这套方案的必然性,需要先明白 Menu 组件内部对展开状态的处理。antd 的 Menu 是对 @rc-component/menu 的封装(见 menu.tsx),组件本身提供了两套开关:

  • 非受控defaultOpenKeys 指定初始展开项,之后由组件内部自行维护展开状态;
  • 受控openKeys 完全由业务方驱动,配合 onOpenChange(openKeys: string[]) => void)把每次变化交还业务方。

components/menu/interface.ts 与 Menu 主文档中可以确认,antd 的 MenuProps 完整透传了 rc-menu 的 openKeys / onOpenChange / triggerSubMenuAction 等能力。

"手风琴互斥"本质上是一种超出默认行为的状态裁剪策略:默认行为是"只增不减",而我们的目标是"每层至多展开一个 + 关闭更深的遗留项"。这必须截获每一次展开事件并对 openKeys 做改写,因此只能通过受控模式实现,这正是示例把 openKeys 绑定到 useState 的原因。

另外,inline 模式下子菜单由点击标题(triggerSubMenuAction 的默认值在非 inline 场景为 hover)触发;若切换到 horizontal/vertical 弹出式布局,这套互斥裁剪是否适用还需要结合鼠标悬停行为另行评估,本文示例的场景限定在侧边栏常见的 inline 模式。

五、与 Layout.Sider 组合:真正的"侧边栏"导航

示例标题中的 "Sider" 指 antd 的 Layout.Sider 侧边布局。二者之间存在官方的联动约定:Sider 提供 siderCollapsed 上下文(见 Sider.tsx),而 menu.tsx 中通过 const mergedInlineCollapsed = inlineCollapsed ?? siderCollapsed; 将 Sider 的折叠状态自动透传给内嵌 Menu。

也就是说,把 <Menu mode="inline" ... /> 放进 <Layout.Sider> 后:

  • 侧边栏折叠为图标条时,Menu 会自动进入 inlineCollapsed 视觉态(收窄至图标、悬停弹出子菜单);
  • 此时不必手工向 Menu 传 inlineCollapsed,Sider 的收起状态即可驱动。

因此本示例的"互斥展开"逻辑可以无缝迁移到真实后台框架中,典型整合形态如下:

<Layout>
  <Layout.Sider collapsible>
    <Menu
      mode="inline"
      theme="dark"
      items={items}
      openKeys={stateOpenKeys}
      onOpenChange={onOpenChange}
      defaultSelectedKeys={['231']}
      style={{ height: '100%', borderRight: 0 }}
    />
  </Layout.Sider>
  <Layout.Content>{/* 页面内容 */}</Layout.Content>
</Layout>

搭配展开裁剪后,即使菜单有几十个一级、二级项,任意时刻可视范围内也只会保留一条"当前路径",这正是后台系统中对"聚焦简洁"(原文档 en-US 描述 keep the entire menu compact)最常见的诉求。

六、注意事项与常见坑位

  1. key 必须唯一levelKeys 依赖 items 中的 key 做唯一寻址,key 重复会直接导致层级表错乱,filter 裁剪结果将不可预期。
  2. 剪枝范围要覆盖真实的层级数getLevelKeys 是通用递归实现,天然支持任意深度;若你自定义了更复杂的 items(例如含 type: 'group'type: 'divider',见 ItemType 定义),请确认这些节点不携带参与展开的 key,或自行过滤,避免被误计入 openKeys。
  3. 受控与初始选中要配合:为了刷新页面后当前菜单路径仍可见,既要给 defaultSelectedKeys/selectedKeys 指定叶子 key,也要让 stateOpenKeys 的初值覆盖该叶子项的完整祖先链(示例里 '2' → '23' → '231' 三层的展开/选中是相互呼应的)。
  4. 不要在受控后继续依赖 defaultOpenKeys:一旦传入 openKeys,展开态就完全由你掌控,后续"路由变化时自动展开对应父级"这类需求,也应当通过 useEffect 更新 stateOpenKeys 实现,保持单一状态源。
  5. 算法分支对"纯收起"直接放行else { setStateOpenKeys(openKeys); } 表明收起操作不需要额外裁剪——因为收起的 kkey 已被 rc-menu 从新 openKeys 中移除,直接把受控状态对齐即可,这也是官方实现选择"找新增 key 分流"的原因。

七、小结

本篇文章给出的是一份可直接复制运行的"互斥展开"侧边菜单方案:先递归构建 levelKeys 层级表,再通过受控 openKeys + onOpenChange 拦截每次展开事件,执行"同层去重 + 深层收拢"两次过滤,最终配合 Layout.Sider 落地为紧凑、聚焦的后台导航。

这一模式本质上是"把组件不可见的内部状态机,用业务可感知的数据结构(key + 层级)显式接管",理解了它,你也就掌握了 Menu 绝大多数高级交互(如路由联动、面包屑反推展开路径)的实现钥匙。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.13 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
897
5.8 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
529
593
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
915
1.83 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.58 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.35 K
1.46 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.01 K
515
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
547
388