Vue3封装Element Plus Menu无限级菜单组件功能的详细代码

发布时间:2022-9-17 09:33

本文分别使用 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 中的属性我们也希望能够暴露出去使用:

但 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 运行效果

总结:

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

Vue3全局实例上挂载属性方法案例讲解 网站建设

Vue3全局实例上挂载属性方法案例讲解

在大多数开发需求中,我们有时需要将某个数据,或者某个函数方法,挂载到,全局实例身上,以便于,在项目全局的任何位置都能够调用其方法,或读取其数据。 在Vue2 中,我们是在 main.js 中 直...
Vue3异步组件suspense使用详解 网站建设

Vue3异步组件suspense使用详解

vue在解析我们的组件时, 是通过打包成一个 js 文件,当我们的一个组件 引入过多子组件是,页面的首屏加载时间 由最后一个组件决定 优化的一种方式就是采用异步组件 ,先给慢的组件一个提示语或者 骨架...