vue动态菜单及tag切换

本文详细介绍了在Vue项目中实现动态路由和权限管理的过程,包括静态路由和动态路由的区别,如何使用Vuex进行状态管理,以及动态加载菜单和标签页的实现。通过一个简化版的项目目录结构和关键代码示例,阐述了动态菜单的生成和动态路由的添加。文章特别提到了基于vue-element-admin的项目改造,指出了其存在的问题,并鼓励读者自己动手实践以深入理解。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

     刚刚接触项目的小伙伴 几乎都接触不到这一块的 因为入职 公司要么有骨干 要么是现有项目维护 所以 对于动态菜单 很好奇 今天带着小伙伴们一起来看看吧 

     可能有些人接触过 只是看看别人写的代码 觉得都没有问题  没有实际动手去做过 这就应对了那句 “看花容易绣花难” 其实我开始的时候 也是这种心理 直到我自己动手的时候 发现里面有很多坑 如果不涉及到tag的切换 估计看看element官网就知道了 关键就是带有tag切换 导致很多小伙伴无从下手 

       现在很多公司的项目都是基于vue-element-admin二开的 虽然vue-element-admin是经典 但是毕竟时间太久了 里面也存在一些缺陷 比如项目过大 所以想弄明白的小伙伴还是老老实实的自己走一遍

先看下这个项目目录结构 因为就是个demo 所以也没有全部都建出来 

       首先先和小伙伴分析一下 所谓的动态路由 不完全都是动态的 其实是有两部分组成 一部分是静态的 我们称之为静态路由(constantRoutes)例如login、404等  还有一部分是根据权限后台返回的 我们才称为动态路由(asyncRoutes)

        其次既然是跟路由有关的 我们肯定要用到状态存储器 vuex 

先看下路由接口 我也没有模拟数据 就暂时先写死的

import Vue from "vue";
import VueRouter from "vue-router";
Vue.use(VueRouter);
import Layout from "@/layout/index"

export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login/index'),
    hidden: true
  },
  {
    path: '/redirect',
    component: Layout,
    hidden: true,
    children: [
      {
        path: '/redirect/:path(.*)',
        component: () => import('@/views/redirect/index')
      }
    ]
  }
];
export const asyncRoutes =[
  
  {
    path: '/',
    component: Layout,
    redirect: 'dashboard',
    children: [
      {
        path: '/dashboard',
        component: () => import('@/views/dashboard/index'),
        name: 'Dashboard',
        meta: { title: '首页', icon: 'dashboard', affix: true }
      }
    ]
  },
  {
    path: '/card',
    component: Layout,
    children: [
      {
        path: '/card/index',
        component: () => import('@/views/card/index'),
        name: 'card',
        meta: { title: '购物车', icon: 'icon', noCache: true }
      }
    ]
  },
  {
    path: '/phone',
    component: Layout,
    meta: {
      title: '数码手机'
    },
    children: [
      {
        path: '/phone/apply',
        component: () => import('@/views/phone/applyPhone/index'),
        name: 'apply',
        meta: { title: '苹果手机', icon: 'icon', noCache: true }
      },
      {
        path: '/phone/hw',
        component: () => import('@/views/phone/hwPhone/index'),
        name: 'hw',
        meta: { title: '华为手机', icon: 'icon', noCache: true }
      }
      
    ]
  }
]
const router = new VueRouter({
  mode: "history",
  base: process.env.BASE_URL,
  routes: constantRoutes,
});

export default router;

main.js 其实我这不是完整的 因为没有调用接口 数据暂时写死的  所以在touter.beforeEach里面 没有做token的判断 不过不影响后续流程

import Vue from "vue";
import App from "./App.vue";
import router from "./router";
import store from "./store";
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';
Vue.config.productionTip = false;
Vue.use(ElementUI)

router.beforeEach(async(to, from, next) => {
  if (to.path === '/login') {
    next()
  } else {
    const hasRoutes = store.getters.routes && store.getters.routes.length > 0
    if (hasRoutes) {
      next()
    } else {
      try {
        const accessRoutes = await store.dispatch('permission/generateRoutes', ["admin"])
        router.addRoutes(accessRoutes)
        next({ ...to, replace: true })
      } catch (error) {
        next(`/login?redirect=${to.path}`)
      }
    }
  }
})

new Vue({
  router,
  store,
  render: (h) => h(App),
}).$mount("#app");

侧边栏

