Vue 3 + Element Plus 后台导航菜单与标签页联动实践
1. 为什么要做这个组合从“一次只能看一个页面”到“随时切换工作现场”早年做后台管理系统的时候我踩过一个特别典型的坑用户打开一个菜单页面处理到一半想去看另一个数据结果当前填的表单全丢了。返回再进来状态归零只能重来。刚开始我觉得这是产品设计问题后来才发现根子在于单页面应用把所有内容都塞进了一个“主内容区”菜单点击一次就切换一次路由上一个页面就被整个卸载了。这种体验在B端系统里是致命伤。管理人员、运营人员处理业务时经常需要在多个页面之间来回参照。比如一边看订单列表一边查客户详情一边改售后记录。如果每次切换都要重新加载、重新翻页、重新搜索效率低到用户会直接在群里骂产品。后来很多成熟的框架比如vue-element-admin、若依这些都引入了“导航菜单标签页”的模式才把这个问题解决得比较干净。要说清楚这件事就不能绕开两个核心组件NavMenu和TabControl。NavMenu负责“能去哪些页面”是入口TabControl负责“当前正在处理哪些页面”是现场。把这两个东西配合好用户在系统里的每一次操作都能保留一个“工作现场”什么时候切走、什么时候切回来都跟没离开过一样。这套设计思路从桌面端发展起来Web端大量借鉴最终成了后台管理系统的标配。这篇文章我就拿实际项目里最常用的Vue 3 Element Plus Vue Router这套组合来讲把两者的联动逻辑一步步拆开。同时我也会带一句桌面端的思路——很多WPF、WinForms项目里也用TabControl原理是相通的。你只要弄懂“导航条目、标签页集合、内容渲染区域”三者的关系在任何技术栈里都能落地。2. NavMenu和TabControl各自的定位与脾气2.1 NavMenu只管“入口”不该管“状态”很多新手会把NavMenu理解成一个普通的菜单组件其实它的定位比想象中简单把路由表映射成一棵可以点击的树。它本身不记录你打开过什么也不关心当前展示的页面是否被缓存过。它只做两件事一是根据default-active或active属性高亮当前菜单项二是抛出select事件告诉你用户点了哪个菜单项。Element Plus的NavMenu组件结构大致是这样的el-menu :default-activeactiveMenu :routerfalse selecthandleMenuSelect el-sub-menu index/dashboard template #title工作台/template el-menu-item index/dashboard/overview数据概览/el-menu-item el-menu-item index/dashboard/analysis运营分析/el-menu-item /el-sub-menu el-menu-item index/order/list订单管理/el-menu-item /el-menu我见过不少人直接把el-menu的router属性设为true这样点击菜单项时组件内部会直接用路由跳转。这个做法在简单页面里能用但一旦你要和TabControl做联动就不太合适了。因为这种内置跳转绕过了你自己对“标签页集合”的控制逻辑。我的建议是关掉内置router行为手动接管select事件。这样“打开标签页”这件事就是可控的、可干预的而不是听天由命地直接换路由。2.2 TabControl管“现场”但它只是一个展示层TabControl这个词如果是桌面端出身的人第一反应是WinForms和WPF里那个带标签页的容器控件。到了Web端并没有一个官方组件叫“TabControl”但Element Plus里有el-tabs以及社区后台模板里常见的TagsView本质上都是TabControl的孪生兄弟。TabControl的核心职责是维护一组“已打开的页面记录”。每条记录至少要包含页面路径、页面标题、是否可关闭、是否激活。它自身不负责渲染页面内容只负责渲染一排标签以及在标签被点击、被关闭时发出对应事件。真正显示内容的还是主内容区通常是路由出口router-view。搞清楚这个分工很重要。我见过有人把页面组件直接写进了TabControl的标签内容里然后在el-tab-pane里面塞一整块业务页面。这种方法不是不能运行但会让标签页组件和业务页面形成强耦合后续想加缓存、想动态控制关闭按钮都会变得非常别扭。正确的做法是TabControl只管理标签页面显示交给路由两者通过当前路由路径关联起来。维度NavMenuTabControl核心职责提供导航入口展示已打开页面集合数据来源路由表/菜单配置用户操作记录关注的焦点我能去哪我在哪、去过哪事件输出selecttab-click、tab-remove是否缓存页面不缓存不直接缓存与keep-alive配合2.3 两者的关系菜单是“因”标签是“果”组合起来用的时候逻辑应该是一条单行道用户在NavMenu上点击菜单系统把这个菜单项转换成一条页面记录放进TabControl的标签集合里然后路由跳转、标签激活、菜单高亮三件事同步完成。反过来用户在TabControl上点击或关闭标签也应该反推回菜单高亮和路由跳转。这套联动说得直白一点就是“入口动作改变状态状态驱动界面刷新”。NavMenu是用户发起动作的地方TabControl是展示状态的地方路由和缓存机制负责让页面内容跟着状态走。三者缺一不可但各自的边界不能混。3. 基础搭建菜单、路由表、标签页三件套3.1 演示环境与项目结构我用Vue 3 Element Plus Vue Router 4来演示项目已经通过npm create vuelatest创建好并且全局注册了Element Plus。接下来的几个文件是核心src/router/index.js路由定义src/layout/components/SidebarMenu.vue侧边栏NavMenu组件src/layout/components/TabsView.vueTabControl标签页组件src/store/tabs.js标签页状态管理Piniasrc/layout/index.vue整体布局组合上面所有组件3.2 路由表设计菜单和页面必须共用同一份配置路由表是菜单和标签页共同的“数据契约”。一个菜单项点击后要跳到哪里一个标签页关闭后要回到哪里全都要靠路由路径来关联。推荐的做法是给路由表增加几个自定义字段比如title和icon用于菜单渲染和标签页标题显示。// src/router/index.js import { createRouter, createWebHistory } from vue-router const routes [ { path: /login, name: Login, component: () import(/views/Login.vue), meta: { hidden: true } }, { path: /, component: () import(/layout/index.vue), redirect: /dashboard, children: [ { path: dashboard, name: Dashboard, component: () import(/views/Dashboard.vue), meta: { title: 工作台, icon: Odometer } }, { path: order/list, name: OrderList, component: () import(/views/order/List.vue), meta: { title: 订单管理, icon: List } }, { path: order/detail/:id, name: OrderDetail, component: () import(/views/order/Detail.vue), meta: { title: 订单详情, activeMenu: /order/list } } ] } ]注意到order/detail/:id这条路由它的meta.activeMenu字段指向/order/list。原因是详情页通常不带菜单项打开详情页时侧边栏需要继续高亮订单管理这一项。如果你没有这个字段NavMenu的默认高亮逻辑会根据当前路径去匹配匹配不到就整片灰掉很丑也很困惑。3.3 SidebarMenu组件把路由表变成可点击菜单菜单渲染的逻辑比较直接遍历路由表找到meta.hidden不为true的路由有子路由就用el-sub-menu否则用el-menu-item。这里我把它写成一个递归组件便于处理无限层级菜单。// src/layout/components/SidebarMenu.vue template el-menu :default-activeactiveMenu :collapseisCollapse selecthandleSelect SidebarItem v-forroute in menuRoutes :keyroute.path :itemroute / /el-menu /template script setup import { computed } from vue import { useRoute, useRouter } from vue-router import { useTabsStore } from /store/tabs import SidebarItem from ./SidebarItem.vue const route useRoute() const router useRouter() const tabsStore useTabsStore() const activeMenu computed(() route.meta.activeMenu || route.path) function handleSelect(index) { // 这里就是联动的关键一步先加标签再跳路由 tabsStore.addTab({ path: index, title: getMenuTitle(index) }) router.push(index) } /script这里有两个细节值得展开。第一个细节getMenuTitle不能简单地从index里反查路由配置。最可靠的方式是借助Vue Router的router.getRoutes()它能拿到所有注册过的路由记录然后用path匹配到对应的meta.title。如果你的菜单是后端接口返回的那就应该把菜单数据做成一个Mappath, title的映射表不要靠字符串截取。第二个细节el-menu的default-active必须绑定到一个响应式的computed上。否则点击菜单项后即使路由已经变化菜单的高亮也不会跟着走。这一点在标签页上直接点击关闭、路由跳转到另一个菜单项时尤其重要——菜单必须能感知路由变化。3.4 TabsView组件标签页的渲染交互骨架TabsView组件看起来就是一组el-tabs但有几个点需要单独处理标签可关闭、当前激活项跟随路由、关闭标签时触发后续逻辑。基础版本如下// src/layout/components/TabsView.vue template div classtabs-view el-tabs typecard :model-valueactiveTab tab-clickhandleTabClick tab-removehandleTabRemove el-tab-pane v-fortab in tabsStore.tabs :keytab.path :labeltab.title :nametab.path :closabletab.closable ! false !-- 注意这里不放页面内容 -- /el-tab-pane /el-tabs /div /template script setup import { computed } from vue import { useRoute, useRouter } from vue-router import { useTabsStore } from /store/tabs const route useRoute() const router useRouter() const tabsStore useTabsStore() const activeTab computed(() route.path) /script标签页的name绑定的是路由路径这是个关键约定。有了这个约定点击标签跳转、删除标签处理、从URL恢复标签集合等逻辑全都能用一个path串起来。页面的内容不放进el-tab-pane而是放在Layout主区域的router-view里标签只做指示器。4. 点菜单开标签页事件链路设计与状态同步4.1 标签页状态仓库用Pinia管住“已打开页面”标签集合是多个组件共享的状态直接用组件的局部变量肯定不行。我用Pinia来管理状态仓库里维护三个核心字段和方法// src/store/tabs.js import { defineStore } from pinia export const useTabsStore defineStore(tabs, { state: () ({ tabs: [] }), actions: { addTab(tab) { const exists this.tabs.some(item item.path tab.path) if (!exists) { this.tabs.push(tab) } }, removeTab(path) { const index this.tabs.findIndex(item item.path path) if (index -1) { this.tabs.splice(index, 1) } }, closeOthers(path) { this.tabs this.tabs.filter( item item.path path || item.closable false ) }, closeAll() { this.tabs this.tabs.filter(item item.closable false) } } })closable这个字段很有用。工作台、首页这类页面一般不允许关闭如果用户把所有标签都关完了主内容区就会空白系统连个兜底页面都没有。所以我在addTab的时候会判断如果path是/dashboard就强制设置closable: false。4.2 点击菜单后的完整链路当用户点击侧边栏菜单项时完整的事件链路是这样的NavMenu抛出select事件携带被点击菜单项的index。handleSelect方法调用tabsStore.addTab把path和title构成的对象添加到标签集合。同时调用router.push(index)进行路由跳转。路由变化后router-view渲染新页面activeMenu计算属性自动更新菜单高亮同步。activeTab计算属性也随着路由变化更新TabControl的激活标签自动切换到新标签。这里有一个很容易被忽略的问题如果用户连续点击同一个菜单项三次addTab方法里的some判断会拦截掉重复添加但router.push还是会执行。重复推入相同路由不会导致页面重新刷新但会在控制台产生一个重复导航警告。解决方式是在handleSelect里先判断当前路由是否就是目标路由是的话直接return。function handleSelect(index) { if (route.path index) return tabsStore.addTab({ path: index, title: getMenuTitle(index) }) router.push(index) }4.3 标签页点击事件切换导航与同步菜单点击标签时要切换到对应的路由。这里注意el-tabs的tab-click事件参数是TabsPaneContext对象可以用pane.paneName拿到我们设置的name值也就是path。直接router.push过去即可function handleTabClick(pane) { const path pane.paneName if (path ! route.path) { router.push(path) } }菜单高亮不需要额外处理因为activeMenu是通过当前路由的meta.activeMenu或path计算出来的路由切换后自动更新。这就是用路由作为唯一数据源的好处。4.4 动态菜单标题遇到动态路由怎么办订单详情这种动态路由如果标题固定叫“订单详情”那打开多个不同的订单时标签页上会显示多个一模一样的“订单详情”用户根本分不清谁是谁。实际项目里应该用路由参数生成标题例如“订单详情 #12345”。实现方式有两种。一种是组件内部在onMounted时获取详情数据然后调用一个updateTabTitle方法更新标签标题// 页面组件内 import { useRoute } from vue-router import { useTabsStore } from /store/tabs const route useRoute() const tabsStore useTabsStore() onMounted(async () { const detail await fetchOrderDetail(route.params.id) tabsStore.updateTabTitle(route.path, 订单详情 #${detail.orderNo}) })另一种是所有路由都约定好meta.title为静态标题动态页在组件内调用document.title时顺带更新标签仓库。我倾向于第一种因为标签标题和页面展示的标题一致视觉上不会出现“看到的是订单A标签写的却是订单B”的割裂。5. 标签页的关闭、切换与状态保持细节都藏在这里5.1 关闭标签页时的路由跳转策略用户点关闭按钮时如果关闭的不是当前激活的标签那很简单直接从标签集合里移除就行路由不用动。但如果关闭的正是当前页面问题就复杂了关闭后应该跳去哪这里的策略一般有三种跳到最近访问过的标签页跳到左侧相邻的标签页跳到固定首页我测试下来“跳到左侧相邻标签”最符合直觉。原因很简单用户浏览的习惯是从左往右关闭当前标签后他的注意力大概率还在附近跳到左边相邻的页面不会让他觉得突兀。实现代码是function handleTabRemove(targetPath) { const targetIndex tabsStore.tabs.findIndex(item item.path targetPath) if (targetIndex -1) return // 如果要关闭的是当前激活页 if (route.path targetPath) { const nextTab tabsStore.tabs[targetIndex - 1] || tabsStore.tabs[targetIndex 1] if (nextTab) { router.push(nextTab.path) } else { router.push(/dashboard) } } tabsStore.removeTab(targetPath) }这里一定要先跳路由再移标签顺序不能反。如果先移除标签路由匹配到的是一个已经不存在的记录就会跑到通配路由去。顺序对了用户看到的过渡起码是顺滑的。5.2 关闭其他、关闭全部不可关闭页的兜底逻辑右键菜单里的“关闭其他”“关闭全部”是老功能了实现不复杂但兜底逻辑要想清楚。“关闭全部”不能把所有标签全删光至少要保留一个不可关闭的首页标签。这也是前面在addTab里设置closable: false的意义所在。function closeAllTabs() { tabsStore.closeAll() // 如果当前页面被关了跳到剩下的第一个标签 const remaining tabsStore.tabs[0] if (remaining remaining.path ! route.path) { router.push(remaining.path) } }5.3 页面状态保持keep-alive的正确挂载方式标签页给人的最大好处就是切换出去再切回来页面状态还在。但默认情况下Vue Router在路由切换后会把旧页面组件卸载所有状态清零。要让页面“记住”自己的状态必须配合keep-alive。关键点来了keep-alive的include属性需要一组组件名而不是路由路径。组件名是页面组件里defineOptions({ name: OrderList })定义的那个name跟路由的meta.title完全不是一回事。很多人在这里卡住明明include写了一大串页面缓存就是不生效一查才发现include里填的是路径。实际操作里我通常把include数组也放进Pinia管理在addTab时顺便收集当前路由对应的组件名// Layout的router-view部分 router-view v-slot{ Component } keep-alive :includetabsStore.cachedViews component :isComponent / /keep-alive /router-viewcachedViews从哪来在路由跳转时通过router.currentRoute.value.matched拿到匹配的组件定义取name字段加入数组。也可以用route.meta.noCache来控制哪些页面不缓存——比如一些要求每次都重新加载数据的报表页面给路由meta加个标记过滤掉就行。6. 从URL刷新恢复标签这个能力很多人没做6.1 为什么刷新后标签会全部丢失如果不做额外处理用户按一下F5整个Pinia回到初始状态标签集合清空只显示当前访问的那个页面。这在开发环境里无所谓生产环境就会招来投诉“我刚才打开的那么多页面呢”恢复的思路是在应用初始化时根据当前路由反向构建一个标签集合。因为路由是唯一的入口只要用户刷新后还能访问当前页面就说明这个页面是合法的那么它就应该出现在标签栏里。其它历史标签没办法完美恢复除非你把标签集合持久化到localStorage或sessionStorage里。6.2 基于路由的反向恢复方案我在Layout组件的onMounted里调用一个初始化方法// src/layout/index.vue import { onMounted } from vue import { useRoute } from vue-router import { useTabsStore } from /store/tabs const tabsStore useTabsStore() const route useRoute() onMounted(() { if (!tabsStore.tabs.length route.path ! /login) { tabsStore.addTab({ path: route.path, title: getRouteTitle(route) }) } })这个方案只恢复当前页一个标签但配合localStorage可以做完整恢复。做法是把tabsStore的tabs数组在每次变化时序列化存到localStorage刷新后从localStorage读回来。需要注意的是存进去之前要过滤掉closable: false的首页标签因为那些是每次初始化都会重新生成的固定项重复恢复会产生两个一模一样的首页标签。6.3 恢复动态路由标签时的坑如果标签集合里存的是/order/detail/123刷新后直接用这个path调router.push必须保证该路由已经动态注册过。否则Vue Router会匹配到通配路由页面变成404。在实际项目中服务端返回的菜单和路由表通常是异步加载的刷新时需要在异步路由注入完成后再根据localStorage里的标签集合逐个校验并恢复。校验的方式是检查path是否存在于router.getRoutes()列表中不存在的标签直接丢弃不要勉强恢复。7. 权限菜单、iframe嵌套与真实项目里的变体7.1 动态菜单和路由双轨制大部分正规后台系统菜单不是写死在路由表里的而是登录后从接口拿。接口返回的是菜单树里面可能包含按钮权限标识、组件路径、路由路径。这种情况下前端要做两件事一是根据菜单数据动态注册路由二是根据同一份数据渲染NavMenu。我的做法是维护一份“菜单配置”数据源它既不是纯前端路由表也不是纯后端返回的原始JSON而是经过一次映射转换之后的视图模型// 后端返回的菜单项 const menuFromServer [ { id: 2, parentId: 0, path: /order, component: layout/index, children: [ { id: 21, parentId: 2, path: /order/list, component: order/List, title: 订单管理, icon: List } ] } ]渲染菜单时直接遍历这份菜单配置注册路由时也基于这份菜单配置动态执行router.addRoute。这样菜单和路由天然保持同步不需要两套映射关系。标签页的标题也从这份配置里取保证三方一致。7.2 动态权限下的标签页守卫有了路由权限控制后标签页也会出现一个边界问题当前用户刷新页面时Vue Router会重新校验所有路由。如果/order/detail/123需要权限而用户已经掉了权限那么在router.beforeEach里应该跳转到403页面同时把那个标签从TabControl里移除。否则标签栏里会出现一个点了就跳403的“幽灵标签”。处理逻辑不复杂在路由守卫里加一步router.beforeEach((to, from, next) { if (to.meta.requiresAuth !hasPermission(to.path)) { const tabsStore useTabsStore() tabsStore.removeTab(to.path) next(/403) return } next() })7.3 iframe页面不能走keep-alive有些遗留系统是iframe嵌入第三方页面的这种标签页有一个特殊问题iframe内部的浏览状态只由浏览器原生管理不归Vue管keep-alive对iframe完全无效。而且默认情况下标签切换时iframe里的页面会被移除重挂重新触发一次加载。解决方案是把key设为iframe的src而不是路由path让同一个src的iframe在Vue的Diff过程中被复用。还有一种延迟加载的策略页面首次激活后再设置src减少一开始同时创建多个iframe的性能压力iframe v-ififrameUrl :srciframeUrl :keyiframeUrl frameborder0 /实测下来同时打开五六个iframe标签页面加载速度和内存占用会明显上升。所以iframe页面缓存能不上就不上实在躲不开至少用v-if控制延迟创建。8. 我踩过的坑和最终建议做完这套联动之后有段时间我统计了用户反馈发现最频繁的抱怨集中在两类一类是“页面关掉了但再点菜单打开的却是旧数据”另一类是“标签栏越来越多分不清哪个是哪个”。前者是keep-alive的缓存策略太粗暴所有页面都缓存导致的后者是动态路由标题没有及时更新导致的。针对前者我给所有列表页都加了一个“刷新按钮”点击时通过路由替换重新进入同一个页面的方式强制刷新数据。这个做法的原理是用{ path: /order/list, query: { t: Date.now() } }这种带时间戳的query触发路由变化然后监听route.query.t的变化重新拉取数据比直接把页面移出缓存再移回来要稳得多。针对后者我总结出一条硬性原则标签标题必须具体到用户一眼能看懂。动态详情页就带编号同一个模块的列表页标签保持在五个以内超出后自动用下拉菜单折叠。这些功能看着小却是标签页系统能否被用户接受的分水岭。如果你现在正准备做一个后台系统我的建议是先把这套“菜单-标签-路由-缓存”四件套做好地基再去做业务页面。因为后面所有页面都跑在这个框架之上一旦地基设计得别扭后面每个页面都跟着难受。反过来的话哪怕业务页面写得一般只要导航和标签页交互顺畅用户的体验分也会高不少。最后分享一个调试技巧在开发阶段把Pinia的tabs数组挂到window上随时在控制台里查看当前标签集合的状态。排查“标签和菜单不同步”这类问题的时候这一招比打一堆log快多了。
上一篇/下一篇内容由系统自动关联
返回资讯列表 →