Vue2 + Element-UI V2 扩展动态菜单,submenu 可点击,可根据宽度伸缩菜单

1,322 阅读2分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第8天,点击查看活动详情

最近在使用 Element UI V2 的时候,遇到了菜单的问题。默认的 NavMenu 不支持 submenu 点击,不支持宽度过长自动隐藏。此文通过一些取巧的方式,相对的实现动态菜单的一些功能。

前期准备

需求理解

NavMenu 本身已具备的功能

  • 横向菜单
  • 设置菜单背景色和文字颜色等
  • 父子菜单,选中之后,会添加 is-active 类名

动态菜单

  • 通过树状结构数组实现动态菜单

submenu 可点击,可选中

  • Navmenusubmenu 是不可以点击也不能单独选中的。我们需要可以点击并选中

横向菜单太长,自动隐藏到“更多”菜单中

  • 横向空间有限,菜单多的时候,无法完全放的下。添加 “更多” 菜单显示剩余菜单

代码思路

动态菜单

使用 Vue 中自循环组件,通过传入menus,递归渲染 submenumenu-item

需要注意的点:

  • 要多包一层 div
  • 循环组件上别忘记 v-on="$listeners",否则 @click 事件传递不出来

submenu 可点击,可选中

可点击:submenutitle 上添加 @click 事件。

可选中:遇到 submenu 就给其下添加一个隐藏的 menu-item,用于选中。设置 submenumenu-itemkey 不一致

横向菜单太长,自动隐藏

处理传入的 menus 数组,通过计算菜单实际所占的宽度,将超过宽度的菜单填充为“更多”菜单的 children

效果图

image-20221220000704373

具体代码实现

使用 vue-cli 创建项目

参考文档: https://cli.vuejs.org/zh/guide/installation.html

vue create dynamic-navmenu

安装必需的包

注意 sass 的版本,如果太高的话,会报错

npm install element-ui@2.15.12 -S

# 防止 sass 版本过高,报错
npm install -D node-sass@1.57.1 sass-loader@8.0.2

完整版如下:

> npm list
dynamic-navmenu@0.1.0 /Users/zhen/Desktop/items
├── @vue/cli-plugin-babel@4.5.19
├── @vue/cli-plugin-eslint@4.5.19
├── @vue/cli-service@4.5.19
├── babel-eslint@10.1.0
├── core-js@3.26.1
├── element-ui@2.15.12
├── eslint-plugin-vue@6.2.2
├── eslint@6.8.0
├── sass-loader@8.0.2
├── sass@1.57.0
├── vue-template-compiler@2.7.14
└── vue@2.7.14

main.js 增加 Element UI

可以参考官方文档:element.eleme.cn/2.0/#/zh-CN…

import Vue from 'vue'
import App from './App.vue'

import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

Vue.config.productionTip = false

Vue.use(ElementUI);

new Vue({
  render: h => h(App),
}).$mount('#app')

编写循环组件 DynamicMenu.vue

该组件只用于循环生成 menu-itemsubmenu,真正的 el-menu 还需要套一层

具体代码如下,其中有几个需要注意的点:

  • 1、设置组件的 nameDynamicMenus
  • 2、添加 div 组件,包裹 v-for 生成的 menu-itemsubmenu
  • 3、submenu 设置不同的 keyindex
  • 4、根据 menu id,如果是 "更多" 菜单,显示特殊的样式
  • 5、在 titlemenu-item 上的添加 @click 事件,用于响应点击
  • 6、添加隐藏的menu-item,为了 submenu 能够选中
  • 7、如果有子级数据使用递归组件,注意别忘了加 v-on="$listeners", 否则@click 会不响应
