如何使用Vue3+Element-plus+Pinia优雅的实现菜单栏的动态展示(递归)
在后台管理系统中常见的布局通常都存在侧边菜单栏,不管是侧边菜单蓝或者是顶部菜单栏,都会存在多级菜单的可能,真多此情况,利用Vue3+Element-plus+Pinia实现菜单栏的动态实现
环境准备:
vue3: v3.3.4
element-plus: v2.3.12,
mockjs: v1.1.0,
pinia: v2.1.6,
vite: v4.4.5
typescript: v5.0.2
vite-plugin-mock: v2.9.6
vue-router: v4.2.4
axios: v1.5.0
创建项目
pnpm create vite@latest选择typescipt语言
安装Element-plus、pinia、mockjs、vite-plugin-mock、vue-router
Element-plus配置
            
            
              SH
              
              
            
          
          # 安装Element-plus
pnpm i element-plus -d
        - 
在项目中的
main.ts引入element-plus并使用
TSimport { createApp } from 'vue' import App from '@/App.vue' //引入模板的全局的样式 import '@/styles/index.scss' // 插件引入 import ElementPlus from 'element-plus' // 样式引入 import 'element-plus/dist/index.css' // 国际化配置 // @ts-expect-error import zhCn from 'element-plus/dist/locale/zh-cn.mjs' const app = createApp(App) app.use(ElementPlus, { locale: zhCn, // element-plus国际化 }) app.mount('#app') - 
根据语法
<el-xxx>进行使用 
Axios配置(axios的二次封装)
            
            
              sh
              
              
            
          
          # 安装axios
pnpm i axios
        - 
src/utils下创建
request.ts文件tsimport axios from 'axios' import { ElMessage } from 'element-plus' // 对axios进行二次封装 const requests = axios.create({ // 参考上一篇文章中的环境变量配置 baseURL: import.meta.env.VITE_APP_BASE_API, timeout: 5000, }) // 添加请求拦截器 requests.interceptors.request.use( (config) => { // 成功之后做些什么 return config }, (error) => { // 对请求错误做些什么 return Promise.reject(error) }, ) // 添加响应拦截器 requests.interceptors.response.use( function (response) { // 2xx 范围内的状态码都会触发该函数。 // 对响应数据做点什么 //config配置对象,headers属性请求头,经常给服务器端携带公共参数 //返回配置对象 return response.data }, function (error) { // 超出 2xx 范围的状态码都会触发该函数。 // 对响应错误做点什么 let message = '' const status = error.response.status switch (status) { case 401: message = 'token过期' break case 403: message = '无权访问' break case 404: message = '请求地址错误' break case 500: message = '服务器出现问题' break default: message = '无网络' } ElMessage({ type: 'error', message: message, }) return Promise.reject(error) }, ) export default requests 
Mock配置
目的:
- 模拟数据: 可以使用
 Mock.js创建虚拟的 JSON 数据,包括字符串、数字、布尔值等,以模拟真实数据的结构和类型。- 拦截请求: 可以拦截前端发出的 Ajax 请求,并根据预定义的规则返回模拟数据,而不是实际向后端发送请求。这对于前端开发人员来说很有用,因为他们可以独立于后端进行开发和测试。
 - 快速开发:
 Mock.js可以帮助前端开发人员快速创建原型和演示,无需依赖于后端数据,从而加快开发速度。- 模拟异常情况: 可以模拟服务器返回的异常情况,如错误的 HTTP 状态码、超时等,以确保前端代码在面对这些情况时能够正常处理。
 
            
            
              sh
              
              
            
          
          # mock安装
pnpm i mockjs vite-plugin-mock -d
        - 
在项目根目录下创建mock文件夹,并创建
user.ts
ts//createUserList:次函数执行会返回一个数组,数组里面包含两个用户信息 function createUserList() { return [ { // 用户id userId: 1, // 头像 avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', // 用户名 username: 'admin', // 密码 password: '111111', // 描述 desc: '平台管理员', // 角色 roles: ['平台管理员'], buttons: ['cuser.detail'], // 路由 routes: ['home'], // token token: 'Admin Token', }, { userId: 2, avatar: 'https://wpimg.wallstcn.com/f778738c-e4f8-4870-b634-56703b4acafe.gif', username: 'system', password: '111111', desc: '系统管理员', roles: ['系统管理员'], buttons: ['cuser.detail', 'cuser.user'], routes: ['home'], token: 'System Token', }, ] } // 对外暴露一个数组:数组里面包含两个接口 // 登录假的接口 // 获取用户信息的假的接口 export default [ // 用户登录接口 { url: '/api/user/login', //请求地址 method: 'post', //请求方式 response: ({ body }) => { // 获取请求体携带过来的用户名与密码 const { username, password } = body // 调用获取用户信息函数,用于判断是否有此用户 const checkUser = createUserList().find( (item) => item.username === username && item.password === password, ) //没有用户返回失败信息 if (!checkUser) { return { code: 201, data: { message: '账号或者密码不正确' } } } //如果有返回成功信息 const { token } = checkUser return { code: 200, data: { token } } }, }, // 获取用户信息 { url: '/api/user/info', method: 'get', response: (request: { headers: { token: any } }) => { //获取请求头携带token const token = request.headers.token //查看用户信息是否包含有次token用户 const checkUser = createUserList().find((item) => item.token === token) //没有返回失败的信息 if (!checkUser) { return { code: 201, data: { message: '获取用户信息失败' } } } //如果有返回成功信息 return { code: 200, data: { checkUser } } }, }, ] - 
已经存在配置类型
TS// 定义用户相关数据的ts类型 // 用户登录接口携带参数的ts类型 export interface loginFormData { username: string password: string } interface dataType { token?: string message?: string } // 登录接口返回的数据 export interface loginResponseData { code: number data: dataType } interface userInfo { userId: number avatar: string username: string password: string desc: string roles: [] buttons: [] routes: [] token: string } interface user { checkUser: userInfo } // 定义服务器返回的用户信息 export interface userReponseData { code: number data: user } 
Pinia配置
            
            
              sh
              
              
            
          
          # 安装pinia
