Vue 多级菜单的实现

4 年前(已编辑)
1621
这篇文章上次修改于 3 年前,可能部分内容已经不适用,如有疑问可询问作者。

最近开发后台,因为不想使用 ElementUI 和其他现成的 UI 框架,于是决定自己做。

碰到的第一个难题就是多级菜单。

因为之前没做过,第一次做起来还是有点难的,最后实现的效果是这样。注意看地址栏。

难题一 CSS 的实现

多级菜单的收缩,展开都是使用 CSS 控制,所以要配合 Vue 传值判断是否 active

在父组件加入 activeItem 告诉子组件哪个索引是活跃的。

菜单由于考虑是多级的,所以我们需要封装成一个组件,并且需要使用组件的递归调用自身已实现多级。

父组件

在父组件中,我们可以使用这种形式来记录菜单数据。

data () {
  return {
items: [{
        title: 'Dashboard', // 标题
        icon: ['fas', 'tachometer-alt'], // fontawesome icon
        path: '/dashboard' // route path
      },
      {
        title: 'Moment',
        icon: ['far', 'clock'],
        path: '/moments'
      }, {
        title: '菜单测试',
        icon: ['fas', 'vial'],
        path: '/moments1',
        subItems: [{
          title: '菜单测试 1',
          icon: ['fas', 'vial'],
          path: '/moments',
          subItems: [{
            title: '菜单测试 1 - 1',
            icon: ['fas', 'vial'],
            path: '/moments',
            subItems: [{
              title: '菜单测试 1 - 1 - 1',
              icon: ['fas', 'vial'],
              path: '/moments',
              subItems: [{
                title: '菜单测试 1 - 1 - 1 - 1',
                icon: ['fas', 'vial'],
                path: '/moments',
              }]
            }]
          }]
        },
        {
          title: '菜单测试 2',
          icon: ['fas', 'vial'],
          path: '/moments2',
        }]
      }
      ],
      activeItems: 0
    }
}

封装组件 Item

Item 是一个菜单的每一个小项。他接受来自父组件的 items 数组,然后使用 v-for 渲染每一个子菜单(不是一级菜单,是多级菜单的递归渲染)。在父组件中,也通过 v-for 渲染一级菜单。

// item.vue

<template>
  <div class="row-item" :class="{active: active}" ref="row-item">
    <div class="item" @click="handleClick">
      <div class="icon">
        <font-awesome-icon :icon="item.icon" />
      </div>
      <div class="title">{{item.title}}</div>
      <div class="down" v-if="hasChild">
        <font-awesome-icon :icon="['fas','chevron-down']" />
      </div>
    </div>
    <!-- 这里是子菜单 如果存在子菜单才会递归自身渲染 ->
    <div
      class="insider"
      :style="active ? 'max-height: '+ height : ''"
      ref="insider"
      v-if="hasChild"
    >
      <item
        :active="activeItems === index ? true : false"
        :item="item"
        :index="index"
        v-for="(item, index) in item.subItems"
        :key="index"
        ref="item"
      />
    </div>
  </div>
</template>


export default {
  name: 'item', // 用于调用自身
  props: {
    active: Boolean,
    item: {
      type: Object,
      required: true,
      validator (val) {
        return typeof (val.title) === "string"
          && val.icon instanceof Array
          && val.icon.length !== 0
      }
    },
    index: Number
  },
  data () {
    return {
      height: 0,
      activeItems: 0,

    }
  },
}

子菜单中判断是否活跃一样是通过上级的 activeItem 是否等于 this.index

// methods
handleClick () {
      this.$parent.activeItems = this.index
      if (this.$parent.activeItems === this.index) {

        this.$refs['row-item'].classList.toggle('hide') // 每次点击当前活跃的菜单 如有子菜单 则切换展开和收缩
      }
     
    },

父组件调用组件

//import item from '@/components/Admin/sidebar/item.vue'

// components: {
//   item
//  },

 <item
            :active="activeItems === index ? true : false"
            :item="item"
            :index="index"
            v-for="(item, index) in items"
            :key="index"
/>

CSS 样式

以上步骤已经实现了对菜单加入和取消 CSS类 activehide

