学习Vue-admin-template-嵌套路由与菜单栏

1,212 阅读7分钟

嵌套路由与菜单栏

项目地址:vue-admin-template

文档地址:vue-admin-template

通过学习vue-admin-template,然后自己通过Vue3+TypeScript去模仿实现了一下。

路由

路由目录结构

首先来看下我们的路由目录结构:

router
|-----modules
      |-------dashboard.ts
      |-------function.ts
      |-------permission.ts
      |-------userinfo.ts
|-----constant.ts
|-----index.ts
|-----type.d.ts

constant.ts:

import type { Component } from 'vue'

/** 公共的路由组件 */
export const Layout = (): Component => import('@/layout/index.vue')

constant.ts主要是用来存放路由模块中用到的公共的组件。

type.d.ts:

import { type Component, type VNode } from 'vue'
import { type RouteRecordRaw } from 'vue-router'

// 导入的所有路由模块的类型
export interface IModuleType {
  default: RouteRecordRaw[] | RouteRecordRaw
}

/** 路由对象的类型 */
interface DTRouteRecordRaw extends RouteRecordRaw {
  /** 路由地址 */
  path: string
  /** 路由的名称 */
  name?: string
  /** 路由对应的组件 */
  component?: Component
  /** 重定向 */
  redirect?: string
  /** 路由元信息,Partial<T> 将T的所有属性更改为可选的 */
  meta?: Partial<DTRouteMeta>
  /** 子路由 */
  children?: DTRouteRecordRaw[]
  // 是否在菜单栏中显示
  hidden?: boolean
}

/** 路由元信息类型 */
interface DTRouteMeta {
  /** Menu标题 */
  title?: string
  /** Icon图标 */
  icon: VNode
  /** Menu项的排序,仅支持第一级 */
  rank: number
  /** 是否显示父级 */
  showParent: boolean
  roles: ['admin', 'editor'] // 设置该路由进入的权限,支持多个权限叠加
  noCache: true // 如果设置为true,则不会被 <keep-alive> 缓存(默认 false)
  breadcrumb: false //  如果设置为false,则不会在breadcrumb面包屑中显示(默认 true)
  affix: true // 如果设置为true,它则会固定在tags-view中(默认 false)

  // 当路由设置了该属性,则会高亮相对应的侧边栏。
  // 这在某些场景非常有用,比如:一个文章的列表页路由为:/article/list
  // 点击文章进入文章详情页,这时候路由为/article/1,但你想在侧边栏高亮文章列表的路由,就可以进行如下设置
  activeMenu: '/article/list'
}

type.d.ts是路由模块的类型声明文件,路由模块中需要声明的类型都集中在这里。

index.ts:

import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { type App } from 'vue'
import { Layout } from './constant'
import type { IModuleType } from './types.d'

/** 路由列表 */
const routes: RouteRecordRaw[] = [
  {
    path: '/',
    component: Layout
  }
]

/** 导入所有路由模块的路由 */
const modules = import.meta.glob<IModuleType>('./modules/**/*.ts', { eager: true })
// console.log(modules)
/** 从modules中拿到所有模块的路由表 */
export const routeModuleList: any[] = []

Object.keys(modules).forEach((key) => {
  routeModuleList.push(modules[key].default)
})
console.log(routeModuleList)

/** 对路由表进行排序 */
routeModuleList.sort((a, b) => {
  return a.meta.rank - b.meta.rank
})

/** 创建router */
const router = createRouter({
  // 路由模式
  history: createWebHistory(),
  // 路由表
  routes: [...routes, ...routeModuleList],
  /** 滚动行为 */
  scrollBehavior: (to, from, savePosition) => {
    /** 延时滚动 */
    return new Promise((resolve) => {
      setTimeout(() => {
        resolve({ top: 0, left: 0 })
      }, 1000)
    })
  }
})

/** 在vue实例对象上安装router */
export const setupRouter = (app: App): void => {
  app.use(router)
}

index.ts主要是用来创建路由实例,导入各个模块的路由并对路由进行例如排序等处理。

index.ts文件中有一段这样的代码:

/** 导入所有路由模块的路由 */
const modules = import.meta.glob<IModuleType>('./modules/**/*.ts', { eager: true })
// console.log(modules)
/** 从modules中拿到所有模块的路由表 */
export const routeModuleList: any[] = []

