- 前端
- UI组件
- 设计系统
【免费下载链接】ant-design-blazor
基于 Ant Design 与 Blazor 的前端组件库。让开发者解放生产力,实现更大价值。
本篇技术指南聚焦 ant-design-blazor 的Menu组件在**侧边内嵌导航(inline 模式)**下的一个高频交互需求:点击某个父级菜单时,自动收起其他已展开的父级菜单,始终保持界面上只有一个 SubMenu 处于展开状态。文章将以官方示例sider-current(只展开当前父级菜单)为骨架,完整讲解基于OpenKeys+OnOpenChange的受控实现方案、基于Accordion的内置手风琴方案,并结合组件源码剖析两种方案的底层行为差异与适用场景。读完你可以直接在自己的 Blazor 项目中落地这一交互,并理解其内部实现原理。
需求场景:多级侧边导航的"聚焦"交互
侧边导航通常承载网站的层级架构(例如"仪表盘 / 业务模块 / 系统设置"等多个一级菜单,每个一级菜单下又有多层子菜单)。当一级菜单较多时,如果用户可以同时展开多个 SubMenu,导航区域会变得冗长、遮挡主要内容,失去聚焦。
官方示例sider-current要解决的问题正是如此:点击菜单,收起其他展开的所有菜单,保持菜单聚焦简洁。这一交互与 Layout 组件的Sider配合使用,常用于后台管理系统的侧边导航栏,可参考 Menu 组件文档 与 通用布局 layout 的组合方式。
前置基础:内嵌菜单与展开状态管理
要实现"只展开当前父级菜单",第一步是让菜单运行在内嵌模式下。在 ant-design-blazor 中,Menu的Mode参数类型为 MenuMode 枚举,包含三个取值:
| 值 | 行为 |
|---|---|
Vertical | 垂直模式,子菜单以弹出浮层(Overlay)方式展示 |
Horizontal | 水平模式,子菜单在下方弹出,适合顶部导航 |
Inline | 内嵌模式,子菜单直接展开在菜单区域内部,适合侧边导航 |
示例中菜单声明为Mode="MenuMode.Inline",并设置了Style="width:256px ;"来模拟侧边栏的宽度。
内嵌模式下,SubMenu 的展开/收起由OpenKeys这一"受控"参数驱动。从 Menu.razor.cs 源码 可以看到:
OpenKeys(string[]):当前展开的 SubMenu 菜单项 key 数组,默认值为空数组;OnOpenChange(EventCallback<string[]>):当 SubMenu 展开/关闭时触发,回调参数为最新的 openKeys 数组;DefaultOpenKeys:仅用于初始化时展开的 key 数组,属于非受控入口(源码在OnInitialized中将其转换为OpenKeys,见 Menu.razor.cs);SelectedKeys/DefaultSelectedKeys:选中项 key 数组,与展开状态相互独立。
由于OpenKeys是受控属性,展开状态完全由外部代码持有,这为我们手动实现"同一时刻只展开一个父级菜单"提供了操作入口。
方案一:OpenKeys + OnOpenChange 手动裁剪(官方 sider-current 示例)
官方示例sider-current的核心做法是:监听展开变化,判断最新打开的 SubMenu 是否属于一级菜单,若是则只保留它一个,否则原样保留全部展开项。完整实现见 SiderCurrent.razor。
第一步:声明受控状态与监听回调
<Menu Mode="MenuMode.Inline" OpenKeys=this.openKeys OnOpenChange=this.onOpenChange Style="width:256px ;">在@code块中:
// submenu keys of first level string[] rootSubmenuKeys = {"sub1", "sub2", "sub4"}; string[] openKeys = {"sub1"}; void onOpenChange(string[] openKeys) { var latestOpenKey = openKeys.FirstOrDefault(key => !this.openKeys.Contains(key)); if (!rootSubmenuKeys.Contains(latestOpenKey)) { this.openKeys = openKeys; } else { this.openKeys = !string.IsNullOrEmpty(latestOpenKey) ? new[] {latestOpenKey} : Array.Empty<string>(); } }第二步:构造多级菜单结构
<SubMenu Key="sub1" TitleTemplate=@sub1Title> <MenuItem Key="1">Option 1</MenuItem> <MenuItem Key="2">Option 2</MenuItem> <MenuItem Key="3">Option 3</MenuItem> <MenuItem Key="4">Option 4</MenuItem> </SubMenu> <SubMenu Key="sub2" TitleTemplate=@sub2Title> <MenuItem Key="5">Option 5</MenuItem> <MenuItem Key="6">Option 6</MenuItem> <SubMenu Key="sub3" Title="Submenu"> <MenuItem Key="7">Option 7</MenuItem> <MenuItem Key="8">Option 8</MenuItem> </SubMenu> </SubMenu> <SubMenu Key="sub4" TitleTemplate=@sub4Title> <MenuItem Key="9">Option 9</MenuItem> <MenuItem Key="10">Option 10</MenuItem> <MenuItem Key="11">Option 11</MenuItem> <MenuItem Key="12">Option 12</MenuItem> </SubMenu>注意菜单结构特意设计了一个二级 SubMenu(sub2下的sub3),这正是该方案的用武之地:rootSubmenuKeys只登记sub1、sub2、sub4三个一级菜单。
第三步:理解裁剪逻辑的关键分支
回调中的latestOpenKey表示"相对上一次状态而言,本次新增展开的那个 key"。判断逻辑分为两种情况:
最新展开的不是一级菜单(例如用户点开了
sub2内部的sub3):说明用户是在已展开的sub2下做层级导航,此时不应该收起sub2及其兄弟菜单,因此this.openKeys = openKeys,直接把最新展开数组原样写回,sub1、sub2、sub4可以同时处于展开状态(为了满足"只展开当前父级"的严格语义,实践中也可在进入sub2时先收起其他一级菜单,这取决于产品交互设计)。最新展开的是一级菜单(例如从
sub1切换到sub4):执行"替换"逻辑——只保留最新的一级菜单 key,其余全部收起。new[] {latestOpenKey}即把展开数组裁剪为只剩一个元素;如果latestOpenKey为空(表示本次是收起操作而非展开),则置为空数组,允许用户手动收起所有菜单。
这里需要特别留意示例中参数与字段同名的写法:void onOpenChange(string[] openKeys)中的openKeys是方法参数,而this.openKeys才是菜单字段。理解这一区分是读懂裁剪逻辑的前提。
方案二:直接启用 Accordion 手风琴模式
如果你需要的语义就是"任何时刻最多只有一个一级 SubMenu 展开",且不要求二级及以下层级额外展开多个,ant-design-blazor 还提供了更省事的内置方案:Menu的Accordion参数。
官方示例sider-current2(配套说明见 sider-current2.md)展示了极简写法,完整代码见 SiderCurrent2.razor:
<Menu Mode="MenuMode.Inline" Accordion SelectedKeys="@(new[] { "1"})" OpenKeys="@(new[] { "sub1" })" Style="width:256px ;"> @* 菜单结构与方案一相同:sub1 / sub2(含 sub3) / sub4 *@ </Menu>与方案一的区别一目了然:
- 无需自己维护
openKeys字段,也无需编写onOpenChange回调——Accordion是组件内置行为; OpenKeys在这里只承担初始展开作用(配合SelectedKeys指定默认选中项);- 手风琴语义由组件在内部强制:每次展开一个 SubMenu 时,其余 SubMenu 全部关闭。
从 Menu.razor.cs 源码 的SelectSubmenu方法可以印证其实现原理:
if (Accordion) { foreach (SubMenu item in _submenus.Where(x => x != menu && x != menu.Parent)) { item.Close(); } }即组件在_submenus列表(由AddSubmenu注册的所有 SubMenu 集合)中,除当前点击的menu与其父级menu.Parent之外,其余一律调用Close()。这意味着手风琴行为同时作用于全部层级——这也是它与方案一在行为上的本质差异。
两种方案的行为差异与选型建议
| 对比维度 | 方案一(OpenKeys + OnOpenChange) | 方案二(Accordion) |
|---|---|---|
| 代码量 | 需维护字段与回调,代码较多 | 一个属性即可,代码最少 |
| 控制力 | 完全受控,可自定义任何展开/收起策略 | 固定为"只保留当前及其父级"策略 |
| 二级菜单行为 | 可在父级展开下同时展开多个二级 SubMenu(示例中sub3被允许与sub2并存) | 打开sub3时,除sub2(其父级)外其他 SubMenu 也会被收起 |
| 关闭能力 | 裁剪为空数组时允许全部收起 | 组件侧始终保留当前点击项展开 |
| 适用场景 | 需要精细定制(如结合路由、缓存上次展开状态、多级同时展开) | 标准后台侧边栏、交互最简单直接 |
选择建议:如果业务要求严格"同一时刻仅一个一级菜单展开",且没有多级同开需求,Accordion是最佳选择;如果需要在sub2内部同时展开多个二级子菜单、或在展开/收起时做额外业务处理(例如记录用户习惯、路由联动),则应采用方案一的受控写法。
源码层面的补充细节
展开状态如何流向 DOM
OpenKeys变化时,Menu.razor.cs 的 HandleOpenKeySet 方法 会遍历_submenus,对 key 出现在OpenKeys中的 SubMenu 调用Open(),其余调用Close()。而SubMenu.Open()在 SubMenu.razor.cs 中会级联调用Parent?.Open(),保证展开子菜单时其所有祖先也保持展开。
收起/展开的动画实现
当Menu的Animation参数开启时,SubMenu通过HandleExpand/HandleCollapse(见 SubMenu.razor.cs)驱动ant-motion-collapse-*系列 CSS 类与高度过渡,实现平滑展开收起动画;内嵌模式下收起还会先Task.Delay(300)等待动画完成。
缩进与层级
内嵌模式下,SubMenu 的每级缩进宽度由Menu.InlineIndent控制(默认 24px)。SubMenu.Padding为Level * InlineIndent(SubMenu.razor.cs),MenuItem的缩进为(ParentMenu.Level + 1) * InlineIndent(MenuItem.razor.cs),这就形成了从一级到多级的递进视觉层级。
测试佐证
仓库测试 Menu.Inline.Tests.razor 通过 bUnit 对 inline 菜单的渲染与交互做了多组断言,例如:
Basic_inline_menu_onClick_opens_submenu:点击 SubMenu 标题后,其ul子层应带上ant-menu-submenu-open类;Basic_inline_menu_click_to_select_submenu_option:选中子项后,SubMenu 同时获得ant-menu-submenu-selected与ant-menu-submenu-open;Basic_inline_menu_expanded_with_selected_nested_option_renders_correctly:验证DefaultOpenKeys与DefaultSelectedKeys组合下的初始渲染。
这些用例可帮助你确认OpenKeys、选中态、展开态在 inline 模式下的预期 DOM 输出。
实战注意事项
- 初始展开:方案一中
openKeys字段初始值{"sub1"}即"首次渲染即展开 sub1";也可以改用DefaultOpenKeys声明初始展开,让展开状态在初始化后才受控。 - 不要混用受控与非受控:
OpenKeys一旦由外部绑定,展开状态的唯一来源就是该字段;此时再依赖DefaultOpenKeys不会响应后续变化(源码在OnInitialized中只做一次赋值,见 Menu.razor.cs)。 - 与 Sider 折叠联动:侧边栏常见的"收起为图标"由
InlineCollapsed参数实现(其取值会受Sider的Collapsed级联影响,见 Menu.razor.cs)。折叠时组件内部会将展开状态全部关闭并切换为浮层展示,注意折叠/还原后重新设置OpenKeys以恢复用户预期状态。 - key 的唯一性:
SubMenu与MenuItem的Key在 源码实现 中未指定时会回退到组件自动生成的Id;做受控展开时务必为每个 SubMenu 显式设置稳定、唯一的Key,否则裁剪逻辑无法匹配。 - RTL 支持:内嵌缩进在 RTL 模式下会自动切换为
padding-right(SubMenu.razor.cs),无需额外处理。
相关资源索引
- 官方示例与说明:sider-current.md、SiderCurrent.razor、sider-current2.md、SiderCurrent2.razor
- 组件文档(完整 API 参数表):index.zh-CN.md
- 核心源码:Menu.razor.cs、SubMenu.razor.cs、MenuItem.razor.cs、MenuMode.cs
- 单元测试:Menu.Inline.Tests.razor
通过本文的两种方案与源码剖析,你可以按需实现"只展开当前父级菜单"的侧边导航交互:需要极简与标准行为时使用Accordion,需要精细控制与多级并存时采用OpenKeys+OnOpenChange的受控写法。
- 前端
- UI组件
- 设计系统
【免费下载链接】ant-design-blazor
基于 Ant Design 与 Blazor 的前端组件库。让开发者解放生产力,实现更大价值。
相关推荐
在 Ant Design 侧边栏导航中实现「只展开当前父级菜单」:受控 openKeys 与层级剪枝算法实战
在 Ant Design 侧边栏导航中实现「只展开当前父级菜单」:受控 openKeys 与层级剪枝算法实战 本文以 Ant Design 官方 Menu 示例
前端UI组件设计系统ng-zorro-antd 菜单互斥展开实践:基于 `[(nzOpen)]` 双向绑定实现"只展开当前父级菜单"
ng zorro antd 菜单互斥展开实践:基于 nzOpen 双向绑定实现"只展开当前父级菜单" 导读 在侧边导航(Sider)布局中,菜单项过多时,如果多
UI组件前端Ant Design Menu 内嵌菜单的收缩与展开:inlineCollapsed 原理与实战
Ant Design Menu 内嵌菜单的收缩与展开:inlineCollapsed 原理与实战 内嵌(inline)菜单是后台管理系统最常见的导航形态,Ant
前端UI组件设计系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考