Vue3 封装 Element Plus Menu 无限级菜单组件【建议收藏】

本文介绍了如何使用 Vue3 和 TypeScript(tsx)以及 Single File Component(SFC)方式封装 Element Plus 的 el-menu 组件,实现菜单的无限级配置。详细讲述了数据结构定义、组件封装过程,以及测试组件的方法,强调了在递归场景下,tsx 相对于 SFC 的优势。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

本文分别使用 SFC(模板方式)和 tsx 方式对 Element Plus el-menu 组件进行二次封装,实现配置化的菜单,有了配置化的菜单,后续便可以根据路由动态渲染菜单。

1 数据结构定义

1.1 菜单项数据结构

使用 element-plus el-menu 组件实现菜单,主要包括三个组件:

el-menu:整个菜单;
el-sub-menu:含有子菜单的菜单项;
el-sub-menu:没有子菜单的菜单项(最末级);

结合菜单的属性和展示效果,可以得到每个菜单项包括:菜单名称、菜单图标、菜单唯一标识、子菜单列表四个属性。于是可得到菜单项结构定义如下:

/**
 * 菜单项
 */
export interface MenuItem {
  /**
   * 菜单名称
   */
  title: string;
  /**
   * 菜单编码(对应 el-menu-item / el-sub-menu 组件的唯一标识 index 字段)
   */
  code: string;
  /**
   * 菜单的图标
   */
  icon?: string;
  /**
   * 子菜单
   */
  children?: MenuItem[]
}

传入 MenuItem 数组,使用该数组渲染出菜单。但有时候数据字段不一定为上面结构定义的属性名,如 菜单名称 字段,上面定义的属性为 title,但实际开发过程中后端返回的是 name,此时字段名不匹配。一种处理方式是前端开发获取到后台返回的数据后,遍历构造上述结构,由于是树形结构,遍历起来麻烦。另一种方式是由用户指定字段的属性名,分别指定菜单名称、菜单编码等分别对应用户传递数据的什么字段。所以需要再定义一个结构,由用户来配置字段名称。

1.2 菜单配置数据结构

首先定义菜单项字段配置的结构:

/**
 * 菜单项字段配置结构
 */
export interface MenuOptions {
  title?: string;
  code?: string;
  icon?: string;
  children?: string;
}

再定义菜单项结构默认字段名:

/**
 * 菜单项默认字段名称
 */
export const defaultMenuOptions: MenuOptions = {
  title: 'title',
  code: 'code',
  icon: 'icon',
  children: 'children'
}

2 使用 tsx 实现封装

2.1 tsx 基本结构

通常使用 tsx 封装组件的结构如下:

import { defineComponent } from 'vue'

export default defineComponent({
  name: 'yyg-menu',

  // 属性定义
  props: {
  },

  setup (props, context) {
    console.log(props, context)

    return () => (
      <div>yyg-menu</div>
    )
  }
})

2.2 定义 prop

首先定义两个属性:菜单的数据、菜单数据的字段名。

// 属性定义
props: {
  data: {
    type: Array as PropType<MenuItem[]>,
    required: true
  },
  menuOptions: {
    type: Object as PropType<MenuOptions>,
    required: false,
    default: () => ({})
  }
},

除了上面定义的两个属性,el-menu 中的属性我们也希望能够暴露出去使用:

image-20220916165319870

el-menu 的属性太多,一个个定义不太现实,在 tsx 中可以使用 context.attrs 来获取。

context.attrs 会返回当前组件定义的属性之外、用户传入的其他属性,也就是返回没有在 props 中定义的属性。

2.3 递归实现组件

setup递归 实现菜单的无限级渲染。封装函数 renderMenu,该函数接收一个数组,遍历数组:

  • 如果没有子节点,则使用 el-menu-item 渲染
  • 如果有子节点,先使用 el-sub-menu 渲染,el-sub-menu 中的内容又继续调用 renderMenu 函数继续渲染。

整个组件实现如下 infinite-menu.tsx