Object.keys(modules).forEach((key) => {
  routeModuleList.push(modules[key].default)
})
console.log(routeModuleList)

这段代码的作用是导入所有路由模块导出的路由表

其中:

const modules = import.meta.glob<IModuleType>('./modules/**/*.ts', { eager: true })

这行代码的作用是,将本目录下的modules目录下的所有js模块导入。打印结果如下:

image.png

从上图可以看出,import.meta.glob(path, [options])返回的是一个对象,一个包含path所有模块的对象。

这个对象的key这个模块相对于index.ts的路径,值是一个Modele对象,对象中default属性所对应的值就是我们所export default导出的内容,也是一个对象。

但这好像还没完,还没有拿到想要的各模块的路由表

所以下面这段代码的作用就是将所有模块的路由表存放在一个数组里面

/** 从modules中拿到所有模块的路由表 */
export const routeModuleList: any[] = []

Object.keys(modules).forEach((key) => {
  routeModuleList.push(modules[key].default)
})

定义了一个空的数组routeModuleList,用来存放各个模块的路由表,然后使用Object.keys()获取了modeuls对象中的所有key,再通过链式调用forEach循环,在循环里通过modules[key].default的形式拿到Module对象中的路由表,最后将每一个路由表进行push到定义的数组routeModuleList中。

各模块路由表

dashboard.ts:

import { Layout } from '../constant'
import { type DTRouteRecordRaw } from '../types.d'

const dashboardRoutes: DTRouteRecordRaw = {
  path: '/dashboard',
  component: Layout,
  meta: {
    title: '首页',
    rank: 1
  },
  children: [
    {
      path: '/dashboard/index',
      name: 'dashboard',
      component: () => import('@/views/dasahboard/index.vue'),
      meta: {
        title: '控制台'
      }
    }
  ]
}

export default dashboardRoutes

function.ts:

import { Layout } from '../constant'
import { type DTRouteRecordRaw } from '../types'

const functionRoutes: DTRouteRecordRaw = {
  path: '/function',
  component: Layout,
  meta: {
    title: '功能'
  },
  children: [
    {
      path: '/function/starts',
      name: 'starts',
      meta: {
        title: '点赞'
      },
      children: [
        {
          path: '/function/starts/mystarts',
          name: 'mystarts',
          meta: {
            title: '我的点赞'
          },
          children: [
            {
              path: '/function/starts/mystarts/set',
              name: 'set',
              component: () => import('@/views/starts/set-mystarts.vue'),
              meta: {
                title: '我的点赞设置',
                showParent: true
              }
            }
          ]
        },
        {
          path: '/function/starts/startsmy',
          name: 'startsmy',
          component: () => import('@/views/starts/starts-my.vue'),
          meta: {
            title: '点赞我的'
          }
        }
      ]
    },
    {
      path: '/function/history',
      name: 'history',
      component: () => import('@/views/history/index.vue'),
      meta: {
        title: '历史记录'
      }
    }
  ]
}

export default functionRoutes

permission.ts:

import { Layout } from '../constant'
import type { DTRouteRecordRaw } from '../types.d'

const permissionRoutes: DTRouteRecordRaw = {
  path: '/permission',
  component: Layout,
  meta: {
    title: '权限管理',
    rank: 3
  },
  children: [
    {
      path: '/permission/index',
      name: 'permission',
      component: () => import('@/views/permission/index.vue'),
      meta: {
        title: '角色管理',
        showParent: true
      }
    }
  ]
}

export default permissionRoutes

菜单(SideBar)

目录结构

layout
|------components
       |---------SideBar.vue
       |---------SideBarItem.vue
|------index.vue

index.vue: 公共组件,包括了侧边栏菜单、头部和内容区域的组件,就是下图这一整个:

image.png

components: 用来存放layout目录下用到的非路由组件

SideBar.vue:

侧边栏组件

<template>
    <el-menu :default-active="$route.path" router class="el-menu-vertical-demo">
        <SideBar v-for="item in routeModuleList" :key="item.path" :route="item" />
    </el-menu>
</template>

<script setup lang="ts">
import { routeModuleList } from '@/router/index'
</script>

