vue3+ts+setup语法糖使用element plus 中的menu组件二次封装一个menu菜单可以根据路由数据显示多级菜单
时间: 2025-07-23 10:05:08 浏览: 32
<think>我们使用 Vue 3 的 Composition API 和 TypeScript,结合 setup 语法糖,以及 Element Plus 的 Menu 组件进行二次封装。
目标:根据路由数据动态生成多级菜单,实现路由数据驱动。
思路:
1. 我们需要获取路由实例,从中提取出需要展示在菜单中的路由信息(通常这些路由会配置 meta 信息,如 title、icon 等,以及 children 子路由)。
2. 递归地处理路由信息,将其转换成菜单项的数据结构。
3. 使用 Element Plus 的 Menu 组件(el-menu, el-sub-menu, el-menu-item)来渲染菜单。
4. 通过封装一个组件,接收路由数据(或者直接从路由实例中获取)作为菜单的数据源。
步骤:
1. 创建组件 `DynamicMenu.vue`。
2. 在组件中,我们可以使用 `useRouter` 来获取路由实例,但更常见的是通过 props 传入菜单数据,或者直接使用路由实例的 `getRoutes()` 方法获取所有路由,然后根据需求过滤。
这里我们选择从父组件传入路由数据(数组),这样更灵活。同时,我们也可以考虑在组件内部直接使用路由实例,但为了通用性,我们选择通过 props 传入。
3. 定义菜单项的数据结构,通常包括:path(路由路径)、title(菜单显示文本)、icon(图标)、children(子菜单项)。
4. 递归组件:由于菜单可能是多级的,所以我们需要一个递归组件来渲染子菜单。
注意:在 Vue 3 中,我们可以使用递归组件,但要注意组件的命名。
实现:
在 `DynamicMenu.vue` 中:
首先,我们定义 props,接收一个菜单数据列表(即路由配置的数组)。每个菜单项应该包含以下属性:
- path: 路由路径
- meta: 包含 title 和 icon 等
- children: 子路由数组
然后,我们使用 `el-menu` 作为容器,遍历菜单数据,对每一项进行判断:
- 如果该项有 children 且 children 不为空,则渲染为 `el-sub-menu`(子菜单)
- 否则,渲染为 `el-menu-item`(菜单项)
由于递归需要,我们将菜单项渲染的部分抽取为一个模板,并递归调用自身。
另外,我们还需要处理菜单的激活状态(当前路由匹配)和点击菜单跳转。
代码实现:
注意:使用 TypeScript,我们需要定义类型。
我们定义菜单项的类型为 RouteRecordNormalized(来自 vue-router)或者自定义一个简单的类型。这里为了简单,我们自定义一个类型。
但是,为了与路由配置一致,我们可以直接使用 vue-router 的 RouteRecordRaw 类型。不过,在组件中,我们可能只需要部分字段。
我们自定义一个类型:
```ts
interface MenuItem {
path: string
name?: string
meta: {
title: string
icon?: string
}
children?: MenuItem[]
}
```
但是,由于我们是从路由配置中提取,而路由配置可能使用 `vue-router` 的 `RouteRecordRaw` 类型,我们可以直接使用该类型(如果项目安装了 vue-router)。
然而,在封装组件时,我们不想依赖具体的路由类型,所以可以自定义一个更简单的类型。
我们选择自定义:
```ts
interface MenuItem {
path: string
title: string // 从 meta 中取出来,为了方便,我们直接平铺
icon?: string
children?: MenuItem[]
}
```
但是,我们也可以不自定义,而是从传入的 prop 中直接使用,因为 Element Plus 的菜单组件只需要特定的字段。我们可以通过 prop 告诉组件如何获取标题和图标。
为了灵活性,我们可以让父组件传入一个函数来格式化每个菜单项,或者我们约定使用 meta 中的 title 和 icon。
这里我们约定:传入的菜单数据数组中的每个对象必须包含 `meta` 属性,且 `meta` 中有 `title` 和可选的 `icon`。
因此,我们定义 prop 的 type 为数组,数组元素为任意对象,但我们需要在组件内部按照约定结构使用。
使用 TypeScript,我们可以这样定义 prop:
```ts
import { defineProps } from 'vue'
// 定义菜单项类型
interface MenuDataItem {
path: string
meta: {
title: string
icon?: string
[key: string]: any
}
children?: MenuDataItem[]
}
// 定义 props
const props = defineProps<{
data: MenuDataItem[]
}>()
```
但是,注意:在 setup 语法糖中,我们可以使用 defineProps 来定义 props,并且不需要导入。
然而,由于我们使用的是 TypeScript,所以需要这样写。
但是,在 setup 语法糖中,我们也可以使用 withDefaults 来定义默认值。
开始编写组件:
注意:递归组件需要在模板中通过组件名调用自身,因此我们需要给组件一个名字。在 setup 语法糖中,我们可以通过 `<script setup>` 加上 `name` 属性来设置组件名,或者使用两个 script 块。
我们可以这样:
```vue
<script lang="ts">
export default {
name: 'DynamicMenu'
}
</script>
<script setup lang="ts">
// 这里是 setup 语法糖
</script>
```
或者,使用 `defineOptions`(需要 Vue 3.3+):
```vue
<script setup lang="ts">
import { defineOptions } from 'vue'
defineOptions({
name: 'DynamicMenu'
})
</script>
```
这里我们假设环境支持 defineOptions。
如果不支持,我们可以使用两个 script 标签。
我们选择使用两个 script 标签的方式,以确保兼容性。
具体代码:
1. 模板部分:使用 `el-menu` 包裹,遍历 data 数组,根据是否有 children 来决定渲染子菜单还是菜单项。
2. 递归:在子菜单的内容中,再次使用 `DynamicMenu` 组件,传入子菜单的 children。
另外,菜单项点击后,我们使用 `router.push` 进行跳转。因此,我们需要在组件内使用 `useRouter`。
注意:在递归时,我们只需要传递当前菜单项的 children 数组。
同时,我们处理菜单的激活状态:使用 `el-menu` 的 `router` 属性,设置为 true,这样它会根据路由的 path 自动匹配激活状态。但是,我们需要确保菜单项的 index 设置为 path(因为 el-menu-item 的 index 属性用于激活匹配)。
因此,在 el-menu-item 和 el-sub-menu 上都需要设置 index 属性,值为菜单项的 path。
代码示例:
```vue
<template>
<el-menu
:router="true"
:default-active="activeMenu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<template v-for="item in data" :key="item.path">
<!-- 有子菜单 -->
<el-sub-menu v-if="item.children && item.children.length" :index="item.path">
<template #title>
<el-icon v-if="item.meta.icon"><component :is="item.meta.icon" /></el-icon>
<span>{{ item.meta.title }}</span>
</template>
<DynamicMenu :data="item.children" />
</el-sub-menu>
<!-- 没有子菜单 -->
<el-menu-item v-else :index="item.path" @click="handleMenuItemClick(item)">
<el-icon v-if="item.meta.icon"><component :is="item.meta.icon" /></el-icon>
<template #title>{{ item.meta.title }}</template>
</el-menu-item>
</template>
</el-menu>
</template>
```
注意:我们使用了动态图标,这里假设 meta 中的 icon 是一个组件名(例如,Element Plus 的图标名,如 `User`)。我们需要从 `@element-plus/icons-vue` 中导入图标,但这里我们使用动态组件 `<component :is>`,因此需要确保父组件已经全局注册了图标,或者我们在当前组件局部注册。但这样会使得组件依赖图标全局注册。
为了避免这个问题,我们可以让父组件传入图标的处理方式,或者我们在这里不处理图标(在父级的路由配置中,icon 可以是一个组件,也可以是一个字符串,如果是字符串,我们可以使用一个映射对象来转换为组件)。
为了简化,我们假设父组件已经全局注册了所有图标(使用 Element Plus 的全局注册图标方式),这样我们就可以直接使用字符串作为组件名。
另外,我们处理点击事件:因为设置了 `router` 属性为 true,所以点击 el-menu-item 会自动跳转到 index 指定的路径,所以我们不需要手动处理。但是,el-sub-menu 不需要跳转,所以没有问题。
但是,我们也可以手动处理:去掉 `router` 属性,然后在 `handleMenuItemClick` 方法中手动跳转。这样更灵活,比如可以处理一些特殊情况。
这里我们使用自动路由。
关于激活菜单:我们使用 `default-active` 绑定 `activeMenu`,这个 `activeMenu` 应该是当前路由的 path。我们可以通过当前路由对象来获取。
在组件内获取当前路由:`const route = useRoute()`
然后,计算 activeMenu:
但是,注意:如果路由有重定向或者嵌套路由,可能需要匹配到最合适的菜单项。通常,我们可以使用 `route.path` 或者 `route.matched` 来获取。
我们这里简单使用 `route.path` 作为激活菜单的 index。
但是,有时候一个菜单项可能对应多个路由(比如动态路由),那么我们需要更复杂的匹配。为了简单,我们假设每个菜单项的路由都是唯一的。
因此:
```ts
import { useRoute } from 'vue-router'
const route = useRoute()
const activeMenu = computed(() => route.path)
```
但是,注意:如果路由配置中有嵌套,那么子路由的 path 可能是完整路径,而菜单项中的 path 可能是相对路径(相对于父路由)。所以,在路由配置中,我们通常使用绝对路径,或者确保菜单项中的 path 是完整的。
另一种做法:在路由配置中,每个菜单项(需要显示在菜单中的路由)都设置一个 meta 属性,比如 `meta.menuPath` 来指定激活时匹配的路径。但这样增加了配置的复杂度。
我们这里使用路由的 path 作为菜单项的 index,并且使用当前路由的 path 来激活,所以要求菜单项中的 path 必须和实际跳转的路径一致。
考虑到嵌套路由,子路由的 path 可能是相对路径,但最终会拼接成绝对路径。所以,我们在菜单项中应该使用绝对路径。
在路由配置中,我们建议使用绝对路径,这样不容易出错。
最后,我们还需要处理一个情况:如果某个菜单项没有 children,但是它的 path 是一个父路径(比如 '/example'),而实际显示的是它的子路由(比如 '/example/child'),那么我们希望这个父菜单项在子路由激活时也能保持展开状态。Element Plus 的菜单组件通过 index 的匹配来实现,而 el-sub-menu 的 index 设置为父菜单的 path,当子菜单被激活时(子菜单的 path 包含父菜单的 path),父菜单会自动展开。所以,我们只需要保证父子菜单的 path 是包含关系(父菜单的 path 是子菜单 path 的前缀)即可。
但是,如果父菜单的 path 和子菜单的 path 没有直接的前缀关系,我们可以通过 `route.matched` 来判断激活的菜单项。不过,Element Plus 的菜单组件内部已经处理了嵌套菜单的展开,只要子菜单项被激活,其所有父菜单都会展开。这是因为我们在 el-sub-menu 中设置了 index 为父菜单的 path,而子菜单项的 index 为子菜单的 path,并且子菜单项的 path 包含父菜单的 path(字符串包含关系),所以会自动展开。
因此,我们只需要保证父子菜单的 path 是包含关系(父菜单的 path 是子菜单 path 的前缀)即可。
完整代码:
注意:由于递归组件,我们需要给组件设置 name,这样在模板中才能调用自身。
我们使用两个 script 标签的方式。
DynamicMenu.vue 文件:
```vue
<script lang="ts">
export default {
name: 'DynamicMenu'
}
</script>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
import { ElMenu, ElMenuItem, ElSubMenu, ElIcon } from 'element-plus'
// 定义菜单项类型
interface MenuDataItem {
path: string
meta: {
title: string
icon?: string
[key: string]: any
}
children?: MenuDataItem[]
}
const props = defineProps<{
data: MenuDataItem[]
}>()
const route = useRoute()
const activeMenu = computed(() => route.path)
</script>
<template>
<el-menu
:router="true"
:default-active="activeMenu"
background-color="#304156"
text-color="#bfcbd9"
active-text-color="#409eff"
>
<template v-for="item in data" :key="item.path">
<el-sub-menu v-if="item.children && item.children.length" :index="item.path">
<template #title>
<el-icon v-if="item.meta.icon">
<component :is="item.meta.icon" />
</el-icon>
<span>{{ item.meta.title }}</span>
</template>
<DynamicMenu :data="item.children" />
</el-sub-menu>
<el-menu-item v-else :index="item.path">
<el-icon v-if="item.meta.icon">
<component :is="item.meta.icon" />
</el-icon>
<template #title>{{ item.meta.title }}</template>
</el-menu-item>
</template>
</el-menu>
</template>
```
注意:我们使用了 `:router="true"`,这样菜单项点击时会自动使用 index 作为 path 进行路由跳转。
另外,我们为每个菜单项设置了唯一的 key,使用 item.path。
使用说明:
在父组件(比如侧边栏组件)中,我们可以这样使用:
首先,获取需要展示在菜单中的路由数据。我们可以通过路由实例的 `getRoutes()` 方法获取所有路由,然后过滤出需要显示在菜单中的路由(比如根据 meta 中是否有 title 属性,或者自定义一个属性如 `showInMenu: true`)。
但是,通常我们不会显示所有路由(比如登录路由、404路由等不需要显示)。所以,我们可以提前在路由配置中标记哪些路由需要显示,然后在父组件中过滤。
例如,在父组件中:
```ts
import { useRouter } from 'vue-router'
import DynamicMenu from './DynamicMenu.vue'
const router = useRouter()
// 获取所有路由配置
const routes = router.getRoutes()
// 过滤出需要显示在菜单中的路由,这里假设根路由(path='/')的 children 就是我们的一级菜单
// 注意:我们的路由结构可能是:根路由下有一个 children 数组,这些 children 就是一级菜单
// 但是,具体项目路由结构可能不同,这里需要根据实际情况调整。
// 例如,我们的一级菜单是根路由的 children
const menuData = routes.find(route => route.path === '/')?.children || []
```
然后,将 menuData 传递给 DynamicMenu 组件。
但是,注意:我们定义菜单项的数据结构需要包含 children 和 meta,所以路由配置中需要正确配置。
另外,我们也可以将菜单数据作为全局状态管理,或者从接口获取,然后通过 prop 传入。
在父组件中:
```vue
<template>
<div class="sidebar">
<dynamic-menu :data="menuData" />
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter } from 'vue-router'
import DynamicMenu from './DynamicMenu.vue'
const router = useRouter()
const menuData = computed(() => {
// 这里根据实际路由结构获取
// 假设我们的一级菜单是根路由的 children,并且根路由本身不需要显示
const rootRoute = router.getRoutes().find(r => r.path === '/')
return rootRoute?.children || []
})
</script>
```
注意:路由配置中,每个需要显示在菜单中的路由都需要配置 meta.title,以及可选的 meta.icon。
例如,路由配置示例:
```ts
const routes = [
{
path: '/',
redirect: '/dashboard',
children: [
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/dashboard/index.vue'),
meta: {
title: '首页',
icon: 'HomeFilled'
}
},
{
path: '/user',
name: 'User',
component: () => import('@/views/user/index.vue'),
meta: {
title: '用户管理',
icon: 'User'
},
children: [
{
path: 'list',
name: 'UserList',
component: () => import('@/views/user/list.vue'),
meta: {
title: '用户列表'
}
},
{
path: 'detail',
name: 'UserDetail',
component: () => import('@/views/user/detail.vue'),
meta: {
title: '用户详情'
}
}
]
}
]
}
]
```
但是,注意:在路由配置中,嵌套路由(children)应该放在父路由的 children 属性中。在菜单组件中,我们递归处理 children。
但是,上面的路由配置中,'/user' 路由有 children,所以它会显示为子菜单。
另外,我们注意到,父路由(如 '/user')通常是一个布局路由,它本身可能没有组件(或者有一个包含 `<router-view>` 的组件),所以不需要显示为一个菜单项(点击父路由可能没有实际页面)。但是,在菜单中,我们希望它显示为可展开的子菜单。所以,我们给它设置了 title 和 icon,并且它的 children 不为空,所以会被渲染为 el-sub-menu。
但是,如果某个路由有 children,但我们也希望它本身可以点击跳转(即同时作为菜单项和子菜单),这种情况在 Element Plus 的菜单中是不支持的。因为一个菜单项要么是 el-menu-item(叶子节点),要么是 el-sub-menu(非叶子节点)。所以,我们通常将父路由设置为不可点击(即不设置 component,或者设置为重定向到第一个子路由),然后只显示子菜单。
因此,在我们的菜单组件中,如果一个路由有 children,我们就渲染为子菜单(不可点击),否则渲染为菜单项(可点击)。
这样符合大多数后台管理系统的菜单设计。
最后,我们还需要注意:图标字符串(如 'HomeFilled')需要是 Element Plus 图标组件的名称,并且确保这些图标已经全局注册。如果没有,我们需要在父组件中注册,或者使用其他方式。
如果项目中使用的是其他图标库,可以调整图标渲染方式,比如使用一个映射对象将字符串转换为组件。
但是,为了不增加组件的复杂度,我们假设父组件已经处理了图标的全局注册。
这样,我们就完成了动态多级菜单的封装。
相关问题:
1. 如何处理菜单权限?我们可以根据用户权限过滤菜单数据,在传入 DynamicMenu 之前,将无权限的菜单项过滤掉。
2. 如何优化菜单性能?如果菜单项很多,递归可能导致性能问题,我们可以使用虚拟滚动,但 Element Plus 的菜单组件不支持虚拟滚动。因此,建议在数据源上进行优化,只生成当前用户可见的菜单项。
3. 如何支持菜单项的外部链接?可以在 meta 中增加一个属性(如 isExternal),然后在渲染菜单项时,如果是外部链接,则使用 `<a>` 标签,并设置 target="_blank"。
我们可以根据需求扩展组件。</think>### Vue 3 + TypeScript + Element Plus 动态多级菜单封装方案
下面是一个基于 Vue 3 的 Composition API、TypeScript 和 Element Plus 的 Menu 组件二次封装方案,实现路由数据驱动的动态多级菜单:
#### 1. 定义路由数据结构 (router.ts)
```typescript
import { RouteRecordRaw } from 'vue-router'
const routes: RouteRecordRaw[] = [
{
path: '/',
redirect: '/dashboard',
meta: { title: '根目录', hidden: true }
},
{
path: '/dashboard',
name: 'Dashboard',
component: () => import('@/views/Dashboard.vue'),
meta: {
title: '控制台',
icon: 'Monitor'
}
},
{
path: '/system',
name: 'System',
meta: {
title: '系统管理',
icon: 'Setting'
},
children: [
{
path: 'user',
name: 'User',
component: () => import('@/views/system/User.vue'),
meta: {
title: '用户管理',
icon: 'User'
}
},
{
path: 'role',
name: 'Role',
component: () => import('@/views/system/Role.vue'),
meta: {
title: '角色管理',
icon: 'Avatar'
}
}
]
},
// 更多路由...
]
export default routes
```
#### 2. 封装动态菜单组件 (DynamicMenu.vue)
```vue
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useRouter, type RouteRecordRaw } from 'vue-router'
import {
Menu as ElMenu,
MenuItem as ElMenuItem,
SubMenu as ElSubMenu
} from 'element-plus'
// 定义组件属性
interface Props {
menuData?: RouteRecordRaw[] // 可传入自定义菜单数据
collapse?: boolean // 是否折叠状态
}
const props = withDefaults(defineProps<Props>(), {
collapse: false
})
const router = useRouter()
const activeMenu = ref('')
// 获取路由生成的菜单数据
const menuRoutes = computed(() => {
return props.menuData || router.options.routes
})
// 处理菜单点击
const handleMenuClick = (path: string) => {
router.push(path)
activeMenu.value = path
}
// 递归渲染多级菜单
const renderMenu = (routes: RouteRecordRaw[]) => {
return routes
.filter(route => !route.meta?.hidden) // 过滤隐藏菜单
.map(route => {
if (route.children?.length) {
return (
<ElSubMenu
index={route.path}
v-slots={{
title: () => (
<>
{route.meta?.icon && <el-icon><component is={route.meta.icon} /></el-icon>}
<span>{route.meta?.title}</span>
</>
)
}}
>
{renderMenu(route.children)}
</ElSubMenu>
)
}
return (
<ElMenuItem
index={route.path}
onClick={() => handleMenuClick(route.path)}
>
{route.meta?.icon && <el-icon><component is={route.meta.icon} /></el-icon>}
<span>{route.meta?.title}</span>
</ElMenuItem>
)
})
}
</script>
<template>
<ElMenu
:default-active="activeMenu"
:collapse="collapse"
class="dynamic-menu"
router
unique-opened
>
<template v-for="item in renderMenu(menuRoutes)" :key="item.index">
<component :is="item.type" v-bind="item.props">
<template v-if="item.children">
<component
v-for="child in item.children"
:is="child.type"
v-bind="child.props"
:key="child.index"
/>
</template>
</component>
</template>
</ElMenu>
</template>
<style scoped>
.dynamic-menu:not(.el-menu--collapse) {
width: 200px;
}
</style>
```
#### 3. 在布局组件中使用 (Layout.vue)
```vue
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { Fold, Expand } from '@element-plus/icons-vue'
import DynamicMenu from '@/components/DynamicMenu.vue'
const router = useRouter()
const isCollapse = ref(false)
// 自定义菜单数据示例(可选)
const customMenu = [
{
path: '/custom',
meta: { title: '自定义菜单', icon: 'MagicStick' },
children: [
{
path: '/custom/page1',
meta: { title: '页面1', icon: 'Document' }
}
]
}
]
</script>
<template>
<div class="layout-container">
<div class="sidebar">
<div class="logo">系统Logo</div>
<DynamicMenu
:menu-data="customMenu"
:collapse="isCollapse"
/>
</div>
<div class="main">
<div class="header">
<el-button
:icon="isCollapse ? Expand : Fold"
@click="isCollapse = !isCollapse"
/>
<!-- 面包屑示例 -->
<el-breadcrumb separator="/">
<el-breadcrumb-item
v-for="(item, index) in $router.matched"
:key="index"
:to="{ path: item.path }"
>
{{ item.meta?.title }}
</el-breadcrumb-item>
</el-breadcrumb>
</div>
<div class="content">
<router-view />
</div>
</div>
</div>
</template>
```
### 功能说明
1. **路由数据驱动**:
- 自动从 Vue Router 配置生成菜单
- 支持 `meta` 属性配置菜单项(`title`、`icon`、`hidden`)
- 支持多级嵌套路由自动生成多级菜单
2. **动态渲染**:
- 使用递归组件渲染无限层级菜单
- 自动过滤标记为 `hidden: true` 的路由
- 支持传入自定义菜单数据覆盖默认路由
3. **响应式设计**:
- 支持菜单折叠/展开状态
- 自动激活当前路由对应的菜单项
- 使用 Vue 3 的 Composition API 和 TypeScript 类型安全
4. **扩展功能**:
- 集成面包屑导航(基于 `$router.matched`)
- 支持 Element Plus 图标组件
- 可通过 `redirect` 配置重定向路由[^1]
### 使用注意事项
1. **路由配置要求**:
- 菜单项标题通过 `meta.title` 设置
- 菜单图标通过 `meta.icon` 设置(需使用 Element Plus 图标名)
- 添加 `hidden: true` 可隐藏菜单项
2. **性能优化**:
- 大型菜单系统建议添加路由懒加载
- 可使用 `keep-alive` 缓存路由组件
- 复杂场景建议配合 `Vuex` 或 `Pinia` 管理菜单状态
3. **自定义扩展**:
- 可在 `renderMenu` 函数中添加权限控制逻辑
- 支持通过 `scoped slots` 自定义菜单项渲染
- 可扩展 `meta` 属性添加更多功能(如权限标识)
```vue
<!-- 自定义菜单项示例 -->
<DynamicMenu>
<template #menu-item="{ route }">
<el-tag v-if="route.meta.new">NEW</el-tag>
{{ route.meta.title }}
</template>
</DynamicMenu>
```
此封装方案实现了路由与菜单的自动同步,遵循 Vue 3 的响应式原则,在路由变化时菜单会自动更新,同时保持组件的高复用性和可配置性[^2]。
阅读全文