最近开发后台,因为不想使用 ElementUI 和其他现成的 UI 框架,于是决定自己做。
碰到的第一个难题就是多级菜单。
因为之前没做过,第一次做起来还是有点难的,最后实现的效果是这样。注意看地址栏。

难题一 CSS 的实现

多级菜单的收缩,展开都是使用 CSS 控制,所以要配合 Vue 传值判断是否 active
在父组件加入 activeItem 告诉子组件哪个索引是活跃的。
菜单由于考虑是多级的,所以我们需要封装成一个组件,并且需要使用组件的递归调用自身已实现多级。

父组件

在父组件中,我们可以使用这种形式来记录菜单数据。
1data () {
2  return {
3items: [{
4        title: 'Dashboard', // 标题
5        icon: ['fas', 'tachometer-alt'], // fontawesome icon
6        path: '/dashboard' // route path
7      },
8      {
9        title: 'Moment',
10        icon: ['far', 'clock'],
11        path: '/moments'
12      }, {
13        title: '菜单测试',
14        icon: ['fas', 'vial'],
15        path: '/moments1',
16        subItems: [{
17          title: '菜单测试 1',
18          icon: ['fas', 'vial'],
19          path: '/moments',
20          subItems: [{
21            title: '菜单测试 1 - 1',
22            icon: ['fas', 'vial'],
23            path: '/moments',
24            subItems: [{
25              title: '菜单测试 1 - 1 - 1',
26              icon: ['fas', 'vial'],
27              path: '/moments',
28              subItems: [{
29                title: '菜单测试 1 - 1 - 1 - 1',
30                icon: ['fas', 'vial'],
31                path: '/moments',
32              }]
33            }]
34          }]
35        },
36        {
37          title: '菜单测试 2',
38          icon: ['fas', 'vial'],
39          path: '/moments2',
40        }]
41      }
42      ],
43      activeItems: 0
44    }
45}
Copy

封装组件 Item

Item 是一个菜单的每一个小项。他接受来自父组件的 items 数组,然后使用 v-for 渲染每一个子菜单(不是一级菜单,是多级菜单的递归渲染)。在父组件中,也通过 v-for 渲染一级菜单。
1// item.vue
2
3<template>
4  <div class="row-item" :class="{active: active}" ref="row-item">
5    <div class="item" @click="handleClick">
6      <div class="icon">
7        <font-awesome-icon :icon="item.icon" />
8      </div>
9      <div class="title">{{item.title}}</div>
10      <div class="down" v-if="hasChild">
11        <font-awesome-icon :icon="['fas','chevron-down']" />
12      </div>
13    </div>
14    <!-- 这里是子菜单 如果存在子菜单才会递归自身渲染 ->
15    <div
16      class="insider"
17      :style="active ? 'max-height: '+ height : ''"
18      ref="insider"
19      v-if="hasChild"
20    >
21      <item
22        :active="activeItems === index ? true : false"
23        :item="item"
24        :index="index"
25        v-for="(item, index) in item.subItems"
26        :key="index"
27        ref="item"
28      />
29    </div>
30  </div>
31</template>
32
33
34export default {
35  name: 'item', // 用于调用自身
36  props: {
37    active: Boolean,
38    item: {
39      type: Object,
40      required: true,
41      validator (val) {
42        return typeof (val.title) === "string"
43          && val.icon instanceof Array
44          && val.icon.length !== 0
45      }
46    },
47    index: Number
48  },
49  data () {
50    return {
51      height: 0,
52      activeItems: 0,
53
54    }
55  },
56}
Copy
子菜单中判断是否活跃一样是通过上级的 activeItem 是否等于 this.index
1// methods
2handleClick () {
3      this.$parent.activeItems = this.index
4      if (this.$parent.activeItems === this.index) {
5
6        this.$refs['row-item'].classList.toggle('hide') // 每次点击当前活跃的菜单 如有子菜单 则切换展开和收缩
7      }
8     
9    },
Copy

父组件调用组件

1//import item from '@/components/Admin/sidebar/item.vue'
2
3// components: {
4//   item
5//  },
6
7 <item
8            :active="activeItems === index ? true : false"
9            :item="item"
10            :index="index"
11            v-for="(item, index) in items"
12            :key="index"
13/>
Copy

CSS 样式

以上步骤已经实现了对菜单加入和取消 CSS类 activehide
接下来就只要写这两个样式就行了。
这里就不说了,菜单的收缩可以使用 max-height 属性。

难点二 路由