import { DefineComponent, defineComponent, PropType } from 'vue'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import { defaultMenuOptions, MenuItem, MenuOptions } from './types'

export default defineComponent({
  name: 'yyg-menu-tsx',

  // 属性定义
  props: {
    data: {
      type: Array as PropType<MenuItem[]>,
      required: true
    },
    menuOptions: {
      type: Object as PropType<MenuOptions>,
      required: false,
      default: () => ({})
    }
  },

  setup (props, context) {
    console.log(props, context)

    // 合并默认的字段配置和用户传入的字段配置
    const options = {
      ...defaultMenuOptions,
      ...props.menuOptions
    }

    // 渲染图标
    const renderIcon = (icon?: string) => {
      if (!icon) {
        return null
      }
      const IconComp = (ElementPlusIconsVue as { [key: string]: DefineComponent })[icon]
      return (
        <el-icon>
          <IconComp/>
        </el-icon>
      )
    }

    // 递归渲染菜单
    const renderMenu = (list: any[]) => {
      return list.map(item => {
        // 如果没有子菜单,使用 el-menu-item 渲染菜单项
        if (!item[options.children!] || !item[options.children!].length) {
          return (
            <el-menu-item index={item[options.code!]}>
              {renderIcon(item[options.icon!])}
              <span>{item[options.title!]}</span>
            </el-menu-item>
          )
        }

        // 有子菜单,使用 el-sub-menu 渲染子菜单
        // el-sub-menu 的插槽(title 和 default)
        const slots = {
          title: () => (
            <>
              {renderIcon(item[options.icon!])}
              <span>{item[options.title!]}</span>
            </>
          ),
          default: () => renderMenu(item[options.children!])
        }

        return <el-sub-menu index={item[options.code!]} v-slots={slots} />
      })
    }

    return () => (
      <el-menu {...context.attrs}>
        {renderMenu(props.data)}
      </el-menu>
    )
  }
})

3 使用 SFC 实现菜单封装

SFC 即 Single File Component,可以理解为 .vue 文件编写的组件。上面使用 tsx 可以很方便使用递归,模板的方式就不太方便使用递归了,需要使用两个组件来实现。

3.1 封装菜单项的渲染

infinite-menu-item.vue

<template>
  <!-- 没有子节点,使用 el-menu-item 渲染 -->
  <el-menu-item v-if="!item[menuOptions.children] || !item[menuOptions.children].length"
                :index="item[menuOptions.code]">
    <el-icon v-if="item[menuOptions.icon]">
      <Component :is="ElementPlusIconsVue[item[menuOptions.icon]]"/>
    </el-icon>
    <span>{{ item[menuOptions.title] }}</span>
  </el-menu-item>

  <!-- 有子节点,使用 el-sub-menu 渲染 -->
  <el-sub-menu v-else
               :index="item[menuOptions.code]">
    <template #title>
      <el-icon v-if="item[menuOptions.icon]">
        <Component :is="ElementPlusIconsVue[item[menuOptions.icon]]"/>
      </el-icon>
      <span>{{ item[menuOptions.title] }}</span>
    </template>
    <!-- 循环渲染 -->
    <infinite-menu-item v-for="sub in item[menuOptions.children]"
                        :key="sub[menuOptions.code]"
                        :item="sub"
                        :menu-options="menuOptions"/>
  </el-sub-menu>
</template>

<script lang="ts" setup>
import { defineProps, PropType } from 'vue'
import { MenuOptions } from './types'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'

defineProps({
  item: {
    type: Object,
    required: true
  },
  menuOptions: {
    type: Object as PropType<MenuOptions>,
    required: true
  }
})
</script>

<style scoped lang="scss">
</style>

3.2 封装菜单组件

infinite-menu-sfc.vue

<template>
  <el-menu v-bind="$attrs">
    <infinite-menu-item v-for="(item, index) in data"
                        :key="index"
                        :item="item"
                        :menu-options="options"/>
  </el-menu>
</template>

<script lang="ts" setup>
import InfiniteMenuItem from './infinite-menu-item.vue'
import { defineProps, onMounted, PropType, ref } from 'vue'
import { defaultMenuOptions, MenuItem, MenuOptions } from './types'