<template>
  <!-- 2、添加 div -->
  <div class="dynamic-menu">
    <template v-for="item in menus">
      <!-- 3、有子菜单,设置不同的 key 和 index -->
      <el-submenu
        v-if="item.hasOwnProperty('children') && item.children.length > 0"
        :key="'submenu' + item.id"
        :index="'submenu' + item.id"
        style="dislay: initial"
      >
        <!-- 4、更多菜单,设置不同样式 -->
        <template v-if="item.id === 'menu_more'" slot="title">
          <i class="el-icon-more"></i>
        </template>

        <!-- 5、title 上添加 @click -->
        <template v-else slot="title">
          <!-- <i class="el-icon-menu"></i> -->
          <span @click="handleClick(item)" style="display: inline-block">
            {{ item.text }}
          </span>
        </template>

        <!-- 6、此处添加 el-menu-item 是为了 submenu 能够选中 -->
        <el-menu-item v-show="false" :index="item.id" :key="item.id">
          <span>{{ item.text }}</span>
        </el-menu-item>

        <!-- 7、如果有子级数据使用递归组件 -->
        <DynamicMenus v-on="$listeners" :menus="item.children"></DynamicMenus>
      </el-submenu>

      <!-- 5、没有子菜单,添加 @click -->
      <el-menu-item
        v-else
        :index="item.id"
        :key="item.id"
        @click="handleClick(item)"
      >
        <!-- <i :class="item.icon"></i> -->
        <span>{{ item.text }}</span>
      </el-menu-item>
    </template>
  </div>
</template>

<script>
export default {
  // 1、 设置名称
  name: 'DynamicMenus',
  components: {},
  props: {
    menus: {
      type: Array,
      default: () => [],
    },

    onClick: {
      type: Function,
      default: () => {},
    },
  },
  methods: {
    handleClick(item) {
      this.$emit('onClick', item);
    },
  },
};
</script>

<style scoped lang="scss">
$fontSize: 14px;

.dynamic-menu {
  display: inline-flex;

  :deep(.el-menu-item),
  :deep(.el-submenu__title) {
    font-size: $fontSize;
  }

  :deep(.el-submenu__title) i {
    color: #fff;
    position: initial;
    display: initial;
    margin-left: 5px;
  }
}

.el-menu--popup {
  .dynamic-menu {
    display: initial;

    :deep(.el-menu-item),
    :deep(.el-submenu__title) {
      font-size: $fontSize;
    }

    :deep(.el-submenu__title) i {
      position: absolute;
      display: inline-block;
    }
  }
}
</style>

编写 Header.vue 组件

Header 组件就比较正常了,按照 Element 官方文档提供的示例进行编写

代码如下,需要注意的点:

  • 1、不能点击的 menu-item 要设置为 disabled,否则 is-active 会设置在其上
  • 2、设置样式,按需修改
<template>
  <div class="preview-header">
    <el-menu
      class="preview-header-menu"
      mode="horizontal"
      background-color="#578cff"
      text-color="#e0e0e0"
      active-text-color="#fff"
      :default-active="menuActive"
      :unique-opened="true"
    >
      <!-- 1、设置为 disabled,防止用户点中 -->
      <el-menu-item
        index="name"
        class="preview-header-name disable-menu-item"
        disabled
      >
        动态菜单
      </el-menu-item>

      <DynamicMenus :menus="menus" @onClick="handleClick"></DynamicMenus>

      <el-menu-item
        index="user"
        disabled
        class="disable-menu-item"
        style="float: right"
      >
        您好,{{ currentUser.userName }}
      </el-menu-item>

      <el-menu-item
        index="help"
        class="disable-menu-item"
        style="float: right; cursor: pointer"
        disabled
      >
        体验说明
      </el-menu-item>
    </el-menu>
  </div>
</template>

<script>
import DynamicMenus from './DynamicMenu.vue';

export default {
  name: 'Header',
  components: {
    DynamicMenus,
  },

  props: {
    menus: {
      type: Array,
      default: () => [],
    },
    select: {
      type: Function,
      default: () => {},
    },
  },

  mounted() {
    console.log('this', this.menus);
  },

  data() {
    return {
      currentUser: { userName: 'XXX' },
      menuActive: this.menus.length > 0 ? this.menus[0].id : '',
    };
  },

  methods: {
    handleClick(item) {
      this.$emit('select', item);
      this.menuActive = item.id;
    },
  },
};
</script>

<style scoped lang="scss">
$menuHeight: 46px;

// 2、设置样式,按需修改

