在使用 vue-element-admin 后,我觉得它通过 vue-router 生成导航菜单功能挺有意思的,所以阅读了一下他的源码,自己简单的实现一下。
文章主要关注如何生成导航菜单,以及子菜单的展开收缩管理,还有点击菜单的激活状态管理。为了避免文章过长,所以就不写登录页、图标以及动态添加路由了。
准备工作
在干活之前需要把要用到的 router、布局准备好
准备 router
要根据 router 生成菜单导航栏,那么一定是需要一个 router 的,这里新建一个 router
/src/router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
Vue.use(VueRouter)
const routes = [
{
path: '/',
component: () => import('../views/Home'),
meta: {
title: '八大菜系介绍'
}
},
{
path: '/luCai',
component: () => import('../views/luCai'),
meta: {
title: '鲁菜'
},
children: [
{
path: 'history',
component: () => import('../views/luCai/history/index'),
meta: {
title: '发展历史'
},
children: [
{
path: 'qinAndHan',
component: () => import('../views/luCai/history/qinAndHanDynasties'),
meta: {
title: '秦汉时期'
}
},
{
path: 'northernWei',
component: () => import('../views/luCai/history/northernWeiDynasty'),
meta: {
title: '北魏时期'
}
}
]
},
{
path: 'features',
component: () => import('../views/luCai/features'),
meta: {
title: '风味特色'
}
}
]
},
{
path: '/chuanCai',
component: () => import('../views/chuanCai'),
meta: {
title: '川菜'
},
children: [
{
path: 'introduction',
component: () => import('../views/chuanCai/introduction'),
meta: {
title: '川菜概论'
}
},
{
path: 'history',
component: () => import('../views/chuanCai/history'),
meta: {
title: '发展历史'
}
}
]
}
]
const router = new VueRouter({
routes
})
export { router, routes }
meta 里面的 title 是菜单显示的名字
准备布局
这里还是采用左侧菜单栏,右侧内容的布局,顶部的 header 不管他。
/src/Layout.vue
<template>
<div id="app">
<simple-menu/>
<div class="container">
<router-view/>
</div>
</div>
</template>
<script>
import SimpleMenu from "./components/menu/Menu"
export default {
components: { SimpleMenu }
}
</script>
<style>
body {
margin: 0;
}
#app {
height: 100vh;
}
.container {
margin-left: 200px;
height: 100%;
background: #f5f7fa;
padding: 10px;
box-sizing: border-box;
}
</style>
这里的 Menu 还是空壳子,先放上代码吧,主要是一点简单的样式
/src/components/menu/Menu.vue
<template>
<div class="menu">
</div>
</template>
<script>
export default {
props: {},
data() {
return {}
},
methods: {},
computed: {}
}
</script>
<style scoped>
.menu {
user-select: none;
width: 200px;
height: 100%;
position: fixed;
top: 0;
left: 0;
box-shadow: 5px 5px 5px rgba(204, 204, 204, 0.23);
background: white;
}
</style>
那么准备就绪了,就看看效果图吧!
PS:有 children 的路由页面都只有简单的<router-view>
,比如 path 为 '/luCai' 的路由 component,它的文件内容就是
<template>
<router-view/>
</template>
开始生成菜单
通过分析我发现菜单的主要组成部分分为 3 个组件:
- 根菜单组件 -> 提供收缩展开状态管理。
- 子菜单组件 -> 将 router 下的 children 收起来,类似于文件夹的作用。另外提供收起展开动画。
- 菜单条目组件 -> 导航路由,点击此组件切换路由。
整个实现我也给分成 3 步:
- 递归生成树状菜单
- 实现树状菜单的展开收起管理
- 实现菜单的激活状态管理
递归生成树状菜单
所谓递归,其实就是我们喜欢的俄罗斯套娃!为了方便描述,我把根菜单组件取名为 Menu.vue,把子菜单组件取名为 SubMenu.vue,菜单条目组件叫做 MenuItem.vue。
他们之间的关系就是 Menu 会遍历 routers 列表,判断遍历的当前 item 是否有 children,如果有则渲染 SubMenu 组件,如果没有则渲染 MenuItem 组件。这里的 item 就是 route 对象。
当 Menu 渲染 SubMenu 时,会传两个 prop 参数,就是当前 item 以及 basePath,basePath的值为 item.path
当 SubMenu 被渲染出来后,会拿到 item,然后遍历 item.children,接着会在循环中判断 children 的 item,这里另外取个名字叫 itemX,免得跟 props 的 item 搞混了。
SubMenu 在循环中判断 itemX 是否有 children?如果有,则渲染 SubMenu 组件,并且把接收到的 basePath 和 itemX.path 拼接起来传给 SubMenu 的 basePath 参数。到这里就算是一个递归调用了,SubMenu 渲染了 SubMenu 渲染了 SubMenu 渲染....直到接收到的 item 没有 children。
如果 itemX 没有 children,则表示这是一个完整的路由了,渲染 MenuItem。MenuItem 的 path 参数为 basePath + itemX.path。经过层层拼接,这样 MenuItem 就可以用 path 进行路由跳转了。
整个流程就这么走完了,可能你还有点懵,那么来张图吧!
还看不懂也没关系,这里有香喷喷的代码
/src/components/menu/MenuItem.vue
<template>
<router-link :to="path">
<div class="menu-item">{{ title }}</div>
</router-link>
</template>
<script>
export default {
name: 'menu-item',
props: {
path: { // 唯一的routerPath
type: String,
required: true
},
title: { // 标题
type: String,
required: true
}
},
data() {
return {}
},
methods: {}
}
</script>
<style scoped>
.menu-item {
padding: 20px;
color: rgb(153, 153, 153);
user-select: none
}
.menu-item:hover {
background: rgb(244, 244, 244);
}
</style>
这个就很简单,点击它就会路由跳转,鼠标放上去背景色变灰,还是要有点美观的。
下面有请 SubMenu 上场!
/src/components/menu/SubMenu.vue
<template>
<div>
<div
class="title"
>
<!-- 标题 -->
<a href="javascript:void(0)">{{ item.meta.title }}</a>
<!-- 箭头符号 -->
<svg class="icon" viewBox="0 0 1024 1024" width="16" height="16">
<path d="M472.064 751.552 72.832 352.32c-22.08-22.08-22.08-57.792 0-79.872 22.016-22.016 57.792-22.08 79.872 0L512 631.744l359.296-359.296c22.016-22.016 57.792-22.08 79.872 0 22.08 22.08 22.016 57.792 0 79.872l-399.232 399.232C529.856 773.568 494.144 773.568 472.064 751.552z" fill="#999999"/>
</svg>
</div>
<div
class="children"
>
<template v-for="itemX in item.children">
<sub-menu
v-if="itemX.children && itemX.children.length>1"
:item="itemX"
:base-path="resolvePath(itemX.path)"
:key="itemX.path"
/>
<menu-item
v-else
:path="resolvePath(itemX.path)"
:title="itemX.meta.title"
:key="itemX.path"
/>
</template>
</div>
</div>
</template>
<script>
import path from 'path'
import MenuItem from "./MenuItem"
export default {
components: { MenuItem },
name: 'sub-menu', // 一定要写 name,不然递归调用自己会报错
props: {
item: { // route object
type: Object,
required: true
},
basePath: { // 上级 path
type: String,
default: ''
}
},
data() {
return {}
},
methods: {
// 将 routePath 和 basePath 拼接起来
resolvePath(routePath) {
return path.resolve(this.basePath, routePath)
}
}
}
</script>
<style scoped>
.title {
padding: 20px;
color: rgb(153, 153, 153);
}
.title:hover {
background: rgb(244, 244, 244);
}
.children {
padding-left: 20px;
background: rgb(244, 244, 244);
}
.icon {
float: right;
color: #999999;
}
</style>
SubMenu 的代码看着就很简单,核心代码就是一个 v-for,当遍历到有 children 时,就把自己渲染出来,以渲染一个子菜单,否则渲染 MenuItem。写到这里我甚至想把之前的文字说明删掉了。
当一个机器的零件造好了,我们就可以把他们组装起来了。组装成 Menu.vue
/src/components/menu/Menu.vue
<template>
<div class="menu">
<template v-for="item in routes">
<sub-menu
v-if="item.children && item.children.length > 1"
:item="item"
:base-path="item.path"
:key="item.path"
/>
<menu-item
v-else
:path="item.path"
:title="item.meta.title"
:key="item.path"
/>
</template>
</div>
</template>
<script>
import { routes } from '../../router'
import SubMenu from './SubMenu'
import MenuItem from './MenuItem'
export default {
name: 'simple-menu',
components: { MenuItem, SubMenu },
props: {},
data() {},
methods: {
},
computed: {
routes() {
return routes
}
}
}
</script>
<style scoped>
.menu {
user-select: none;
width: 200px;
height: 100%;
position: fixed;
top: 0;
left: 0;
box-shadow: 5px 5px 5px rgba(204, 204, 204, 0.23);
background: white;
overflow-x: hidden;
}
</style>
是的,你没看错,这就是一个循环,跟前面写的完全一致。这里的 routes 是要渲染的路由列表,可能有的同学觉得没必要再弄个 computed 来返回,但是如果不弄的话,就会报 undefind,并且还能够在这里对 routes 做些操作。比如只渲染列表里的某一项之类的。
好了,这个时候我们运行来看看效果
管理展开收缩状态
如果菜单全部都展开来显示的话,很占空间,所以我们需要让他把子菜单都收起来。那么怎么管理展开收起状态呢?
可以把要展开的 SubMenu 的 basePath 添加进 Menu 的 openedSubMenus 里,当 SubMenu 的 basePath 在里面,则表示自己是展开,否则是收起的。
之所以放进 Menu 里而不是它的父级组件里,是因为方便统一管理,在关闭一个 SubMenu 时,同时还需要把 SubMenu 的子 SubMenu 给关闭。
那么子组件要如何操作 Menu 的 openedSubMenus 呢?我们可以利用 vue 的 provide/inject 特性。
provide/inject 允许一个组件提供(provide)一些属性给子组件注入(inject)使用。子组件的子组件也就孙子组件、玄孙组件等等都能用,无论有多深层都能用。
当点击 SubMenu 时就让 Menu 判断是展开还是收起。如果是展开就把被点击的 basePath 添加到 openedSubMenus 中,否则移除。
思路有了就开始行动写代码,我们先完成控制中心代码 Menu 组件。
/src/components/menu/Menu.vue
<template>
<div class="menu">
<template v-for="item in routes">
<sub-menu
v-if="item.children && item.children.length > 1"
:item="item"
:base-path="item.path"
:key="item.path"
/>
<menu-item
v-else
:path="item.path"
:title="item.meta.title"
:key="item.path"
/>
</template>
</div>
</template>
<script>
import { routes } from '../../router'
import SubMenu from './SubMenu'
import MenuItem from './MenuItem'
export default {
name: 'simple-menu',
components: { MenuItem, SubMenu },
provide() {
return {
rootMenu: this // 把自己提供给子组件
}
},
props: {},
data() {
return {
openedSubMenus: [] // 已展开的子菜单index
}
},
methods: {
// 处理子菜单点击事件
handleClickSubMenu(basePath) {
if (this.openedSubMenus.includes(basePath)) {
this.closeSubMenu(basePath)
} else {
this.openSubMenu(basePath)
}
},
// 打开子菜单
openSubMenu(basePath){
this.openedSubMenus.push(basePath)
},
// 关闭子菜单
closeSubMenu(basePath) {
this.openedSubMenus.splice(this.openedSubMenus.indexOf(basePath), 1)
// 关闭 path 下的子菜单
this.openedSubMenus = this.openedSubMenus.filter(item => item.indexOf(basePath+'/') !== 0)
}
},
computed: {
routes() {
return routes
}
}
}
</script>
<style scoped>
.menu {
user-select: none;
width: 200px;
height: 100%;
position: fixed;
top: 0;
left: 0;
box-shadow: 5px 5px 5px rgba(204, 204, 204, 0.23);
background: white;
overflow-x: hidden;
cursor: pointer;
}
</style>
这里主要是通过 provide 将 Menu 组件实例提供给了所有后代组件。后代组件只需要通过 inject 注入就可以调用它的方法和 data,同时在 data 里创建了一个空列表 openedSubMenus,当 SubMenu 组件的 basePath 在这个列表里就代表该 SubMenu 是展开的。最后我们创建了三个函数,看注释就知道是干嘛用的,这里有可能让同学们疑惑的地方就是将要收起的子菜单下的所有子菜单一起收起的代码,也就是这一段:
this.openedSubMenus = this.openedSubMenus.filter(item => item.indexOf(basePath+'/') !== 0)
众所周知,我们的 basePath 是一层一层的拼接的,那么就会有一个特点:“即子菜单的 basePath 是子菜单下的所有后代子菜单的 basePath 的前半部分”,举个例子,我们有个子菜单,他的 basePath 是 /animal
,这个子菜单下的子菜单的 route.path 是 dog
,则拼接出来的就是 /animal/dog
,当我们要关闭 /animal
这个子菜单下的所有子菜单时,就只需要把以 /animal/
开头的 basePath 从 openedSubMenus 里筛选出去就好了,所以这里用了 filter 函数进行筛选。之所以要加在后面加上 / 符号,是因为要避免匹配到 /animals
这样的字符串。
接下来是 SubMenu
/src/components/menu/SubMenu.vue
<template>
<div>
<div
class="title"
@click="handleClick"
>
<!-- 标题 -->
<a href="javascript:void(0)" style="user-select: none">{{ item.meta.title }}</a>
<!-- 箭头符号 -->
<svg class="icon" viewBox="0 0 1024 1024" width="16" height="16">
<path
d="M472.064 751.552 72.832 352.32c-22.08-22.08-22.08-57.792 0-79.872 22.016-22.016 57.792-22.08 79.872 0L512 631.744l359.296-359.296c22.016-22.016 57.792-22.08 79.872 0 22.08 22.08 22.016 57.792 0 79.872l-399.232 399.232C529.856 773.568 494.144 773.568 472.064 751.552z"
fill="#999999"/>
</svg>
</div>
<div
v-show="expanded"
class="children"
>
<template v-for="itemX in item.children">
<sub-menu
v-if="itemX.children && itemX.children.length>1"
:item="itemX"
:base-path="resolvePath(itemX.path)"
:key="itemX.path"
/>
<menu-item
v-else
:path="resolvePath(itemX.path)"
:title="itemX.meta.title"
:key="itemX.path"
/>
</template>
</div>
</div>
</template>
<script>
import path from 'path'
import MenuItem from './MenuItem'
export default {
components: {MenuItem},
name: 'sub-menu', // 一定要写 name,不然递归调用自己会报错
inject: ['rootMenu'],
props: {
item: { // route object
type: Object,
required: true
},
basePath: { // 上级 path
type: String,
default: ''
}
},
data() {
return {}
},
computed: {
// 是否展开
expanded() {
return this.rootMenu.openedSubMenus.includes(this.basePath)
}
},
methods: {
// 将 routePath 和 basePath 拼接起来
resolvePath(routePath) {
return path.resolve(this.basePath, routePath)
},
// 处理展开收起事件
handleClick() {
this.rootMenu.handleClickSubMenu(this.basePath)
}
}
}
</script>
<style scoped>
.title {
padding: 20px;
color: rgb(153, 153, 153);
}
.title:hover {
background: rgb(244, 244, 244);
}
.children {
padding-left: 20px;
background: rgb(244, 244, 244);
}
.icon {
float: right;
color: #999999;
}
</style>
SubMenu 的代码就很简单了。
第一步:injiect 注入 rootMenu。
第二步:给组件的标题添加点击事件,调用 rootMenu 的 handleClickSubMenu 方法,并把自己的 basePath 传过去。
第三步:新增计算属性 expanded,计算自己的 basePath 是否在 openedSubMenus 中,存在就表示是展开,反之则是收起。
第四步:使用 v-show
来绑定 expanded 计算属性。要用 v-show
,不要用 v-if
。以避免重复渲染造成性能浪费。
OK,到了这里我们就可以展开收起了,看看效果先
现在还没有展开收起的动画,这一篇文章因为已经很长了,所以我打算另外再写一篇来专门讲展开收起动画。
激活状态管理
终于到了最后的激活状态管理了,这也是最简单的一部分,主要分四步走:
- MenuItem 新增一个计算属性 active 判断自己的 path 跟 $router.path 是否一致,一致则表示激活。
- SubMenu 新增一个对象 childes 用来存 MenuItem 和 SubMenu 的实例。
- SubMenu 和 MenuItem 在挂载后把自己的实例添加到父组件的 childes 中。
- SubMenu 新增一个计算属性 active,遍历 childes 对象,判断是否有 active 为 true 的实例。如果有,则 active 为 true。
先完成第一步,找到 MenuItem.vue,新增一个 .active 样式,激活时让字体变成橘色:
/src/components/menu/MenuItem.vue
.menu-item {
padding: 20px;
color: rgb(153, 153, 153);
user-select: none
}
.menu-item:hover {
background: rgb(244, 244, 244);
}
.active {
color: orange;
}
然后新增一个计算属性 active:
/src/components/menu/MenuItem.vue
computed: {
// 当前菜单是否激活
active() {
return this.$route.path === this.path
}
}
修改 div,通过 :class 进行样式绑定
/src/components/menu/MenuItem.vue
<template>
<router-link :to="path">
<div class="menu-item" :class="{ 'active': active }">{{ title }}</div>
</router-link>
</template>
第一步完成,进入第二步,打开 SubMenu.vue 修改 data
/src/components/menu/SubMenu.vue
data() {
return {
childes: {}
}
}
新增两个方法来添加/删除 child
/src/components/menu/SubMenu.vue
methods: {
// ...其他已有方法
addChild(index, child) {
this.$set(this.childes, index, child)
},
removeChild(index) {
delete this.childes[index]
}
},
到第三步,挂载后将自己添加到父组件的 childes 中,销毁后要从 childes 中移除
/src/components/menu/MenuItem.vue
mounted() {
// 如果父级存在 addChild 方法则将自己添加进去
if (this.$parent.hasOwnProperty('addChild')) {
this.$parent.addChild(this.path, this)
}
},
destroyed() {
// 如果父级存在 removeChild 方法则将自己从中移除
if (this.$parent.hasOwnProperty('removeChild')) {
this.$parent.removeChild(this.path)
}
}
/src/components/menu/SubMenu.vue
mounted() {
// 如果父级存在 addChild 方法则将自己添加进去
if (this.$parent.hasOwnProperty('addChild')) {
this.$parent.addChild(this.basePath, this)
}
},
destroyed() {
// 如果父级存在 removeChild 方法则将自己从中移除
if (this.$parent.hasOwnProperty('removeChild')) {
this.$parent.removeChild(this.basePath)
}
}
第四步,SubMenu 遍历 childes 计算自己是否应该为激活状态。
/src/components/menu/SubMenu.vue
computed: {
// 是否展开
expanded() {
return this.rootMenu.openedSubMenus.includes(this.basePath)
},
// 是否激活
active() {
let active = false
Object.keys(this.childes).forEach(key => {
if (this.childes[key].active) {
active = true
}
})
return active
}
}
最后就是把 css 样式绑定了
/src/components/menu/SubMenu.vue
<div
class="title"
@click="handleClick"
:class="{ 'active': active }"
>
...
</div>
<style scoped>
.title {
padding: 20px;
color: rgb(153, 153, 153);
}
.active {
color: orange;
}
...
</style>
到这里就结束了,来看看效果吧!
结语
现在实现了展开收起管理和激活状态管理,就差一个动画了,当然还有隐藏路由这样的操作,这些后面单独出文章来更新。先来看看带展开收起动画的效果吧。
这里箭头的旋转动画也还没做,不过
“罗马不是一天建成的”(Rome was not built in a day)
所以更多的细节功能后期会慢慢补充,现在先挖个坑。
感谢看官的耐心阅读,敬请期待展开收起动画篇😘