SideBarItem

SideBarItem.vue:

侧边栏菜单项与下拉菜单

<template>
  <!-- 菜单项 -->
  <el-menu-item
    v-if="isItMenuItem(route.children!, route) 
    && (!routeOfMenuItem?.children || routeOfMenuItem.noShowingChildren)"
    :index="routeOfMenuItem?.path"
  >
    <span>{{ routeOfMenuItem?.meta?.title }}</span>
  </el-menu-item>
  <!-- 下拉菜单 -->
  <el-sub-menu v-else :index="route.path">
    <template #title>
      <span>{{ route?.meta?.title }}</span>
    </template>
    <!-- 递归:组件自己使用自己 -->
    <SideBar :route="childRoute" v-for="childRoute in route.children" :key="childRoute?.path" />
  </el-sub-menu>
</template>

<script setup lang="ts">
import { ref } from 'vue'
import { type DTRouteRecordRaw } from '@/router/types.d'
/** 接收父组件传过来的值 */
const props = defineProps<{
  /** 父组件传过来的单个模块的路由表 */
  route: DTRouteRecordRaw
}>()
/** 菜单项(仅一个的时候) */
const routeOfMenuItem = ref()

console.log('route', props.route)
/** 菜单项仅一项时的显示状态 */
function isItMenuItem(children: DTRouteRecordRaw[] = [], parent: DTRouteRecordRaw): boolean {
  /** 将在菜单栏中显示的路由过滤出来 */
  const routeDisplayedInTheMenus: DTRouteRecordRaw[] = children.filter((item) => {
    routeOfMenuItem.value = item
    return !item.hidden
  })
  console.log('routeDisplayedInTheMenus', routeDisplayedInTheMenus)
  /** 当第一个子路由设置了showParent: true时,显示为下拉菜单 */
  if (routeDisplayedInTheMenus[0]?.meta?.showParent) {
    return false
  }

  /** 当只有一个子路由时,为菜单项 */
  if (routeDisplayedInTheMenus.length === 1) {
    return true
  }

  /** 如果子路由下面没有子路由了, 显示父路由,并添加noShowingChildren: true,表示不显示子路由的 */
  if (routeDisplayedInTheMenus.length === 0) {
    routeOfMenuItem.value = { ...parent, noShowingChildren: true }
    return true
  }

  /** 默认展示目录 */
  return false
}
</script>

template

首先说一下为什么要递归组件,因为sub-menu下拉菜单下有menu-item菜单项以及有可能还有sub-menu下拉菜单,sub-menu下拉菜单下有menu-item菜单项以及有可能还有sub-menu下拉菜单....一直这样循环下去,就像这样:

<el-menu-item index="1-4-1">item one</el-menu-item>
<el-sub-menu index="1">
  <template #title>
    <el-icon><location /></el-icon>
    <span>Navigator One</span>
  </template>
  <el-sub-menu index="1-4">
    <template #title>item four</template>
    <el-menu-item index="1-4-1">item one</el-menu-item>
    <el-sub-menu index="1-4">
          <template #title>item four</template>
          <el-menu-item index="1-4-1">item one</el-menu-item>
          <el-sub-menu index="1-4">
              <template #title>item four</template>
              <el-menu-item index="1-4-1">item one</el-menu-item>
          </el-sub-menu>
    </el-sub-menu>
  </el-sub-menu>
</el-sub-menu>

细细一看,这结构不就是SideBarItem.vue的模板结构嘛,简直一模一样。

<!-- 菜单项 -->
<el-menu-item
  v-if="isItMenuItem(route.children!, route) 
  && (!routeOfMenuItem?.children || routeOfMenuItem.noShowingChildren)"
  :index="routeOfMenuItem?.path"
>
  <span>{{ routeOfMenuItem?.meta?.title }}</span>
</el-menu-item>
<!-- 下拉菜单 -->
<el-sub-menu v-else :index="route.path">
  <template #title>
    <span>{{ route?.meta?.title }}</span>
  </template>
  <!-- 递归:组件自己使用自己 -->
  <SideBar :route="childRoute" v-for="childRoute in route.children" :key="childRoute?.path" />
</el-sub-menu>

script

接着我们再来讲讲逻辑:

