菜单组件:子菜单组件SubMenu
SubMenu 组件是整个菜单系统的核心渲染引擎。与 MenuItem 只负责单条菜单项不同,SubMenu 需要同时处理单级菜单项和多级嵌套菜单两种场景,并且要兼容 Element Plus 官方组件的所有使用形态——水平菜单、垂直侧栏菜单、折叠菜单。这个组件的设计难点在于:如何用一套模板逻辑覆盖所有场景,同时保持递归渲染的正确性。
组件职责划分
在开始编码之前,先理清菜单组件的层级关系:
Menu(顶层容器)
├── SubMenu(递归渲染引擎)
│ ├── MenuItem(单级菜单项)
│ └── SubMenu(嵌套子菜单,递归调用自身)
text
Menu 组件负责接收 data(菜单数据数组)和 props(Element Plus el-menu 的属性配置),然后通过 v-for 遍历 data,对每一项调用 SubMenu。SubMenu 内部根据当前数据项是否包含 children 来决定渲染 el-menu-item 还是 el-sub-menu。
Props 设计与属性透传
SubMenu 的 props 直接继承自 Element Plus 的 ElSubMenu 的属性,使用 Partial 包装,使所有属性变为可选:
// src/components/menu/sub-menu.vue
interface SubMenuProps {
data: AppRouteMenuItem
// 继承 el-sub-menu 的所有属性,但全部可选
subMenuProps?: Partial<ElSubMenuProps>
}
typescript
通过 computed 属性解构出 data 和剩余的 subMenuProps,模板中使用 v-bind="subMenuProps" 实现属性透传:
const subMenuEntries = computed(() => {
const { data, ...rest } = props
return rest
})
typescript
这种写法的好处是:当 Element Plus 的 el-sub-menu 新增属性时,组件无需修改即可支持。
三种渲染场景的模板设计
Element Plus 的菜单在不同模式下,el-menu-item 的内部结构有微妙但关键的区别:
| 场景 | 图标 | 标题 | 子菜单标题 |
|---|---|---|---|
| 水平菜单 | 无 | 直接文本 | template #title |
| 垂直侧栏 | el-icon + span | 直接文本 | template #title |
| 折叠菜单 | el-icon + template #title | 直接文本 | template #title |
核心区别在于:侧栏模式下图标和文字是并排的兄弟元素,而折叠模式下图标和文字都嵌套在 template #title 插槽中。
判断条件设计
<template>
<!-- 场景一:无子菜单,渲染 el-menu-item -->
<el-menu-item v-if="!menuHasChildren(data)" :index="getIndex(data)">
<!-- 无图标:纯文本 -->
<span v-if="!data.meta?.icon">{{ data.meta?.title }}</span>
<!-- 有图标 + 非折叠:侧栏模式 -->
<template v-else-if="!collapse">
<Iconify :icon="data.meta.icon" />
<span>{{ data.meta?.title }}</span>
</template>
<!-- 有图标 + 折叠:折叠模式 -->
<template v-else>
<el-icon><Iconify :icon="data.meta.icon" /></el-icon>
<template #title>{{ data.meta?.title }}</template>
</template>
</el-menu-item>
<!-- 场景二:有子菜单,渲染 el-sub-menu -->
<el-sub-menu v-else :index="getIndex(data)">
<template #title>
<Iconify v-if="data.meta?.icon" :icon="data.meta.icon" />
<span>{{ data.meta?.title }}</span>
</template>
<!-- 递归渲染子菜单 -->
<SubMenu
v-for="child in data.children"
:key="`${data.path}/${child.path}`"
:data="child"
/>
</el-sub-menu>
</template>
vue
menuHasChildren 是一个工具函数,判断条件为:当前菜单项的 children 是数组且长度大于 0。这个判断不依赖 hideMenu 属性(隐藏的菜单项在进入 SubMenu 之前已经被过滤掉了)。
递归 Key 的设计
递归组件必须提供唯一的 key 值。这里采用路径拼接的方式:
:key="`${data.path}/${child.path}`"
typescript
这样即便存在同名路由(如多个模块下都有 index 路由),也能保证 key 的全局唯一性。
generateMenuKeys:为菜单生成唯一 index
Element Plus 的 el-menu-item 和 el-sub-menu 要求提供 index 属性作为唯一标识,且遵循层级命名规则:
一级菜单:1, 2, 3, 4...
二级菜单:1-1, 1-2, 2-1...
三级菜单:1-1-1, 1-1-2...
text
这个 index 值不是后端返回的,需要前端根据菜单数据动态生成。实现思路是通过递归遍历菜单数据,为每条记录的 meta 属性上追加一个 key 字段:
// src/components/menu/use-menu.ts
export function useMenu() {
function generateMenuKeys(
menus: AppRouteMenuItem[],
level: number = 0
): void {
const filteredMenus = menus.filter(
(m) => !m.meta?.hideMenu
)
let i = 1
filteredMenus.forEach((item) => {
// 生成 key:层级-序号
const key = level === 0 ? `${i}` : `${level}-${i}`
item.meta = {
...item.meta,
key,
}
// 递归处理子菜单
if (
Array.isArray(item.children) &&
item.children.length > 0
) {
generateMenuKeys(item.children, key)
}
i++
})
}
function getIndex(item: AppRouteMenuItem): string {
return item.meta?.key ?? ''
}
return { generateMenuKeys, getIndex }
}
typescript
注意 i++ 的位置——必须在 key 生成之后立即递增,而不是放在 forEach 循环末尾。如果放在错误位置,会导致同级菜单的 index 重复,从而引发展开/折叠行为异常。
menuHasChildren 工具函数
export function menuHasChildren(item: AppRouteMenuItem): boolean {
return (
!item.meta?.hideMenu &&
Array.isArray(item.children) &&
item.children.length > 0
)
}
typescript
这个函数同时排除了被标记为 hideMenu 的菜单项和没有子菜单的叶子节点。
Menu 组件中的使用方式
// src/components/menu/menu.vue
import { useMenu } from './use-menu'
const { generateMenuKeys, getIndex, menuHasChildren } = useMenu()
// 计算过滤后的菜单(含 key 生成)
const filteredMenus = computed(() => {
generateMenuKeys(props.data)
return props.data.filter((m) => !m.meta?.hideMenu)
})
typescript
在模板中,对每个菜单项分别判断是否显示 MenuItem 或 SubMenu:
<!-- 无子菜单:渲染 MenuItem -->
<MenuItem
v-if="!menuHasChildren(data)"
:data="data"
/>
<!-- 有子菜单:渲染 SubMenu -->
<SubMenu
v-if="menuHasChildren(data)"
:data="data"
/>
vue
不能使用 v-if/v-else 因为中间可能存在其他结构(如 flex-grow 撑开元素)。
Meta 类型扩展
由于我们需要在 meta 上动态添加 key 和 disabled 等属性,需要扩展路由元信息的类型定义:
// src/types/router.d.ts
interface RootMeta {
title?: string
icon?: string
hideMenu?: boolean
order?: number
disabled?: boolean
// 允许用户自定义属性
[key: string]: unknown
}
typescript
使用 [key: string]: unknown 索引签名,既保证类型安全,又允许开发者灵活扩展。
本节小结
SubMenu 组件的设计体现了"递归 + 条件渲染"的经典模式。核心要点有三个:
- 递归渲染:SubMenu 内部嵌套自身,通过
menuHasChildren判断终止条件。 - 场景适配:根据
collapse和icon的组合,用三段条件渲染覆盖 Element Plus 所有菜单形态。 - index 生成:
generateMenuKeys通过递归为每条菜单生成层级化的唯一标识,确保 Element Plus 的菜单激活/展开逻辑正确工作。
下一节将独立出 MenuItem 组件,处理三种渲染场景(无图标、侧栏、折叠)的细节逻辑,包括 disabled 属性的透传和 slot 机制的使用。
↑