Vue 多级菜单的实现
最近开发后台,因为不想使用 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类 active
和 hide
。
接下来就只要写这两个样式就行了。
这里就不说了,菜单的收缩可以使用 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>