首页 / 资讯中心 / 文章详情

Vue后台管理系统:基于路由表驱动的顶部一级与左侧二级菜单联动方案

Vue后台管理系统:基于路由表驱动的顶部一级与左侧二级菜单联动方案 ★ FEATURED ARTICLE
做后台管理系统也有五六年了Vue ElementUI 的组合我前后在不下五个项目里用过。最近团队新起了一个中后台项目需求方明确提了一个很常见的布局诉求顶部放一级菜单左侧根据顶部所选模块动态展示对应的二级菜单栏。这种布局在电商运营后台、数据看板系统里非常常见尤其是那种一级模块只有四五个、但每个模块下面有几十个子页面的大型后台。这篇文章就把这套方案的完整落地过程写出来包括路由怎么设计、菜单数据怎么组织、顶栏和侧边栏怎么联动、高亮和展开状态怎么保持以及我在实操中踩过的一些坑。如果你是刚接触 Vue 后台开发或者正在为菜单布局发愁这文章可以直接照着抄。1. 总体设计思路菜单布局方案选型与数据驱动模型1.1 为什么选“顶部一级 左侧二级”而不是纯左侧菜单先说说方案背景。现在后台管理系统的主流布局有三类纯左侧菜单、顶部一级加左侧二级、纯顶部菜单。纯左侧菜单是最常见的把所有模块都塞进侧边栏简单粗暴但有一个很现实的问题——当业务模块超过一定数量比如一级菜单有八个、二级菜单每个又有七八项侧边栏会变得非常长用户得不停滚动体验很差。顶部一级加左侧二级的方案核心优势在于把菜单做了空间隔离顶部只放四五个一级模块点到哪个模块左侧才显示这个模块下的二级菜单。这样一来任意时刻侧边栏只会展示当前活跃模块的内容视觉负担小而且非常适合那种“模块间业务相对独立、用户往往只关心其中一两个模块”的后台场景。我在这次项目里还考虑过一个变体方案顶部一级、左侧二级和三级混合。后来跟产品和前端一起讨论决定先按“顶部一级 左侧最多两级”来规划。如果左侧二级下面还要挂三级页面就通过 tabs 页签或者页面内跳转去处理避免左侧菜单层级过深。这个取舍很重要因为 ElementUI 的 el-menu 虽然支持多级嵌套但它有个天然问题——折叠状态下弹层层级容易出 bug菜单越深越容易出现选中态错乱。1.2 菜单数据单一来源用路由表驱动菜单渲染这是整篇文章最核心的一个设计理念菜单数据不单独维护一份而是从路由表里自动提取。为什么要这么做我刚做后台那会儿见过很多项目的写法是后端返回一份菜单 JSON前端再写一份路由表菜单和路由各管各的。结果就是后端的菜单配置改了前端路由没同步更新或者前端路由加了新页面忘了去菜单配置文件里加一项最后页面能通过 URL 直接访问但菜单里找不到入口。把路由表作为菜单数据的唯一来源之后这个“双份数据不一致”的问题就从根本上消失了。路由表里写什么结构侧边栏就渲染什么菜单要加页面就先加路由菜单自动生成要隐藏某个菜单项就在路由的 meta 里标记一下。后端权限控制要做也只需要在拿到路由表之后做一次过滤逻辑非常干净。这次项目我采用的是静态路由表加后端动态权限的混合模式。基础路由登录页、404、首页写死在代码里业务路由定义在一份常量数组里前端根据用户权限过滤后再用router.addRoute动态注册。这样既能保证权限控制的有效性又不会让路由表的维护变得太复杂。2. 整体布局与菜单数据模型构建2.1 使用 el-container 搭建页面主框架ElementUI 的布局容器组件提供了 el-container、el-header、el-aside、el-main 这套组合专门用于搭建后台页面的基础框架。对于“顶部一级 左侧二级”的布局我用的是这样的结构template el-container classlayout-wrapper el-header classlayout-header div classheader-left div classlogo运营后台/div !-- 顶部一级菜单 -- TopMenu :menustopMenus :active-menucurrentTopMenu selecthandleTopMenuSelect / /div div classheader-right el-dropdown span classuser-info管理员/span el-dropdown-menu slotdropdown el-dropdown-item退出登录/el-dropdown-item /el-dropdown-menu /el-dropdown /div /el-header el-container classlayout-body !-- 左侧二级菜单 -- el-aside width240px classlayout-aside SidebarMenu :menuscurrentSideMenus :active-menuactivePath :open-namesopenNames / /el-aside el-main classlayout-main router-view / /el-main /el-container /el-container /template这个结构有个细节值得注意el-container可以嵌套使用外层负责“头部 主体”内层负责“侧边栏 内容区”。如果不嵌套把 el-header、el-aside、el-main 平级放侧边栏就会被拉到 header 下面而不是贴合整个页面左侧。这是我早期踩过的坑Layout 组件一定要分层嵌套才能得到理想的视觉效果。另外el-aside 的宽度我用的是固定 240px。有些项目会做菜单折叠功能折叠状态下切到 64px这时候需要给 el-aside 动态绑定 width并且考虑折叠后只有图标没有文字的状态。这次项目没做折叠功能的需求我就先固定宽度保持代码简单。但如果你要做折叠一定要记得把aside的width用计算属性管理同时 el-menu 的collapse属性要保持同步。2.2 路由表结构与一级二级菜单数据的自动提取路由表是数据源所以它的结构设计直接决定了菜单渲染的复杂度。我的路由表设计原则是一级路由代表一个顶部模块二级路由代表这个模块下的侧边栏分组三级路由代表具体的页面。先看一个简化后的路由定义示例// router/modules/order.js const orderModule { path: /order, component: Layout, meta: { title: 订单管理, icon: el-icon-s-order, hidden: false }, children: [ { path: list, component: () import(/views/order/list.vue), meta: { title: 订单列表 } }, { path: refund, component: () import(/views/order/refund.vue), meta: { title: 退款管理 } }, { // 有子菜单的情况children 下再挂页面 path: after-sale, meta: { title: 售后管理 }, children: [ { path: complaint, component: () import(/views/order/complaint.vue), meta: { title: 投诉处理 } }, { path: return-goods, component: () import(/views/order/return-goods.vue), meta: { title: 退货管理 } } ] } ] }从这份路由表里提取顶部菜单的逻辑很简单遍历路由表中注册的业务模块通常直接来自一份模块常量数组取每个模块的path、meta.title、meta.icon。左侧菜单则需要根据当前激活的顶部模块找到对应的路由对象取出它的 children 数组传给侧边栏组件做渲染。读取children的时候有个细节要特别注意如果一级路由的 component 是 Layout 布局组件并且使用了router-view做嵌套出口二级路由的 path 就不要加/前缀。比如/order/list这种完整路径会写到父路由上子路由只用listVue Router 会自动拼接成/order/list。如果子路由不加判断地写成/list页面会跳到根路径下菜单高亮和路由匹配都会出问题。2.3 菜单渲染的三种数据形态我做了三个计算属性分别用于不同的渲染场景这也是整套菜单体系的核心数据模型computed: { // 顶部一级菜单格式[{ path: /order, title: 订单管理, icon: el-icon-s-order }] topMenus() { return this.moduleRoutes.map(route ({ path: route.path, title: route.meta.title, icon: route.meta.icon })) }, // 当前激活的顶部菜单 path例如 /order currentTopMenu() { return / this.$route.path.split(/)[1] }, // 当前顶部菜单对应的左侧子菜单 currentSideMenus() { const currentModule this.moduleRoutes.find(route route.path this.currentTopMenu) return currentModule ? currentModule.children : [] }, // 当前路由路径用于侧边栏高亮 activePath() { return this.$route.path }, // 当前路由匹配到的父级路径用于展开对应的子菜单 openNames() { const matched this.$route.matched return matched.map(item item.path) } }这几个计算属性是整个菜单联动的地基。理解了它们后面顶栏和侧边栏的组件代码就只是“把这些数据渲染出来”的问题。currentTopMenu这里的写法我特别说明一下用$route.path.split(/)[1]去取一级路径比在created里手动存一个变量要可靠得多。因为刷新页面之后组件重新实例化所有状态都要从路由里恢复。只要路由不变状态就不会丢。3. 核心实现顶栏一级菜单与侧边栏二级菜单的联动逻辑3.1 顶部一级菜单栏实现顶栏菜单用 ElementUI 的el-menu横向模式实现:modehorizontal即可。代码如下template el-menu modehorizontal :default-activeactiveMenu :routertrue classtop-menu el-menu-item v-foritem in menus :keyitem.path :indexitem.path i :classitem.icon/i span{{ item.title }}/span /el-menu-item /el-menu /template script export default { name: TopMenu, props: { menus: { type: Array, required: true }, activeMenu: { type: String, required: true } } } /script这里有两个关键点。第一个el-menu上开了:routertrue。这一项非常关键——ElementUI 的 menu 组件在开启 router 模式之后点击el-menu-item会直接调用router.push(index)也就是说el-menu-item的 index 会被当作路由地址跳转。这样就不用手动监听 select 事件再调一次 router.push少写不少代码。要注意如果没有开启 router 模式点击菜单只是切换选中态不做路由跳转很容易出现“菜单变了但页面没变”的诡异现象。第二个默认选中项activeMenu是从父组件传下来的currentTopMenu通过 prop 传入。这样顶部菜单的高亮就完全由路由决定。任何时刻用户刷新页面浏览器地址栏里的路径都会自动映射回正确的顶部一级菜单。顶部菜单这里我没用router-view的嵌套出口去切内容因为点击顶部菜单时真正需要变化的其实有两个东西左侧菜单和右侧内容区。左侧菜单的变化是通过currentSideMenus去驱动的右侧内容区则靠路由变化去匹配。3.2 侧边栏二级菜单实现侧边栏菜单同样用el-menu不同点是使用垂直模式并且需要开启unique-opened来保证同时只展开一个子菜单。二维数据的渲染代码如下template el-menu classsidebar-menu :default-activeactiveMenu :default-openedsopenNames :routertrue :unique-openedtrue template v-formenu in menus !-- 没有 children 的普通菜单项 -- el-menu-item v-if!menu.children || menu.children.length 0 :keymenu.path :indexgetFullPath(menu) i :classmenu.meta.icon/i span slottitle{{ menu.meta.title }}/span /el-menu-item !-- 有 children 的嵌套菜单 -- el-submenu v-else :keymenu.path :indexgetFullPath(menu) template slottitle i :classmenu.meta.icon/i span{{ menu.meta.title }}/span /template el-menu-item v-forchild in menu.children :keychild.path :indexgetFullPath(child) {{ child.meta.title }} /el-menu-item /el-submenu /template /el-menu /template这里最核心的是getFullPath这个方法它负责把嵌套路由的相对路径拼成完整路径methods: { getFullPath(route) { // 如果路径以 / 开头直接返回否则拼接父路径 return route.path.startsWith(/) ? route.path : /${route.path.split(/)[1]}/${route.path} } }这里最容易踩的坑是路径拼接。路由表里子路由通常只写相对路径比如list但 el-menu-item 的 index 需要的是完整路径/order/list因为 router 模式会直接把 index 当路由地址 push。如果 index 写的是list点击菜单会跳转到/list然后直接 404。很多新手都会在这里卡住排查半天发现是 index 路径的问题。侧边栏的default-openeds我传的是openNames也就是从当前路由 matched 里提取的父路径数组。这样用户刷新页面后左侧菜单会自动展开当前页面所在的父级分组高亮和展开状态都不会丢。完整路径的一个细节openNames里因为$route.matched的路径是完整路径像/order、/order/after-sale所以我统一用完整路径作为菜单的 index。这样 default-active 和 default-openeds 才能匹配上。如果用相对路径菜单高亮和展开匹配必然出问题这个一定要想清楚。3.3 菜单与路由的双向同步机制整个联动逻辑可以分为两个方向的流动路由 → 菜单状态当用户刷新页面、从地址栏直接输入 URL、或者通过页面内部操作跳转路由时$route变化计算属性currentTopMenu、currentSideMenus、activePath、openNames都会自动重新计算驱动菜单高亮和展开状态变化。这是数据响应的天然优势不需要任何手动监听。菜单 → 路由当用户点击顶部一级菜单时el-menu在 router 模式下会自动调用router.push(/order)路由变化随之触发左侧菜单重新渲染当用户点击左侧二级菜单时同样通过 router 模式跳转到对应页面。这就形成了一个闭环。项目里我还加了watch处理一个业务需求如果用户当前在/order/list页面然后点击顶部菜单切换到/product模块此时左边变成商品管理的菜单列表但右侧内容区还停留在订单列表页面。虽然这不影响功能但视觉上会显得很怪。我的处理方案是在顶部菜单的 select 事件里加一个主动跳转handleTopMenuSelect(path) { // 如果当前已经在该模块下不处理 if (this.$route.path.startsWith(path)) return // 否则跳转到该模块的第一个可访问子页面 const module this.moduleRoutes.find(route route.path path) if (module module.children module.children.length 0) { const firstChild this.getFirstLeafRoute(module) this.$router.push(firstChild) } else { this.$router.push(path) } }getFirstLeafRoute是一个递归查找函数会沿着当前模块的 children 一路找下去直到找到第一个没有 children 的路由返回它的完整路径。这样点击顶部菜单左侧菜单变化和右侧内容切换是同时完成的体验好很多。这里有一个相反的边界考虑如果某个一级模块的 children 为空也就是它下面没有二级菜单那点击顶部菜单应该直接跳到这个模块的页面本身。我们的路由表设计里一级模块的 component 是 Layoutchildren 为空时 Layout 内部没有内容所以这种模块我一般会在 children 里加一个默认的 index 页面保证任何一级模块都不会是空的。4. 多级菜单与面包屑导航的联动处理4.1 多级菜单的递归实现虽然前面提到的设计原则是尽量控制菜单深度但在实际业务里三级菜单几乎是躲不掉的。比如“售后管理”下面挂“投诉处理”这就是一个典型的三级菜单结构。ElementUI 的el-menu-item和el-submenu天然支持嵌套但如果我们用v-for一层层手写模板写三层还能忍写到四层五层就完全没法维护了。正确的做法是封装一个递归组件把侧边栏菜单项做成一个SidebarMenuItem.vuetemplate div !-- 叶子节点 -- el-menu-item v-if!item.children || item.children.length 0 :indexfullPath i :classitem.meta.icon/i span slottitle{{ item.meta.title }}/span /el-menu-item !-- 非叶子节点 -- el-submenu v-else :indexfullPath template slottitle i :classitem.meta.icon/i span{{ item.meta.title }}/span /template sidebar-menu-item v-forchild in item.children :keychild.path :itemchild :base-pathfullPath / /el-submenu /div /template script export default { name: SidebarMenuItem, props: { item: { type: Object, required: true }, basePath: { type: String, default: } }, computed: { fullPath() { return this.item.path.startsWith(/) ? this.item.path : ${this.basePath}/${this.item.path} } } } /script递归组件最核心的传参有两点一是basePath要从上层一层层传递下来每次递归都拼接上当前层级的路径二是叶子节点的判断标准要统一children 为空数组或者 undefined 都算叶子。这里有个细节递归组件的名字要在name里声明模板里才能直接通过sidebar-menu-item自引用。Vue 2 的递归组件对大小写不敏感但建议统一用 kebab-case 写法避免在字符串模板里解析出错。还有一点经验之谈递归组件的v-for一定要写:keykey 不能直接用index因为菜单可能在后端返回后顺序变化。我最常用的是item.path如果同一层级的 path 有重复一般不会就拼上Math.random()兜底。4.2 面包屑导航与当前菜单实时同步我做后台项目基本都会配面包屑它和菜单位于不同层级但数据来自同一个$route.matched。在我的实现里面包屑组件和侧边栏组件共用了同一份菜单数据源只不过渲染方式不同template el-breadcrumb separator/ el-breadcrumb-item v-for(item, index) in levelList :keyitem.path router-link v-ifindex levelList.length - 1 :toitem.redirect || item.path {{ item.meta.title }} /router-link span v-else{{ item.meta.title }}/span /el-breadcrumb-item /el-breadcrumb /template script export default { computed: { levelList() { return this.$route.matched.filter( item item.meta item.meta.title ) } } } /script这里 filter 的meta.title很关键。不用这个过滤的话$route.matched里会带上 Layout 布局组件和顶层路由那些通常没有 title或者 title 是空字符串直接渲染会把面包屑弄乱。面包屑的最后一栏要禁止跳转通常用 span 而不是 router-link。因为用户已经在当前页面了再点面包屑跳回自己没有任何意义。中途路径则保留 router-link方便用户快速回到上级菜单。值得一提的联动细节面包屑和菜单共用$route.matched这意味着只要路由配置合理面包屑的文案会自动和菜单保持一致。后端如果通过 meta.title 动态下发菜单名面包屑也会跟着变这对多语言系统特别友好。4.3 页面刷新后菜单状态恢复的原理整个方案里最容易被忽略、也是实际上最容易出 bug 的就是刷新之后的菜单恢复。很多初学 Vue 的朋友会在 created 里写this.activeMenu this.$route.path这样的代码然后在 watch 路由的时候再改一次。这样确实能工作但问题是刷新时 activeMenu 的初始值永远不对总是渲染完默认值再跳变到正确值视觉上就是“菜单闪了一下”。我的方案里侧边栏没有初始化状态完全用计算属性实时算出来。因为activeMenu、openNames都是计算属性它们和$route绑定刷新后组件首次渲染时计算属性会根据当前路由直接算出正确的高亮和展开项从根源上消除闪烁问题。如果你对闪烁特别敏感还有一个优化点在el-menu渲染前加一个v-if等路由匹配完成之后再渲染整个菜单。具体写法是在侧边栏组件根部加一个判断div v-ifready !-- 菜单内容 -- /divcreated() { this.$nextTick(() { this.ready true }) }其实在正常的 Vue 应用里$route在组件初始化之前就已经确定了所以计算属性天然不会产生延迟。我后来遇到过一次闪烁排查下来是el-menu的default-openeds在路由懒加载下偶尔会拿到空数组解决方案是把default-openeds改成手动控制展开项。这个见下面第 6 节。5. 权限控制下的动态菜单接入方案5.1 菜单按角色权限过滤标题里写的场景是“后台管理系统”这种系统十有八九要做权限控制。我的动态菜单方案后端在用户登录后返回该用户的角色和可访问路由路径列表前端根据这个列表去过滤路由表只保留有权限的模块。路由表中每个模块的 meta 里定义一个 roles 字段meta: { title: 订单管理, icon: el-icon-s-order, roles: [admin, operator] }在用户登录后我通过一个工具函数filterMenusByRoles(menus, userRoles)递归过滤菜单数据同时也是在用同样的过滤结果去动态注册路由function filterMenusByRoles(routes, roles) { const res [] routes.forEach(route { const meta route.meta || {} // 路由配置了 roles但当前用户角色不匹配则跳过 if (meta.roles meta.roles.length 0 !meta.roles.some(r roles.includes(r))) { return } if (route.children route.children.length 0) { route.children filterMenusByRoles(route.children, roles) } res.push(route) }) return res }递归过滤要特别留意父路由有权限子路由不一定有子路由都过滤掉之后父路由只剩一个空壳 Layout也要顺手剔除。我的做法是过滤完 children 之后再判断如果原路由有 children 且过滤后没了就把它隐藏。这个过滤函数同时服务于两个场景菜单渲染和路由注册。要注意的是路由注册用的过滤结果应该是深拷贝后的原始路由对象因为router.addRoute会修改传入的路由对象。如果直接拿菜单渲染用的那份数据去注册路由后续再根据用户角色切换或重新登录时原始路由数据已经被污染了权限变更后无法正确恢复。5.2 动态路由注册与页面刷新 404 的根源动态路由是目前后台管理系统的主流做法但它的坑比静态路由多得多。最常见的表现就是登录后一切正常一刷新页面直接 404。这个问题的根源在于刷新时动态注册的路由还没有执行 addRoute页面就先去匹配路由了。解决思路是刷新后先走一个“前置加载”流程// router/index.js let dynamicRoutesAdded false router.beforeEach(async (to, from, next) { const token store.getters.token if (!token) { if (to.path /login) { next() } else { next(/login) } return } if (to.path /login) { next(/) return } // 动态路由未注册先拉取用户信息并注册路由 if (!dynamicRoutesAdded) { const userInfo await store.dispatch(user/getInfo) const routes await store.dispatch(user/getMenuRoutes) routes.forEach(route router.addRoute(route)) dynamicRoutesAdded true // 重新进入当前路由确保匹配到的是动态注册的路由 next({ ...to, replace: true }) return } next() })这里next({ ...to, replace: true })是整个逻辑的命脉。为什么要这么写因为当前正在跳转的to是在动态路由注册之前解析的此时 Vue Router 认为找不到匹配项。只有重新导航一次它才会使用最新的路由表去匹配。不写这一步刷新后页面会直接停在白屏或者 404。这个方案还有一个细节如果用户在没有权限的模块下刷新页面比如直接输入/order/refund而他的角色没有退款权限此时动态路由里根本没有这个路径to匹配不到任何路由记录需要在beforeEach末尾加一个判断匹配不到路由时跳转到 404 或首页。否则 Vue Router 会无限循环跳转控制台报NavigationDuplicated。6. 常见问题与排查技巧实录6.1 刷新后菜单高亮丢失或错乱现象点击菜单正常高亮刷新页面后高亮跑到别的菜单上或者干脆不高亮。这个问题的排查路径基本是这样的首要检查activeMenu是否来自计算属性而不是在 data 里初始化的普通变量。data 里的值会在组件创建时用当前$route.path赋值但刷新时组件的创建先于路由匹配完成所以初始值可能是空字符串之后就不会再更新了。接下来检查el-menu-item的 index 是否和this.$route.path完全一致。常见的有三种不一致大小写不一致/order/list和/Order/List、多了一个斜杠/order/list/和/order/list、路由配置的是/order/但页面跳转的是/order。ElementUI 的 default-active 用的是严格字符串匹配差一个字符都不行。如果菜单是多级嵌套还要检查 default-openeds 里的展开项是否都使用了完整路径。我之前遇到一个三级菜单默认展开一级模块没问题但二级菜单无论如何都不展开。后来发现是因为openNames数组里存的是相对路径而el-submenu的 index 是完整路径匹配不上。6.2 点击菜单 URL 变化但页面内容不更新这个问题在 ElementUI 菜单里也经常见。排查方向是el-menu是否设置了:routertrue。如果没设置它只是切换了菜单高亮不会调路由。算是 ElementUI 最常见的“隐性坑”。另一种情况是router模式开了但el-menu-item的 index 使用的是页面组件内的某个业务 id而不是路由 path。比如有的项目把/product/123这种带参数的地址作为 index。在 router 模式下Vue Router 会试图跳转到/product/123这个路径这没问题。但如果是/product/detail/123而路由只注册了/product/detail/:id那么点击后 URL 变成了/product/detail/123能正常解析但菜单的高亮匹配的是字符串 index和路由解析出来的 path 没区别一般不会出问题。真正的难点在动态参数菜单 index 是/product/123页面刷新后$route.path是/product/123注意path 不含 query只含路径部分。如果 index 和 path 一致高亮正常如果不一致高亮就会丢失。我的经验是有参数的菜单页在getFullPath时就把参数拼好或者用$route.fullPath去匹配高亮。这种现象在我处理“商品详情页”这类带 id 的菜单时经常碰到。业务上这类页面往往从列表页跳转进去而不是直接挂在菜单下所以我的建议是带参数页面不要出现在菜单里菜单里放一个代表列表的静态路径即可。如果一定要挂在菜单下就保证 index 和最终 path 格式完全统一。6.3 菜单数据响应式丢失的问题有一种情况是侧边栏菜单渲染出来了但点击切换顶部菜单左侧菜单不变。排查下来通常是数据源的问题父组件的 moduleRoutes 是普通数组在created里通过后端接口拉取后赋给 data但模板里的currentSideMenus是根据它计算出来的。如果赋值时用了this.moduleRoutes response.data而不是this.$set在 Vue 2 里会给 data 中已存在的属性赋新值这没问题。问题出在接口没有返回时 moduleRoutes 为空数组接口返回后才赋值计算属性自然跟着更新。真正的响应式大坑在于后端返回的嵌套对象层级很深在赋值之后又需要给某个菜单动态添加 hidden 或 badge 字段直接menu.badge new不会触发视图更新。解决方法是先深拷贝再修改最后一次性赋值或者用this.$set对每个要修改的属性显式声明。我后端接口返回的菜单数据一般会有缓存修改菜单后如果不重新登录就不生效其实就是因为接口返回的数组对象被存到了本地缓存而缓存不会通知 Vue 更新。这种情况我一般在返回数据后主动更新 store 里的菜单数据。6.4 菜单折叠动画卡顿与闪烁如果做了折叠功能还有一个体验问题折叠动画期间菜单文字和图标错位展开后又恢复。这个是 ElementUI 在折叠模式下删除了文字节点的时机导致的不算 bug但很影响视觉。我的优化方式是折叠状态下手动隐藏 secondary 菜单的子标题只保留图标。另一个闪烁问题是 el-aside 在菜单数据重新加载时整个组件会被销毁重建这时候页面左侧会有短暂空白。因为v-for的 key 变了。我的习惯是侧边栏组件的 key 用currentTopMenu这样切换顶部一级模块时侧边栏组件会重新渲染。但如果同一个路由下菜单数据因为权限变化而更新这个 key 不会变组件不会重建就避免了闪烁。这里要权衡的是如果顶部一级模块切换时左侧菜单数量差异很大重建反而更干净但如果频繁切换导致闪烁就不应该重建而是让菜单内容做 diff 更新。6.5 路由懒加载导致页面闪白后台系统页面多我一般会做路由懒加载即组件通过() import(/views/xxx.vue)加载。但懒加载有个副作用菜单切换时如果新页面组件还没加载会有几百毫秒的白屏。项目里小页面还好大的报表页面白屏时间能到一秒。优化手段有两个。第一把核心页面做预加载在用户登录后通过import()把常用模块提前加载一遍利用浏览器的空闲时间缓存第二配合内容区加一个 loading 动画在router.beforeEach里设置一个pageLoading状态router.afterEach里关掉。ElémentUI 的v-loading指令直接绑在 el-main 上就行。这样即便加载慢用户看到的也是 loading 而不是白屏。7. 方案扩展从两列菜单到完整导航体系这套“顶部一级 左侧二级”的菜单体系本质上是从路由表到 UI 组件的一次数据流统一。把主流程跑通之后后续一些常见需求都可以在这个基础上扩展。第一个我觉得值得做的是页签Tabs。后台用户通常会在多个页面之间来回切换没有页签会比较痛苦。ElementUI 没有现成的页签组件但可以用 el-tag 加 vuex 实现。页签的数据来源是路由变化打开新页面就往 store 里追加一条关闭页签时如果有激活页签被关闭了要自动激活相邻页签。这里的页签和菜单共用同一份路由 path可以做到很好的联动。第二个是动态标题。比如用户从订单列表点击某条记录进入详情页菜单名称是“订单详情”但浏览器标签页标题应该显示具体订单号比如“订单详情 - SO20241001”。这需要在路由 meta 里配置 title然后在router.afterEach里根据 meta 动态设置document.title。如果同时做了多语言还要考虑语言切换后 document.title 同步刷新。我在项目里封装了一个setPageTitle(to)工具函数统一处理。第三个是菜单搜索。当一级模块和二级菜单加起来的项数太多用户找不到某个功能入口菜单顶部的搜索框会特别管用。实现思路是监听键盘快捷键弹出菜单搜索框输入关键词后从路由表里模糊匹配标题选中后跳转。因为我们的菜单数据就是路由表所以搜索范围天然和菜单可访问范围一致不会搜到没有权限的页面。这些扩展方案的核心都指向同一个点菜单数据源必须干净、一致、可追溯。只要路由表设计得好后面加什么功能都不慌。回头再看这套方案最让我满意的不是代码本身而是“以路由表为唯一数据源”这个设计决策。它帮我省掉了大量“菜单改了路由没改”“路由改了菜单没改”的扯皮时间。我的建议是新项目一开始就按这个思路搭建不要图省事把菜单写死否则后面业务越铺越大返工成本会特别高。如果你正在做或准备做 Vue 后台管理系统不妨拿这篇文章的代码直接跑一个 demo把路由表、递归菜单、动态注册这几块吃透后面的开发会顺很多。
阅读完成 · 觉得有帮助?
咨询建站