<template>
  <el-scrollbar wrap-class="scrollbar-wrapper">
    <el-menu
      :default-active="activeMenu"
      background-color="red"
      text-color="black"
      :unique-opened="false"
      active-text-color="yellow"
      :collapse-transition="false"
      router
      mode="vertical"
    >
      <sidebar-item v-for="route in routes" :key="route.path" :item="route" :base-path="route.path" />
    </el-menu>
  </el-scrollbar>
</template>

<script>
import { mapGetters } from 'vuex'
import sidebarItem from "./sidebarItem.vue"
export default {
  name: '',
  components:{sidebarItem},
  computed: {
    ...mapGetters([
      'routes',
    ]),
    activeMenu() {
      const route = this.$route
      const { meta, path } = route
      if (meta.activeMenu) {
        return meta.activeMenu
      }
      return path
    },
    showLogo() {
      return this.$store.state.settings.sidebarLogo
    },
    isCollapse() {
      return !this.sidebar.opened
    }
  },
  mounted(){
  }
}
</script>

<style lang='scss' scoped>
.sidebar{
  height:100vh;
  width:200px;
  background: red;
}
</style>

侧边栏组件

<template>
  <div v-if="!item.hidden">
    <template v-if="hasOneShowingChild(item.children,item)">
      <el-menu-item :index="onlyOneChild.path">
        <span slot="title">{{onlyOneChild.meta.title}}</span>
      </el-menu-item>
    </template>
    <template v-else>
      <el-submenu ref="subMenu" :index="item.path" popper-append-to-body>
        <template slot="title">
          <span>{{item.meta.title}}</span>
        </template>
        <sidebar-item
          v-for="child in item.children"
          :key="child.path"
          :is-nest="true"
          :item="child"
          :base-path="child.path"
        />
      </el-submenu>
    </template>
  </div>
</template>

<script>
export default {
  name: 'sidebarItem',
  props: {
    item: {
      type: Object,
      required: true
    },
    isNest: {
      type: Boolean,
      default: false
    },
    basePath: {
      type: String,
      default: ''
    }
  },
  data() {
    this.onlyOneChild = null
    return {}
  },
  mounted(){
  },
  methods: {
    hasOneShowingChild(children = [], parent) {
      const showingChildren = children.filter(item => {
        if (item.hidden) {
          return false
        } else {
          this.onlyOneChild = item
          return true
        }
      })
      if (showingChildren.length === 1) {
        return true
      }
      if (showingChildren.length === 0) {
        this.onlyOneChild = { ... parent}
        return true
      }
      return false
    }
  }
}
</script>

tags

<template>
  <div id="tags-view-container" class="tags-view-container">
    <scroll-pane scroll-paneref="scrollPane" ref="scrollPane" class="tags-view-wrapper" @scroll="handleScroll">
      <router-link
        v-for="tag in visitedViews"
        ref="tag"
        :key="tag.path"
        :class="isActive(tag)?'active':''"
        :to="{ path: tag.path, query: tag.query, fullPath: tag.fullPath }"
        tag="span"
        class="tags-view-item"
        @click.middle.native="!isAffix(tag)?closeSelectedTag(tag):''"
        @contextmenu.prevent.native="openMenu(tag,$event)"
      >
        {{ tag.title }}
        <span v-if="!isAffix(tag)" class="el-icon-close" @click.prevent.stop="closeSelectedTag(tag)" />
      </router-link>
    </scroll-pane>
    <ul v-show="visible" :style="{left:left+'px',top:top+'px'}" class="contextmenu">
      <li @click="refreshSelectedTag(selectedTag)">Refresh</li>
      <li v-if="!isAffix(selectedTag)" @click="closeSelectedTag(selectedTag)">Close</li>
      <li @click="closeOthersTags">Close Others</li>
      <li @click="closeAllTags(selectedTag)">Close All</li>
    </ul>
  </div>