import { ref } from 'vue'
import { type DTRouteRecordRaw } from '@/router/types.d'
/** 接收父组件传过来的值 */
const props = defineProps<{
  /** 父组件传过来的单个模块的路由表 */
  route: DTRouteRecordRaw
}>()
/** 菜单项 */
const routeOfMenuItem = ref()

console.log('route', props.route)
/** 是否显示为菜单项 */
function isItMenuItem(children: DTRouteRecordRaw[] = [], parent: DTRouteRecordRaw): boolean {
  /** 将在菜单栏中显示的路由过滤出来 */
  const routeDisplayedInTheMenus: DTRouteRecordRaw[] = children.filter((item) => {
    routeOfMenuItem.value = item
    return !item.hidden
  })
  console.log('routeDisplayedInTheMenus', routeDisplayedInTheMenus)
  /** 当第一个子路由设置了showParent: true时,显示为下拉菜单 */
  if (routeDisplayedInTheMenus[0]?.meta?.showParent) {
    return false
  }

  /** 当只有一个子路由时,为菜单项 */
  if (routeDisplayedInTheMenus.length === 1) {
    return true
  }

  /** 如果子路由下面没有子路由了, 显示父路由,并添加noShowingChildren: true,表示不显示子路由的 */
  if (routeDisplayedInTheMenus.length === 0) {
    routeOfMenuItem.value = { ...parent, noShowingChildren: true }
    return true
  }

  /** 默认展示目录 */
  return false
}

为什么要做这个逻辑判断呢?

因为每一个路由表都是不同的,有的路由表的子路由下没有孙子路由了,那我们就认定这个子路由对应显示的就是菜单项,那有的路由表的子路由下面还会有一个多个孙子路由,那这种我们就认定这个子路由是一个下拉菜单。所以我们需要区分不同的路由到底是以菜单项或者下拉菜单的形式展示。

props.route: 从父组件SideBar.vue传过来的值(item):

<SideBar v-for="item in routeModuleList" :key="item.path" :route="item" />

routeOfMenuItem:菜单项

isItMenuItem(children, parent):判断是否显示为菜单项

routeDisplayedInTheMenus:存储过滤出来的需要在菜单栏中显示的路由表,因为有些路由是不需要展示在菜单栏中的,比如:404页面的路由等。

过滤出需要在侧边栏展示的路由:

const routeDisplayedInTheMenus: DTRouteRecordRaw[] = children.filter((item) => { 
    routeOfMenuItem.value = item
    return !item.hidden 
})

routeOfMenuItem: 当前项,在模板中有进行判断,如果有children则不显示。

当第一个子路由设置了showParent: true时,显示为下拉菜单

if (routeDisplayedInTheMenus[0]?.meta?.showParent) {
  return false
}

当从children中过滤出来只有一个路由时,显示为菜单项

if (routeDisplayedInTheMenus.length === 1) { 
    return true 
}

如果没有children属性,使用默认值空数组的,显示为菜单项

/** 如果过滤出来的数组routeDisplayedInTheMenus长度为0, 
  * 那么表示这个路由是没有children子路由的,
  * 同时也说明它应该是一个菜单项,
  * 所以使用它自己的路由配置,并添加noShowingChildren: true,表示不显示子路由的
*/
if (routeDisplayedInTheMenus.length === 0) {
  routeOfMenuItem.value = { ...parent, noShowingChildren: true }
  return true
}

默认显示为下拉菜单

return false

回头发现,在el-menu-item组件上添加了v-if,条件为:

isItMenuItem(route.children!, route) && (!routeOfMenuItem?.children || routeOfMenuItem.noShowingChildren)

isItMenuItem(route.children!, route): 他会返回如下情况:

  1. 只有一个路由
  2. 路由没有子路由
  3. 没有设置showParent: true

(!routeOfMenuItem?.children || routeOfMenuItem.noShowingChildren): 这段逻辑表示,路由没有子路由的,或者不显示子路由的

那么两个逻辑合并起来就是下面的条件:

  1. 只有一个路由并且(没有子路由或不显示子路由的)
  2. 没有子路由并且(没有子路由或不显示子路由的)
  3. 没有设置showParent:true并且(没有子路由或不显示子路由的)