const props = defineProps({
  data: {
    type: Array as PropType<MenuItem[]>,
    required: true
  },
  menuOptions: {
    type: Object as PropType<MenuOptions>,
    required: false,
    default: () => ({})
  }
})

const options = ref({})

onMounted(() => {
  options.value = {
    ...defaultMenuOptions,
    ...props.menuOptions
  }
})
</script>

<style scoped lang="scss">
</style>

4 测试组件

4.1 菜单测试数据

menu-mock-data.ts

export const mockData = [{
  title: '系统管理',
  id: 'sys',
  logo: 'Menu',
  children: [{
    title: '权限管理',
    id: 'permission',
    logo: 'User',
    children: [
      { title: '角色管理', id: 'role', logo: 'User' },
      { title: '资源管理', id: 'res', logo: 'User' }
    ]
  }, {
    title: '字典管理', id: 'dict', logo: 'User'
  }]
}, {
  title: '营销管理', id: '2', logo: 'Menu'
}, {
  title: '测试',
  id: 'test',
  logo: 'Menu',
  children: [{
    title: '测试-1',
    id: 'test-1',
    logo: 'Document',
    children: [{ title: '测试-1-1', id: 'test-1-1', logo: 'Document', children: [{ title: '测试-1-1-1', id: 'test-1-1-1', logo: 'Document' }]}, { title: '测试-1-2', id: 'test-1-2', logo: 'Document' }]
  }]
}]

4.2 测试页面

<template>
  <div class="menu-demo">
    <div>
      <h3>tsx</h3>
      <yyg-infinite-menu-tsx
        :data="mockData"
        active-text-color="red"
        default-active="1"
        :menu-options="menuOptions"/>
    </div>

    <div>
      <h3>sfc</h3>
      <yyg-infinite-menu-sfc
        :data="mockData"
        active-text-color="red"
        default-active="1"
        :menu-options="menuOptions"/>
    </div>
  </div>
</template>

<script lang="ts" setup>
import YygInfiniteMenuTsx from '@/components/infinite-menu'
import YygInfiniteMenuSfc from '@/components/infinite-menu-sfc.vue'
import { mockData } from '@/views/data/menu-mock-data'

const menuOptions = { title: 'title', code: 'id', icon: 'logo' }
</script>

<style scoped lang="scss">
.menu-demo {
  display: flex;

  > div {
    width: 250px;
    margin-right: 30px;
  }
}
</style>

4.3 运行效果

image-20220916180635608

总结:

  1. 在之前的文章中有读者问我为什么要使用 tsx,从这个例子可以看出,如果控制流程复杂或有递归等操作时,tsx 会比 sfc 更容易实现;
  2. tsx 和 sfc 中动态组件的使用;
  3. tsx 中的 context.attrs 和 sfc 中的 v-bind=“$attrs” 的使用。

感谢你阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与大家分享更多干货