</template>
<script>
import scrollPane from "./ScrollPane.vue"
import path from "path"
export default {
  name: '',
  components:{scrollPane},
  data(){
    return{
      visible:false,
      top: 0,
      left: 0,
      selectedTag: {},
      affixTags: []
    }
  },
  watch:{
    $route() {
      console.log(987)
      this.addTags()
      this.moveToCurrentTag()
    },
    visible(value) {
      if (value) {
        document.body.addEventListener('click', this.closeMenu)
      } else {
        document.body.removeEventListener('click', this.closeMenu)
      }
    }
  },
  computed:{
    visitedViews() {
      return this.$store.state.tagsView.visitedViews
    },
    routes() {
      return this.$store.state.permission.routes
    }
  },
  methods:{
    isActive(route) {
      return route.path === this.$route.path
    },
    isAffix(tag) {
      return tag.meta && tag.meta.affix
    },
    addTags() {
      const { name } = this.$route
      if (name) {
        this.$store.dispatch('tagsView/addView', this.$route)
      }
      return false
    },
    filterAffixTags(routes,basePath = '/'){
      let tags = []
      routes.forEach(route => {
        if (route.meta && route.meta.affix) {
          const tagPath = path.resolve(basePath, route.path)
          tags.push({
            fullPath: tagPath,
            path: tagPath,
            name: route.name,
            meta: { ...route.meta }
          })
        }
        if (route.children) {
          const tempTags = this.filterAffixTags(route.children, route.path)
          if (tempTags.length >= 1) {
            tags = [...tags, ...tempTags]
          }
        }
      })
      return tags
    },
    moveToCurrentTag() {
      const tags = this.$refs.tag
      this.$nextTick(() => {
        for (const tag of tags) {
          if (tag.to.path === this.$route.path) {
            this.$refs.scrollPane.moveToTarget(tag)
            if (tag.to.fullPath !== this.$route.fullPath) {
              this.$store.dispatch('tagsView/updateVisitedView', this.$route)
            }
            break
          }
        }
      })
    },
    refreshSelectedTag(view) {
      this.$store.dispatch('tagsView/delCachedView', view).then(() => {
        const { fullPath } = view
        this.$nextTick(() => {
          this.$router.replace({
            path: '/redirect' + fullPath
          })
        })
      })
    },
    closeSelectedTag(view) {
      this.$store.dispatch('tagsView/delView', view).then(({ visitedViews }) => {
        if (this.isActive(view)) {
          this.toLastView(visitedViews, view)
        }
      })
    },
    closeOthersTags() {
      this.$router.push(this.selectedTag)
      this.$store.dispatch('tagsView/delOthersViews', this.selectedTag).then(() => {
        this.moveToCurrentTag()
      })
    },
    closeAllTags(view) {
      this.$store.dispatch('tagsView/delAllViews').then(({ visitedViews }) => {
        if (this.affixTags.some(tag => tag.path === view.path)) {
          return
        }
        this.toLastView(visitedViews, view)
      })
    },
    toLastView(visitedViews, view) {
      const latestView = visitedViews.slice(-1)[0]
      if (latestView) {
        this.$router.push(latestView.fullPath)
      } else {
        if (view.name === 'Dashboard') {
          this.$router.replace({ path: '/redirect' + view.fullPath })
        } else {
          this.$router.push('/')
        }
      }
    },
    openMenu(tag, e) {
      const menuMinWidth = 105
      const offsetLeft = this.$el.getBoundingClientRect().left 
      const offsetWidth = this.$el.offsetWidth
      const maxLeft = offsetWidth - menuMinWidth 
      const left = e.clientX + 15 
      if (left > maxLeft) {
        this.left = offsetLeft
      } else {
        this.left = left
      }

      this.top = e.clientY
      this.visible = true
      this.selectedTag = tag
    },
    closeMenu() {
      this.visible = false
    },
    handleScroll() {
      this.closeMenu()
    },
    initTags() {
      const affixTags = this.affixTags = this.filterAffixTags(this.routes)
      for (const tag of affixTags) {
        if (tag.name) {
          this.$store.dispatch('tagsView/addVisitedView', tag)
        }
      }
    },
  },
  mounted(){
    this.initTags()
    this.addTags()
  }

}
</script>

