Vue3 封裝 Element Plus Menu 無限級選單元件

2022-09-17 06:03:19

本文分別使用 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" 的使用。

感謝你閱讀本文,如果本文給了你一點點幫助或者啟發,還請三連支援一下,點贊、關注、收藏,作者會持續與大家分享更多幹貨