• Vue3 封装 Element Plus Menu 无限级菜单组件


    本文分别使用 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-menudiv>
        )
      }
    })
    

    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 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 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>tsxh3>
          <yyg-infinite-menu-tsx
            :data="mockData"
            active-text-color="red"
            default-active="1"
            :menu-options="menuOptions"/>
        div>
    
        <div>
          <h3>sfch3>
          <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" 的使用。

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

  • 相关阅读:
    Spring之简单工厂模式 工厂方法模式
    文心一言 VS 讯飞星火 VS chatgpt (90)-- 算法导论8.3 3题
    2023年上半年软考网工选择题易错总结
    Redis使用lua脚本实现库存扣减
    【mia】3 本地rtc发布/推流:frame分发
    解放双手神器-autojs
    电影票小程序插件 电影票CPS插件 电影票微信小程序插件
    git 远程名称 远程分支 介绍
    酷玩Go命令行工具—Cobra
    驱动开发:内核注册并监控对象回调
  • 原文地址:https://www.cnblogs.com/youyacoder/p/16701222.html