本文分別使用 SFC(模板方式)和 tsx 方式對 Element Plus *el-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-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" 的使用。
感謝你閱讀本文,如果本文給了你一點點幫助或者啟發,還請三連支持一下,點贊、關註、收藏,作者會持續與大家分享更多乾貨