<think>好的,我现在需要帮助用户找到在Vue3中使用Element Plus实现递归菜单的示例或教程。首先,我需要回顾用户提供的引用内容,看看是否有相关的资料。 用户引用了四个来源,其中引用1、2、3和4都提到了VueElement Plus的递归菜单实现。特别是引用1、2、3详细介绍了递归组件封装步骤和使用场景,而引用4讨论了vue-router与element-plus结合实现动态路由菜单的方法。这些信息应该足够指导用户完成递归菜单的实现。 接下来,我需要整理这些引用的关键点。比如,引用3提到了创建项目、引入库、封装递归组件等步骤,这可以作为教程的结构基础。引用2给出了使用示例,展示了如何传递菜单数据和设置属性,这可以作为代码示例的参考。引用1和4则强调了递归在动态菜单中的应用和与路由的结合,这部分需要包含在实现步骤中,特别是动态生成菜单和路由绑定的部分。 用户需要具体的示例或教程,所以应该分步骤说明,包括项目设置、组件封装、数据结构和路由配置等。每个步骤需要提供代码片段,并确保使用正确的LaTeX格式,比如变量名或代码块。同时,需要检查是否所有行内数学表达式都使用$...$,独立公式用$$...$$,但在这个问题中可能数学公式较少,主要关注代码和结构。 另外,用户可能关心如何动态生成菜单,如何处理多级嵌套,以及如何与vue-router集成。因此,在教程中需要涵盖递归组件封装方法,如何通过props传递菜单数据,使用v-for循环渲染子菜单,以及点击菜单项跳转路由的实现。 最后,要生成相关问题,可能涉及性能优化、不同菜单模式(如水平或垂直)的实现,或者如何处理权限控制等。这些问题应该基于教程内容,帮助用户进一步探索相关主题。 现在需要确保回答结构清晰,分步骤说明,并正确引用提供的资料。同时,检查是否遵循了系统级指令,特别是LaTeX格式和引用标识的添加。</think>以下是在Vue3中结合Element Plus实现递归菜单的完整实现思路与示例代码: ### 一、实现递归菜单的核心步骤 1. **组件递归原理** 通过组件自身调用自身处理嵌套数据结构,需在组件内部定义`name`属性用于递归调用[^3]。 2. **菜单数据结构规范** 要求数据包含`children`字段表示子菜单项: ```typescript interface MenuItem { index: string title: string icon?: string children?: MenuItem[] } ``` 3. **组件封装流程** ```vue <!-- RecursiveMenu.vue --> <template> <el-sub-menu v-if="item.children" :index="item.index"> <template #title> <el-icon v-if="item.icon"><component :is="item.icon"/></el-icon> <span>{{ item.title }}</span> </template> <recursive-menu v-for="child in item.children" :key="child.index" :item="child" /> </el-sub-menu> <el-menu-item v-else :index="item.index"> <el-icon v-if="item.icon"><component :is="item.icon"/></el-icon> <span>{{ item.title }}</span> </el-menu-item> </template> <script setup lang="ts"> defineProps<{ item: MenuItem }>() </script> ``` ### 二、动态路由集成方案 1. **路由配置示例** ```typescript const routes = [ { path: &#39;/dashboard&#39;, component: Dashboard, meta: { menu: { index: &#39;1&#39;, title: &#39;控制台&#39;, icon: &#39;monitor&#39; } } }, { path: &#39;/user&#39;, meta: { menu: { index: &#39;2&#39;, title: &#39;用户管理&#39;, icon: &#39;user&#39;, children: [...] } } } ] ``` 2. **动态生成菜单** 通过路由信息自动构建菜单结构: ```vue <el-menu :router="true"> <recursive-menu v-for="route in filteredRoutes" :key="route.meta.menu.index" :item="route.meta.menu" /> </el-menu> ``` ### 三、典型应用场景 1. **权限管理系统** 根据用户权限动态过滤菜单项,实现差异化展示[^4] 2. **多级导航系统** 支持无限级嵌套的侧边栏/顶部导航菜单[^2] 3. **动态配置系统** 通过API获取菜单结构,实现可视化菜单配置 ### 四、完整实现示例 ```vue <!-- App.vue --> <template> <el-container> <el-aside width="200px"> <el-menu :default-active="activeIndex" mode="vertical" @select="handleSelect" > <recursive-menu v-for="item in menuData" :key="item.index" :item="item" /> </el-menu> </el-aside> <router-view /> </el-container> </template> <script setup lang="ts"> import { ref } from &#39;vue&#39; import { Document, Menu as IconMenu } from &#39;@element-plus/icons-vue&#39; const activeIndex = ref(&#39;1&#39;) const menuData = ref<MenuItem[]>([ { index: &#39;1&#39;, title: &#39;首页&#39;, icon: &#39;document&#39; }, { index: &#39;2&#39;, title: &#39;系统管理&#39;, icon: &#39;setting&#39;, children: [ { index: &#39;2-1&#39;, title: &#39;用户管理&#39;, icon: &#39;user&#39; } ] } ]) </script> ```
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员优雅哥

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值