<style lang='scss' scoped>
.tags-view-container {
  height: 34px;
  width: 100%;
  background: #fff;
  border-bottom: 1px solid #d8dce5;
  box-shadow: 0 1px 3px 0 rgba(0, 0, 0, .12), 0 0 3px 0 rgba(0, 0, 0, .04);
  .tags-view-wrapper {
    .tags-view-item {
      display: inline-block;
      position: relative;
      cursor: pointer;
      height: 26px;
      line-height: 26px;
      border: 1px solid #d8dce5;
      color: #495060;
      background: #fff;
      padding: 0 8px;
      font-size: 12px;
      margin-left: 5px;
      margin-top: 4px;
      &:first-of-type {
        margin-left: 15px;
      }
      &:last-of-type {
        margin-right: 15px;
      }
      &.active {
        background-color: #42b983;
        color: #fff;
        border-color: #42b983;
        &::before {
          content: '';
          background: #fff;
          display: inline-block;
          width: 8px;
          height: 8px;
          border-radius: 50%;
          position: relative;
          margin-right: 2px;
        }
      }
    }
  }
  .contextmenu {
    margin: 0;
    background: #fff;
    z-index: 3000;
    position: absolute;
    list-style-type: none;
    padding: 5px 0;
    border-radius: 4px;
    font-size: 12px;
    font-weight: 400;
    color: #333;
    box-shadow: 2px 2px 3px 0 rgba(0, 0, 0, .3);
    li {
      margin: 0;
      padding: 7px 16px;
      cursor: pointer;
      &:hover {
        background: #eee;
      }
    }
  }
}
</style>

scrollPane组件

<template>
  <el-scrollbar ref="scrollContainer" :vertical="false" class="scroll-container" @wheel.native.prevent="handleScroll">
    <slot />
  </el-scrollbar>
</template>

<script>
const tagAndTagSpacing = 4 // tagAndTagSpacing

export default {
  name: 'ScrollPane',
  data() {
    return {
      left: 0
    }
  },
  computed: {
    scrollWrapper() {
      return this.$refs.scrollContainer.$refs.wrap
    }
  },
  mounted() {
    this.scrollWrapper.addEventListener('scroll', this.emitScroll, true)
  },
  beforeDestroy() {
    this.scrollWrapper.removeEventListener('scroll', this.emitScroll)
  },
  methods: {
    handleScroll(e) {
      const eventDelta = e.wheelDelta || -e.deltaY * 40
      const $scrollWrapper = this.scrollWrapper
      $scrollWrapper.scrollLeft = $scrollWrapper.scrollLeft + eventDelta / 4
    },
    emitScroll() {
      this.$emit('scroll')
    },
    moveToTarget(currentTag) {
      const $container = this.$refs.scrollContainer.$el
      const $containerWidth = $container.offsetWidth
      const $scrollWrapper = this.scrollWrapper
      const tagList = this.$parent.$refs.tag

      let firstTag = null
      let lastTag = null
      if (tagList.length > 0) {
        firstTag = tagList[0]
        lastTag = tagList[tagList.length - 1]
      }

      if (firstTag === currentTag) {
        $scrollWrapper.scrollLeft = 0
      } else if (lastTag === currentTag) {
        $scrollWrapper.scrollLeft = $scrollWrapper.scrollWidth - $containerWidth
      } else {
        const currentIndex = tagList.findIndex(item => item === currentTag)
        const prevTag = tagList[currentIndex - 1]
        const nextTag = tagList[currentIndex + 1]
        const afterNextTagOffsetLeft = nextTag.$el.offsetLeft + nextTag.$el.offsetWidth + tagAndTagSpacing
        const beforePrevTagOffsetLeft = prevTag.$el.offsetLeft - tagAndTagSpacing
        if (afterNextTagOffsetLeft > $scrollWrapper.scrollLeft + $containerWidth) {
          $scrollWrapper.scrollLeft = afterNextTagOffsetLeft - $containerWidth
        } else if (beforePrevTagOffsetLeft < $scrollWrapper.scrollLeft) {
          $scrollWrapper.scrollLeft = beforePrevTagOffsetLeft
        }
      }
    }
  }
}
</script>

<style lang="scss" scoped>
.scroll-container {
  white-space: nowrap;
  position: relative;
  overflow: hidden;
  width: 100%;
  ::v-deep {
    .el-scrollbar__bar {
      bottom: 0px;
    }
    .el-scrollbar__wrap {
      height: 49px;
    }
  }
}
</style>

很多方法都是从vue-element-admin拿过来用的 但是关键的vuex数据获取 存储 给简化了  具体就不展示了 想要代码的小伙伴 私信我我上传到gitBub后给地址 自己下载看吧 希望对小伙伴的提升有所帮助!