到这,我已经查了很多文章,也想了很久,可能是我比较笨吧,一直没想出来。
最后,我想到了点击菜单时,先判断是不是尾菜单,就是不含子菜单的菜单,不可再下拉。
如果是,就合并上一级菜单的 path,(注意看前面的 path
那么只要在 handleClick 的时候加一层判断和跳转就行了。
1// item.vue
2// handleClick(){
3 this.$parent.activeItems = this.index
4      if (this.$parent.activeItems === this.index) {
5
6        this.$refs['row-item'].classList.toggle('hide')
7      }
8      if (!this.hasChild) {
9        let path = this.item.path
10        let item = this.$parent
11        for (; ;) {
12          // path += item.path
13          if (item.item && item.item.path) {
14            path = item.item.path + path
15            item = item.$parent
16          } else break
17        }
18        // console.log(path);
19        path = this.$root.$data.route + path
20        if (path === this.$route.fullPath) {
21          return
22        }
23        this.$router.push(path)
24      }
25}
Copy
最后贴一张想了很久画了很久的手稿,字丑勿喷。

完整代码

1// index.vue
2<template>
3  <div class="bg">
4    <div class="wrap">
5      <div class="side-bar">
6        <div class="title">Moment</div>
7        <div class="items">
8          <item
9            :active="activeItems === index ? true : false"
10            :item="item"
11            :index="index"
12            v-for="(item, index) in items"
13            :key="index"
14          />
15        </div>
16        <div class="user">
17          <div class="block">
18            <img :src="user.avatar" />
19            <div class="username" style="transform: translateY(5px)">{{user.username}}</div>
20            <div class="dot">.</div>
21          </div>
22        </div>
23      </div>
24      <div class="content">
25        <router-view></router-view>
26      </div>
27    </div>
28  </div>
29</template>
30
31<script>
32import { mapGetters } from 'vuex'
33
34import item from '@/components/Admin/sidebar/item.vue'
35export default {
36  name: 'admin',
37  computed: {
38    ...mapGetters(['user']),
39  },
40  components: {
41    item
42  },
43  created () {
44    this.$root.$data.route = '/master'
45  },
46  beforeDestroy () {
47    this.$root.$data.route = null
48    delete this.$root.$data.route
49  },
50  data () {
51    return {
52      path: '/',
53      items: [{
54        title: 'Dashboard',
55        icon: ['fas', 'tachometer-alt'],
56        path: '/dashboard'
57      },
58      {
59        title: 'Moment',
60        icon: ['far', 'clock'],
61        path: '/moments'
62      }, {
63        title: '菜单测试',
64        icon: ['fas', 'vial'],
65        path: '/moments1',
66        subItems: [{
67          title: '菜单测试 1',
68          icon: ['fas', 'vial'],
69          path: '/moments',
70          subItems: [{
71            title: '菜单测试 1 - 1',
72            icon: ['fas', 'vial'],
73            path: '/moments',
74            subItems: [{
75              title: '菜单测试 1 - 1 - 1',
76              icon: ['fas', 'vial'],
77              path: '/moments',
78              subItems: [{
79                title: '菜单测试 1 - 1 - 1 - 1',
80                icon: ['fas', 'vial'],
81                path: '/moments',
82              }]
83            }]
84          }]
85        },
86        {
87          title: '菜单测试 2',
88          icon: ['fas', 'vial'],
89          path: '/moments2',
90        }]
91      }
92      ],
93      activeItems: 0
94    }
95  },
96}
97</script>
98
99<style lang="scss" scoped>
100@import url(https://fonts.googleapis.com/css?family=McLaren&display=swap);
101$deepBg: #1681e1;
102$shallowbg: #1a9cf3;
103.bg {
104  position: fixed;
105  top: 0;
106  left: 0;
107  bottom: 0;
108  right: 0;
109  background-color: $deepBg;
110}
111
112.wrap {
113  position: fixed;
114  top: 0;
115  left: 0;
116  right: 0;
117  bottom: 0;
118  margin: 5rem;
119  background: linear-gradient(to bottom, #1188e8, #16aae7);
120  border-radius: 24px;
121  display: grid;
122  grid-template-columns: 17% auto;
123  box-shadow: 5px 24px 133px rgba(0, 0, 0, 0.3);
124
125  .side-bar {
126    $left-margin: 1.5rem;
127    color: #fff;
128    display: grid;
129    grid-template-rows: 6rem auto 6rem;
130    overflow: hidden;
131    > .title {
132      display: flex;
133      font-family: 'Josefin Sans', sans-serif;
134      justify-content: center;
135      align-items: center;
136      font-size: 1.4rem;
137      user-select: none;
138    }
139
140    .items {
141      margin-left: $left-margin;
142      box-sizing: border-box;
143      overflow: scroll;
144    }
145
146    .user {
147      margin: $left-margin;
148      background: #13afea;
149
150      // background-clip: content-box;
151      border-radius: 12px;
152      position: relative;
153      .block {
154        max-height: 100%;
155        display: grid;
156        grid-template-columns: 50px auto 20px;
157        margin: 0.5rem;
158        user-select: none;
159        * {
160          display: flex;
161          align-items: center;
162          justify-content: center;
163        }
164        .username {
165          font-family: 'Josefin Sans', sans-serif;
166        }
167
168        img {
169          max-width: 30px;
170          border-radius: 50%;
171        }
172      }
173    }
174  }
175  .content {
176    background-color: #fff !important;
177    border-radius: 0 24px 24px 0;
178  }
179}
180</style>
Copy
1// item.vue
2<template>
3  <div class="row-item" :class="{active: active}" ref="row-item">
4    <div class="item" @click="handleClick">
5      <div class="icon">
6        <font-awesome-icon :icon="item.icon" />
7      </div>
8      <div class="title">{{item.title}}</div>
9      <div class="down" v-if="hasChild">
10        <font-awesome-icon :icon="['fas','chevron-down']" />
11      </div>
12    </div>
13    <div
14      class="insider"
15      :style="active ? 'max-height: '+ height : ''"
16      ref="insider"
17      v-if="hasChild"
18    >
19      <item
20        :active="activeItems === index ? true : false"
21        :item="item"
22        :index="index"
23        v-for="(item, index) in item.subItems"
24        :key="index"
25        ref="item"
26      />
27    </div>
28  </div>
29</template>
30
31<script>
32export default {
33  name: 'item',
34  props: {
35    active: Boolean,
36    item: {
37      type: Object,
38      required: true,
39      validator (val) {
40        return typeof (val.title) === "string"
41          && val.icon instanceof Array
42          && val.icon.length !== 0
43      }
44    },
45    index: Number
46  },
47  data () {
48    return {
49      height: 0,
50      activeItems: 0,
51
52    }
53  },
54  computed: {
55    hasChild () {
56      return !(JSON.stringify(this.item.subItems) === '{}' || this.item.subItems === undefined)
57    }
58  },
59  methods: {
60    handleClick () {
61      this.$parent.activeItems = this.index
62      if (this.$parent.activeItems === this.index) {
63
64        this.$refs['row-item'].classList.toggle('hide')
65      }
66      if (!this.hasChild) {
67        let path = this.item.path
68        let item = this.$parent
69        for (; ;) {
70          // path += item.path
71          if (item.item && item.item.path) {
72            path = item.item.path + path
73            item = item.$parent
74          } else break
75        }
76        // console.log(path);
77        path = this.$root.$data.route + path
78        if (path === this.$route.fullPath) {
79          return
80        }
81        this.$router.push(path)
82      }
83    },
84  },
85  mounted () {
86    try {
87      this.height = [...this.$refs.insider.querySelectorAll('.item')].length * 5 + 'rem'
88    } catch (e) {
89      console.log('没有子元素')
90    }
91  }
92}
93</script>
94
95<style lang="scss" scoped>
96.row-item.active {
97  background: rgba(16, 133, 211, 0.5);
98}
99.row-item {
100  transition: background 0.5s;
101  border-radius: 24px 0 0 24px;
102}
103.row-item.hide .insider {
104  max-height: 0 !important;
105}
106.row-item.active:not(.hide) {
107  > .item .down {
108    transform: rotate(180deg);
109  }
110}
111.insider {
112  overflow: hidden;
113  max-height: 0;
114  transition: max-height 0.5s;
115}
116
117.item {
118  * {
119    font-family: 'McLaren', cursive;
120  }
121  display: grid;
122  grid-template-columns: 20px auto 30px;
123  padding: 1rem 0 1rem 1rem;
124  transition: 0.5s;
125  line-height: 1.5;
126  user-select: none;
127  opacity: 0.8;
128
129  > * {
130    display: flex;
131    align-items: center;
132    justify-content: center;
133  }
134
135  .down {
136    justify-content: right;
137    opacity: 0;
138    transition: opacity 0.5s, transform 0.5s;
139    transform-origin: 8px 10px;
140  }
141
142  &:hover {
143    background: #1a9cf3;
144    border-radius: 24px 0 0 24px;
145    opacity: 1;
146    .down {
147      opacity: 0.8;
148    }
149  }
150}
151</style>
Copy

亲亲留个评论再走呗

正在加载评论区...