接下来就只要写这两个样式就行了。

这里就不说了,菜单的收缩可以使用 max-height 属性。

难点二 路由

到这,我已经查了很多文章,也想了很久,可能是我比较笨吧,一直没想出来。

最后,我想到了点击菜单时,先判断是不是尾菜单,就是不含子菜单的菜单,不可再下拉。

如果是,就合并上一级菜单的 path,(注意看前面的 path

那么只要在 handleClick 的时候加一层判断和跳转就行了。

// item.vue
// handleClick(){
 this.$parent.activeItems = this.index
      if (this.$parent.activeItems === this.index) {

        this.$refs['row-item'].classList.toggle('hide')
      }
      if (!this.hasChild) {
        let path = this.item.path
        let item = this.$parent
        for (; ;) {
          // path += item.path
          if (item.item && item.item.path) {
            path = item.item.path + path
            item = item.$parent
          } else break
        }
        // console.log(path);
        path = this.$root.$data.route + path
        if (path === this.$route.fullPath) {
          return
        }
        this.$router.push(path)
      }
}

最后贴一张想了很久画了很久的手稿,字丑勿喷。

完整代码

// index.vue
<template>
  <div class="bg">
    <div class="wrap">
      <div class="side-bar">
        <div class="title">Moment</div>
        <div class="items">
          <item
            :active="activeItems === index ? true : false"
            :item="item"
            :index="index"
            v-for="(item, index) in items"
            :key="index"
          />
        </div>
        <div class="user">
          <div class="block">
            <img :src="user.avatar" />
            <div class="username" style="transform: translateY(5px)">{{user.username}}</div>
            <div class="dot">.</div>
          </div>
        </div>
      </div>
      <div class="content">
        <router-view></router-view>
      </div>
    </div>
  </div>
</template>

<script>
import { mapGetters } from 'vuex'

import item from '@/components/Admin/sidebar/item.vue'
export default {
  name: 'admin',
  computed: {
    ...mapGetters(['user']),
  },
  components: {
    item
  },
  created () {
    this.$root.$data.route = '/master'
  },
  beforeDestroy () {
    this.$root.$data.route = null
    delete this.$root.$data.route
  },
  data () {
    return {
      path: '/',
      items: [{
        title: 'Dashboard',
        icon: ['fas', 'tachometer-alt'],
        path: '/dashboard'
      },
      {
        title: 'Moment',
        icon: ['far', 'clock'],
        path: '/moments'
      }, {
        title: '菜单测试',
        icon: ['fas', 'vial'],
        path: '/moments1',
        subItems: [{
          title: '菜单测试 1',
          icon: ['fas', 'vial'],
          path: '/moments',
          subItems: [{
            title: '菜单测试 1 - 1',
            icon: ['fas', 'vial'],
            path: '/moments',
            subItems: [{
              title: '菜单测试 1 - 1 - 1',
              icon: ['fas', 'vial'],
              path: '/moments',
              subItems: [{
                title: '菜单测试 1 - 1 - 1 - 1',
                icon: ['fas', 'vial'],
                path: '/moments',
              }]
            }]
          }]
        },
        {
          title: '菜单测试 2',
          icon: ['fas', 'vial'],
          path: '/moments2',
        }]
      }
      ],
      activeItems: 0
    }
  },
}
</script>