.preview-header {
  width: 100%;
  height: $menuHeight;
  line-height: $menuHeight;
 
  &-name {
    font-size: 22px;
    max-width: 150px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  &-menu {
    :deep(.el-menu-item),
    :deep(.el-submenu .el-submenu__title) {
      height: $menuHeight;
      line-height: $menuHeight;
      // border: none !important;
      padding: 0 10px;
    }

    :deep(.is-active) {
      border-bottom: 2px solid #fff !important;
    }
  }
}

// 将 disabled 的颜色设置为白色
.disable-menu-item {
  color: #fff !important;
  opacity: 1 !important;
  cursor: default !important;
}
</style>

使用 Header.vue,在此之前需要通过处理菜单 menus,实现 “更多” 功能

utils/index.js

该文件主要是通过计算菜单的宽度,然后将剩余的菜单设置到 “更多” 里面,就可以实现

需要注意的点:

  • getTextWidth 方法会受到字体等影响,需要注意菜单字体的大小
  • transferMenus 方法中的 fullWidth 会受到外部 menu-itempadding, marign, border 等的影响,同时别忘记了更多 本身也会占空间,此处计算不是很精细,需要测试一下看看
// utils/index.js
export function getTextWidth(str, fontSize = "14px") {
    let width = 0;
    const html = document.createElement("span");
    // html.style.display = "none";
    html.style.position = "absolute";
    html.style.bottom = 0;
    html.style.zIndex = -10000;
    html.style.fontSize = fontSize;
    html.innerText = str;
    html.setAttribute("id", "getTextWidth");
    document.querySelector("#app").appendChild(html);
    const dom = document.querySelector("#getTextWidth");
    width = dom.offsetWidth;

    // 删除 dom
    dom.remove();

    return width;
}

export function transferMenus(
  appData,
  fullWidth = window.innerWidth - 150 - 104 - 76 - 65,
  fontSize = "14px",
  paddingWidth = 20
) {
  const dealMenus = [];

  const moreMenus = {
    id: "menu_more",
    text: "更多",
    type: "list",
    api: "",
    table: "",
    children: [],
    menuData: {
      modelId: "",
      modelVersion: "",
      pageId: "",
    },
    disabled: true,
  };

  let totalWidth = 0;

  appData.menuInfo.forEach((m) => {
    const textWidth = getTextWidth(m.text, fontSize);

    // 左右边距 20
    let realWidth = textWidth + paddingWidth;

    // 如果有子元素,需要多加 5 + 12
    if(Array.isArray(m.children) && m.children.length > 0) {
      realWidth += (5 + 12)
    }

    totalWidth += realWidth

    if (totalWidth >= fullWidth) {
      moreMenus.children.push(m);
    } else {
      dealMenus.push(m);
    }
  });

  return moreMenus.children.length > 0 ? [...dealMenus, moreMenus] : dealMenus;
}

utils/menus.js

该文件主要是模拟动态生成菜单

// utils/menus.js
export const AllMenus = generateMenus(20);

function generateMenus(len = 10) {
  const menus = []
  for (let index = 0; index < len; index++) {

    const menu = {
      id: `menu_${index + 1}`,
      text: `第 ${index + 1} 个菜单`,
      children: [
        {
          id: `menu_${index + 1}_children_1`,
          text: `第${index + 1}-1个子菜单`,
          children: [
            {
              id: `menu_${index + 1}_children_1_1`,
              text: `第${index + 1}-1-1个子菜单`,
              children: []
            }
          ]
        },
        {
          id: `menu_${index + 1}_children_1_2`,
          text: `第${index + 1}-1-2个子菜单`,
          children: []
        }
      ]
    }

    menus.push(menu)
  }

  return menus
}

App.vue

使用 Header 组件,将转换过的 menus 传入组件中

<template>
  <div>
    <Header :menus="menus" @select="handleSelectMenu" />

    <img
      style="margin-top: 100px"
      alt="Vue logo"
      src="https://vuejs.org/images/logo.png"
    />
  </div>
</template>

<script>
import Header from "./Header.vue";
import { transferMenus } from "./utils/index.js";
import { AllMenus } from "./utils/menus.js";

export default {
  name: "App",
  components: {
    Header,
  },

  data() {
    return {
      menus: transferMenus(AllMenus),
    };
  },

  methods: {
    handleSelectMenu(item) {
      console.log("App.vue select menu ", item.text);
    },
  },
};
</script>

<style>
body {
  margin: 0;
}
</style>

结束

# 运行起来,可查看效果了
npm run serve