pnpm i pinia -d
        - 
在目录:src/store/下创建
index.ts文件,并进行一下配置
ts// src/store/index.ts // 创建仓库 import { createPinia } from 'pinia' const pinia = createPinia() // 对外暴露 export default pinia - 
type.ts文件存放仓库中所存在的字段TSimport type { RouteRecordRaw } from 'vue-router' // 小仓库的类型 export interface UserState { token: string | null // 存放用户可访问的菜单的路由配置 menuRoutes: RouteRecordRaw[] username: string avatar: string } - 
新建文件夹modules,创建
menu.ts、user.ts文件TS/** * menu.ts:传入是否可折叠数据 */ // 从pinia从导入对应函数 import { defineStore } from 'pinia' // 作为菜单栏折叠显示数据变化使用 /** * ToggleCollapse:自定义仓库名称 * useToggleCollapse:store实例 */ let useToggleCollapse = defineStore('ToggleCollapse', { state: () => { return { // 是否折叠 isStoreCollapse: false, } }, }) export default useToggleCollapsets/** * user.ts:菜单信息 */ // 创建用户 import { defineStore } from 'pinia' // 引入数类型 import type { UserState } from './types/type' // 引入路由 import { constantRoute } from '@/router/routes' // useStore 可以是 useUser、useCart 之类的任何东西 // 第一个参数是应用程序中 store 的唯一 id const useUserStore = defineStore('User', { // 小仓库 state: (): UserState => { return { // 仓库存储菜单的类型:[] // menuRoutes:用于存储用户可访问的菜单路由配置 // constantRoute: 所有路由的配置信息 menuRoutes: constantRoute, } }, getters: {}, }, }) export default useUserStore 
vue-router配置
            
            
              sh
              
              
            
          
          # 安装vue-router
pnpm i vue-router -d 
        - 