<style lang="scss" scoped>
@import url(https://fonts.googleapis.com/css?family=McLaren&display=swap);
$deepBg: #1681e1;
$shallowbg: #1a9cf3;
.bg {
  position: fixed;
  top: 0;
  left: 0;
  bottom: 0;
  right: 0;
  background-color: $deepBg;
}

.wrap {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  margin: 5rem;
  background: linear-gradient(to bottom, #1188e8, #16aae7);
  border-radius: 24px;
  display: grid;
  grid-template-columns: 17% auto;
  box-shadow: 5px 24px 133px rgba(0, 0, 0, 0.3);

  .side-bar {
    $left-margin: 1.5rem;
    color: #fff;
    display: grid;
    grid-template-rows: 6rem auto 6rem;
    overflow: hidden;
    > .title {
      display: flex;
      font-family: 'Josefin Sans', sans-serif;
      justify-content: center;
      align-items: center;
      font-size: 1.4rem;
      user-select: none;
    }

    .items {
      margin-left: $left-margin;
      box-sizing: border-box;
      overflow: scroll;
    }

    .user {
      margin: $left-margin;
      background: #13afea;

      // background-clip: content-box;
      border-radius: 12px;
      position: relative;
      .block {
        max-height: 100%;
        display: grid;
        grid-template-columns: 50px auto 20px;
        margin: 0.5rem;
        user-select: none;
        * {
          display: flex;
          align-items: center;
          justify-content: center;
        }
        .username {
          font-family: 'Josefin Sans', sans-serif;
        }

        img {
          max-width: 30px;
          border-radius: 50%;
        }
      }
    }
  }
  .content {
    background-color: #fff !important;
    border-radius: 0 24px 24px 0;
  }
}
</style>
// item.vue
<template>
  <div class="row-item" :class="{active: active}" ref="row-item">
    <div class="item" @click="handleClick">
      <div class="icon">
        <font-awesome-icon :icon="item.icon" />
      </div>
      <div class="title">{{item.title}}</div>
      <div class="down" v-if="hasChild">
        <font-awesome-icon :icon="['fas','chevron-down']" />
      </div>
    </div>
    <div
      class="insider"
      :style="active ? 'max-height: '+ height : ''"
      ref="insider"
      v-if="hasChild"
    >
      <item
        :active="activeItems === index ? true : false"
        :item="item"
        :index="index"
        v-for="(item, index) in item.subItems"
        :key="index"
        ref="item"
      />
    </div>
  </div>
</template>

<script>
export default {
  name: 'item',
  props: {
    active: Boolean,
    item: {
      type: Object,
      required: true,
      validator (val) {
        return typeof (val.title) === "string"
          && val.icon instanceof Array
          && val.icon.length !== 0
      }
    },
    index: Number
  },
  data () {
    return {
      height: 0,
      activeItems: 0,

    }
  },
  computed: {
    hasChild () {
      return !(JSON.stringify(this.item.subItems) === '{}' || this.item.subItems === undefined)
    }
  },
  methods: {
    handleClick () {
      this.$parent.activeItems = this.index
      if (this.$parent.activeItems === this.index) {

        this.$refs['row-item'].classList.toggle('hide')
      }
      if (!this.hasChild) {
        let path = this.item.path
        let item = this.$parent
        for (; ;) {
          // path += item.path
          if (item.item && item.item.path) {
            path = item.item.path + path
            item = item.$parent
          } else break
        }
        // console.log(path);
        path = this.$root.$data.route + path
        if (path === this.$route.fullPath) {
          return
        }
        this.$router.push(path)
      }
    },
  },
  mounted () {
    try {
      this.height = [...this.$refs.insider.querySelectorAll('.item')].length * 5 + 'rem'
    } catch (e) {
      console.log('没有子元素')
    }
  }
}
</script>

<style lang="scss" scoped>
.row-item.active {
  background: rgba(16, 133, 211, 0.5);
}
.row-item {
  transition: background 0.5s;
  border-radius: 24px 0 0 24px;
}
.row-item.hide .insider {
  max-height: 0 !important;
}
.row-item.active:not(.hide) {
  > .item .down {
    transform: rotate(180deg);
  }
}
.insider {
  overflow: hidden;
  max-height: 0;
  transition: max-height 0.5s;
}

.item {
  * {
    font-family: 'McLaren', cursive;
  }
  display: grid;
  grid-template-columns: 20px auto 30px;
  padding: 1rem 0 1rem 1rem;
  transition: 0.5s;
  line-height: 1.5;
  user-select: none;
  opacity: 0.8;

  > * {
    display: flex;
    align-items: center;
    justify-content: center;
  }

  .down {
    justify-content: right;
    opacity: 0;
    transition: opacity 0.5s, transform 0.5s;
    transform-origin: 8px 10px;
  }

  &:hover {
    background: #1a9cf3;
    border-radius: 24px 0 0 24px;
    opacity: 1;
    .down {
      opacity: 0.8;
    }
  }
}
</style>
评论区加载中...