在 Ant Design 侧边栏导航中实现「只展开当前父级菜单」:受控 openKeys 与层级剪枝算法实战
本文以 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(只展开当前父级菜单),其说明与代码分别位于:
- 说明文档:sider-current.md
- 完整示例代码:sider-current.tsx
在 Menu 主文档中,它被收录于示例列表(见 index.en-US.md 与 index.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]),
);
这段逻辑分两层含义:
- 同层互斥(remove repeat key):排除掉刚打开的
currentOpenKey本身后,在剩余 key 中寻找与它处于同一层级的兄弟子菜单,找到就将其从展开列表剔除。效果即官方说明中的"收起其他展开的所有菜单"(原文档 sider-current.md)。 - 深层收拢(remove current level all child):把展开列表过滤为
levelKeys[key] <= levelKeys[currentOpenKey],即只保留不深于当前展开层级的祖先路径,任何"更深"的遗留子层级一律移除。
举个具体例子:假设当前展开 ['1', '11'],用户点开同层兄弟父级 2:
- 新旧对比发现新增 key 为
2(层级 1); openKeys此刻为['1', '11', '2'],过滤掉2后,同级(层级 1)命中1,repeatIndex指向它;- 第一层 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)最常见的诉求。
六、注意事项与常见坑位
- key 必须唯一:
levelKeys依赖items中的 key 做唯一寻址,key 重复会直接导致层级表错乱,filter裁剪结果将不可预期。 - 剪枝范围要覆盖真实的层级数:
getLevelKeys是通用递归实现,天然支持任意深度;若你自定义了更复杂的items(例如含type: 'group'或type: 'divider',见 ItemType 定义),请确认这些节点不携带参与展开的 key,或自行过滤,避免被误计入 openKeys。 - 受控与初始选中要配合:为了刷新页面后当前菜单路径仍可见,既要给
defaultSelectedKeys/selectedKeys指定叶子 key,也要让stateOpenKeys的初值覆盖该叶子项的完整祖先链(示例里'2' → '23' → '231'三层的展开/选中是相互呼应的)。 - 不要在受控后继续依赖 defaultOpenKeys:一旦传入
openKeys,展开态就完全由你掌控,后续"路由变化时自动展开对应父级"这类需求,也应当通过useEffect更新stateOpenKeys实现,保持单一状态源。 - 算法分支对"纯收起"直接放行:
else { setStateOpenKeys(openKeys); }表明收起操作不需要额外裁剪——因为收起的 kkey 已被 rc-menu 从新 openKeys 中移除,直接把受控状态对齐即可,这也是官方实现选择"找新增 key 分流"的原因。
七、小结
本篇文章给出的是一份可直接复制运行的"互斥展开"侧边菜单方案:先递归构建 levelKeys 层级表,再通过受控 openKeys + onOpenChange 拦截每次展开事件,执行"同层去重 + 深层收拢"两次过滤,最终配合 Layout.Sider 落地为紧凑、聚焦的后台导航。
- 想亲自运行体验,可对照 sider-current.tsx 与说明文档 sider-current.md;
- 想了解 Menu 其余受控/非受控能力与全部 API,可查阅 index.zh-CN.md 或 index.en-US.md;
- 若你的侧边栏还需要"收起为图标条 + 悬停弹层"能力,inline-collapsed.tsx 与上文提及的
inlineCollapsed/Sider 联动机制是自然的下一步扩展点。
这一模式本质上是"把组件不可见的内部状态机,用业务可感知的数据结构(key + 层级)显式接管",理解了它,你也就掌握了 Menu 绝大多数高级交互(如路由联动、面包屑反推展开路径)的实现钥匙。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00