### Vue3 动态路由与左侧菜单实现 在 Vue3 中实现动态路由以及左侧菜单的功能,可以通过结合后端接口请求的方式完成。以下是具体的实现方法: #### 1. 获取后端路由数据 通过 HTTP 请求从后端获取用户的权限路由表。这些路由通常由用户角色决定,因此可以根据不同的角色返回对应的路由结构。 ```javascript // 使用 Axios 或其他库发起请求 async function fetchRoutes() { const response = await axios.get('/api/getUserRoutes'); // 假设这是后端接口地址 return response.data.routes; // 返回路由数组 } ``` 这部分逻辑可以从引用中提取到[^2],其中提到前端结合后端接口请求来实现动态路由是一种常见的权限控制方案。 --- #### 2. 添加动态路由至 Vue Router 当收到后端返回的路由数据后,将其解析并添加到 Vue Router 的实例中。 ```javascript import { createRouter, createWebHistory } from 'vue-router'; let router = null; function setupDynamicRoutes(routesFromBackend) { routesFromBackend.forEach(route => { router.addRoute(route); // 将每个路由对象添加到路由器中 }); } export async function initializeRouter(app) { const routes = [ { path: '/', component: () => import('@/views/Home.vue'), name: 'Home' } ]; router = createRouter({ history: createWebHistory(), routes, }); app.use(router); // 初始化完成后加载动态路由 const backendRoutes = await fetchRoutes(); setupDynamicRoutes(backendRoutes); } ``` 上述代码展示了如何将后端返回的路由注册到 `Vue Router` 中[^4]。需要注意的是,父级路由应包含布局组件(如 `DEFAULT_LAYOUT`),而子路由才是具体的内容页面。 --- #### 3. 渲染左侧菜单 为了使左侧菜单能够动态更新,需基于路由数据生成菜单项。这一步骤可以在 Vuex 或 Pinia 中管理状态,并传递给菜单组件。 ```javascript // store.js (Pinia 示例) import { defineStore } from 'pinia'; import { ref } from 'vue'; export const useMenuStore = defineStore('menu', () => { const menuItems = ref([]); function setMenus(routers) { menuItems.value = routers.map(item => ({ title: item.meta.menuName || '', key: item.name, children: item.children?.map(child => ({ ...child })) })); } return { menuItems, setMenus }; }); ``` 接着,在菜单组件中绑定该数据源: ```html <template> <div class="side-menu"> <a-menu v-model:selectedKeys="selectedKeys" mode="inline"> <template v-for="(item, index) in menuItems" :key="index"> <!-- 如果存在子菜单 --> <a-sub-menu v-if="item.children && item.children.length > 0" :key="item.key"> <span>{{ item.title }}</span> <template v-for="(subItem, subIndex) in item.children" :key="subIndex"> <a-menu-item @click="navigateTo(subItem)"> {{ subItem.meta.menuName }} </a-menu-item> </template> </a-sub-menu> <!-- 单层菜单 --> <a-menu-item v-else @click="navigateTo(item)" :key="item.key"> {{ item.title }} </a-menu-item> </template> </a-menu> </div> </template> <script> import { useRouter } from 'vue-router'; import { useMenuStore } from '@/store/menu'; export default { setup() { const router = useRouter(); const menuStore = useMenuStore(); function navigateTo(menuItem) { router.push({ name: menuItem.name }); } return { selectedKeys: [], menuItems: menuStore.menuItems, navigateTo }; }, }; </script> ``` 这里利用了 Ant Design Vue 的 `<a-menu>` 组件作为示例[^3],当然也可以替换为 Layui 提供的相关控件。 --- #### 4. 处理多级嵌套菜单 如果需要支持二级以上菜单,则应在初始化阶段递归处理路由树结构。例如: ```javascript function buildNestedMenu(routes) { return routes.map(route => { const menuItem = { title: route.meta.menuName || '', key: route.name, }; if (route.children && route.children.length > 0) { menuItem.children = buildNestedMenu(route.children); } return menuItem; }); } ``` 调用此函数即可构建完整的菜单层次结构。 --- #### 5. 缓存机制与 Tag 路由选项卡 对于已访问过的页面,可通过 Vuex/Pinia 存储其记录,并配合标签页形式展示。每次切换时更新当前激活的状态。 ```javascript const visitedViews = ref([]); function addVisitedView(view) { if (!visitedViews.value.some(v => v.path === view.path)) { visitedViews.value.push(Object.assign({}, view)); } } ``` 随后在视图关闭事件中移除对应条目。 --- ### 总结 综上所述,Vue3 动态路由与左侧菜单的实现主要分为以下几个方面: 1. **从后端拉取路由配置**; 2. **动态注入路由规则**; 3. **根据路由生成菜单列表**; 4. **扩展功能如缓存和 Tags View 支持**。 ---
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

沫熙瑾年

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值