routes.tsTSexport const constantRoute = [ // 登录 { path: '/login', name: 'Login', component: () => import('@/views/login/login.vue'), // 用于展示惨菜单标题 meta: { title: '登录', show: false, // 路由隐藏 icon: 'Promotion', }, }, // 一级路由layout { path: '/', component: () => import('@/layout/index.vue'), name: 'layout', meta: { title: 'layout', show: true, icon: 'House', }, redirect: '/home', children: [ // 首页路由 { path: '/home', component: () => import('@/views/home/home.vue'), name: 'Home', meta: { title: '首页', show: true, icon: 'House', }, }, ], }, // 数据大屏 { path: '/screen', component: () => import('@/views/screen/screen.vue'), name: 'Screen', meta: { show: true, title: '数据大屏', icon: 'Platform', }, }, // 权限管理 { path: '/acl', name: 'Acl', component: () => import('@/layout/index.vue'), meta: { title: '权限管理', show: true, icon: 'Lock', }, redirect: '/acl/user', children: [ { path: '/acl/user', name: 'Acl', component: () => import('@/views/acl/user/user.vue'), meta: { title: '用户管理', show: true, icon: 'User', }, }, { path: '/acl/role', component: () => import('@/views/acl/role/role.vue'), name: 'Role', meta: { title: '角色管理', show: true, icon: 'UserFilled', }, }, { path: '/acl/permission', component: () => import('@/views/acl/permission/permission.vue'), name: 'Permission', meta: { title: '菜单管理', show: true, icon: 'Files', }, }, ], }, // 商品管理 { path: '/product', name: 'Product', component: () => import('@/layout/index.vue'), meta: { title: '商品管理', show: true, icon: 'Goods', }, redirect: '/product/trademark', children: [ { path: '/product/trademark', name: 'Trademark', component: () => import('@/views/product/trademark/trademark.vue'), meta: { title: '品牌管理', show: true, icon: 'Cherry', }, }, { path: '/product/attribute', name: 'Attribute', component: () => import('@/views/product/attribute/attribute.vue'), meta: { title: '属性管理', show: true, icon: 'ChromeFilled', }, }, { path: '/product/sku', component: () => import('@/views/product/sku/sku.vue'), name: 'Sku', meta: { title: 'SKU管理', show: true, icon: 'UserFilled', }, }, { path: '/product/spu', name: 'Spu', component: () => import('@/views/product/spu/spu.vue'), meta: { title: 'Spu管理', show: true, icon: 'Files', }, }, ], }, // 404 { path: '/404', name: '404', component: () => import('@/views/404/404.vue'), meta: { title: '404', show: false, icon: 'DocumentDelete', }, }, // 任意路由:所有都不匹配就匹配当前路由 { path: '/:pathMatch(.*)*', redirect: '/404', name: 'Any', meta: { title: '任意', show: false, icon: 'DataLine', }, }, ] - 
router/index.tsTS// 通过vue-router实现模板路由配置 import { createRouter, createWebHashHistory } from 'vue-router' import { constantRoute } from './routes' // 创建路由器 const router = createRouter({ // 路由模式 history: createWebHashHistory(), routes: constantRoute, // 滚动行为 scrollBehavior(to, from, savedPosition) { // return 期望滚动到哪个的位置 }, }) export default router 
实现思路与代码:
- 将所有的一级和多级路由配置完成之后,把路由数组存入到pinia中的state实例当中
 - 单独配置state中的menuRoutes存储配置进行类型设置
 - 将数组constantRoute 存入到menuRoutes中
 - 父组件
 index.vue将数据传入到封装好的menu.vue中,父组件通过变量调用 useUserStore 函数,使用menuRoutes- 子组件
 menu.vue通过defineProps(['menuList'])获取负组件中的数据- 子组件对所有路由配置信息进行循环并且条件处理
 
- 路由 == 1
 
- 直接展示
 - 子路由 && 子路由 == 1
 
- 展示完所有的一级路由再直接把一个子路由展示
 - 子路由 && 子路由 > 1(递归)
 
- 展示完所有的一级路由,再将子路由作为对象数组继续传入到子组件继续循环(自己传给自己)
 
父组件:index.vue
        
            
            
              html
              
              
            
          
          <template>
 <!-- 展示菜单 -->
      <el-scrollbar class="scrollbarHeight">
        <!-- 获取存储在仓库中的菜单数据,数据是从路由中获取 -->
        <el-menu
          background-color="#001529"
          text-color="white"
          active-text-color="aqua"
          :default-active="$route.path"
          :collapse="toggleCollapse.isStoreCollapse"
          :collapse-transition="true"
        >
          <Menu :menuList="userStore.menuRoutes" />
        </el-menu>
      </el-scrollbar>
    </div>
</template>
<script setup lang="ts">
import Menu from './menu/menu.vue'
import Tabbar from './tabbar/tabbar.vue'
// 获取用户相关小仓库
import useUserStore from '@/store/modules/users'
// 引用路由
import { useRoute } from 'vue-router'
    
// 使用仓库
let userStore = useUserStore()
// 获取路由对象
let $route = useRoute()
</script>
        子组件:menu.vue
        
            
            
              html
              
              
            
          
          <template>
  <template v-for="(item, index) in menuList" :key="item.path">
    <!-- 没有子路由 -->
    <template v-if="!item.children">
      <el-menu-item :index="item.path" v-if="item.meta.show" @click="goRoute">
        <el-icon>
          <component :is="item.meta.icon"></component>
        </el-icon>
        <template #title>
          <span>{{ item.meta.title }}</span>
        </template>
      </el-menu-item>
    </template>
    <!-- 子组件有1个 -->
    <template v-if="item.children && item.children.length == 1">
      <el-menu-item
        :index="item.children[0].path"
        v-if="item.children[0].meta.show"
        @click="goRoute"
      >
        <el-icon>
          <component :is="item.children[0].meta.icon"></component>
        </el-icon>
        <template #title>
          <span>{{ item.children[0].meta.title }}</span>
        </template>
      </el-menu-item>
    </template>
    <!-- 多个子路由 -->
    <el-sub-menu
      :index="item.path"
      v-if="item.children && item.children.length > 1"
    >
      <template #title>
        <el-icon>
          <component :is="item.meta.icon"></component>
        </el-icon>
        <span>{{ item.meta.title }}</span>
      </template>
      <!-- 递归展示子路由 -->
      <Menu :menuList="item.children"></Menu>
    </el-sub-menu>
  </template>
</template>
<script setup lang="ts">
// 引用路由
import { useRouter } from 'vue-router'
// 获取父组件中所有的路由数据
defineProps(['menuList'])
let $router = useRouter()
const goRoute = (vc: any) => {
  $router.push(vc.index)
}
</script>
<script lang="ts">
export default {
  name: 'Menu',
}
</script>
        实际效果


图解
