本文分别使用 SFC(模板方式)和 tsx 方式对 Element Plusel-menu组件进行二次封装,实现配置化的菜单,有了配置化的菜单,后续便可以根据路由动态渲染菜单。
1 数据结构定义
1.1 菜单项数据结构
使用 element-plusel-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”的使用。
感谢你阅读本文,如果本文给了你一点点帮助或者启发,还请三连支持一下,点赞、关注、收藏,作者会持续与大家分享更多干货