尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

Vue3+Element Plus搭建RBAC动态菜单Layout布局实战

Vue3+Element Plus搭建RBAC动态菜单Layout布局实战 开篇先聊聊为什么一个“页面骨架”值得单独写一篇。我做了好几年的前端几乎每个中后台项目的第一步都是搭Layout。这东西看着简单不就是左边菜单、右边内容、上面顶栏吗可真等你把RBAC权限、动态路由、多级菜单、按钮级别控制全部塞进去的时候Layout就不再是“布局”那么简单了——它是整个前端权限体系的骨架也是所有业务页面的容器。你在这块偷的懒后面全都会变成维护期的债。这篇系列第08篇咱们就专门把Layout布局搭建这件事掰开揉碎聊清楚。内容包括Layout的整体设计思路、侧边栏与顶栏的模块划分、动态菜单如何跟RBAC权限模型握手、多级菜单的递归渲染、响应式适配方案以及我在实际项目中踩过的若干个不大不小的坑。内容会偏实战沿用Vue 3 Element Plus Vue Router的组合式API写法这套组合是目前做中后台系统的主流选择就算你用的是React系的技术栈设计思路也完全可以平移。1. 内容整体设计与思路拆解1.1 为什么Layout是RBAC前端的“骨架”先明确一个概念在RBAC基于角色的访问控制模型里后端的核心是“用户—角色—权限”三张表的关系管理前端要承接的是两件事一是根据当前登录用户的角色动态生成能访问的菜单和路由二是在页面内根据权限指令控制按钮、操作区域的显示或禁用。而Layout恰恰把这两件事串起来了。你可以把Layout理解为整个后台系统的“容器组件”它不承载具体业务但它决定了用户登录后进入哪个布局框架侧边栏显示哪些菜单项这是动态的依赖权限接口返回的数据菜单点击后内容区渲染哪个页面组件顶部导航、标签页、用户信息区哪些需要展示。如果Layout写死了那RBAC就无从谈起——你总不能每个角色的菜单都写死一套代码吧所以Layout的核心任务是把“权限数据”翻译成“界面表现”。这个翻译过程的效率和质量直接决定了你整个后台系统的开发体验和后期可维护性。1.2 方案选型为什么选择“上下侧边”的经典结构前端后台的Layout方案常见的无非这么几种上下结构顶部导航 内容区适合菜单层级浅、功能模块少的系统。左右结构侧边栏 内容区适合菜单多、层级深的系统这也是绝大多数中后台项目的选择。上下 侧边组合顶部一级导航 侧边二级导航适合有多个一级业务域每个域下又有大量子功能的系统。我这次选的组合结构但不是一上来就拍脑袋定的。当时考虑过“纯左右结构”就是侧边栏承载所有层级的菜单。但后来梳理了业务模块发现系统有“工作台”“用户管理”“订单管理”“系统设置”几个大块每个大块下面有3到10个不等的子页面。如果全部塞进侧边栏菜单会变得特别长而且一级模块之间切换时用户容易迷失当前所在的位置。所以最终采用了顶部一级导航 侧边二级导航上下组合的结构。顶部放一级模块首页、用户中心、业务中心、系统设置侧边栏根据顶部的选中项动态渲染该模块下的二级菜单。用户在任何时候都能清楚地知道我在哪个大模块这个模块下有哪些功能。实际用下来这个结构的可扩展性很不错后续加新模块时不需要改动Layout的框架代码只需要增加路由配置和菜单数据。1.3 技术栈与目录设计既然标题里写了“RBAC前端架构”那我默认你的项目具备以下基础Vue 3 组合式APIscript setup语法Vue Router 4路由模式为createWebHistoryPinia 做全局状态管理Element Plus 组件库接口层使用 axios 封装统一携带Token。在这个基础上Layout相关的目录我建议这样组织src/ ├── layout/ │ ├── index.vue // Layout主框架组件 │ ├── components/ │ │ ├── Sidebar/ │ │ │ ├── index.vue // 侧边栏主组件 │ │ │ ├── SidebarItem.vue // 菜单项递归组件 │ │ │ └── Link.vue // 菜单链接封装 │ │ ├── Navbar/ │ │ │ ├── index.vue // 顶部导航组件 │ │ │ ├── Breadcrumb.vue // 面包屑 │ │ │ └── UserMenu.vue // 用户下拉菜单 │ │ └── TagsView/ │ │ ├── index.vue // 页签组件 │ │ └── Scaffold.vue // 关闭逻辑 │ ├── hooks/ │ │ ├── useLayout.ts // 布局相关的逻辑复用 │ │ └── usePermission.ts // 权限判断逻辑 │ └── styles/ │ └── index.scss // Layout专用样式组件拆分得细一点是后面所有功能的基石。你哪怕现在只需要一个简单的侧边栏也建议按这个粒度拆否则等你要加面包屑、加页签、加折叠按钮的时候会发现自己正在一个几百行的大组件里做手术。2. 核心细节解析与实操要点2.1 Layout主框架搭建从index.vue说起主框架的代码看起来特别简单但里面的“门道”其实不少。我直接贴出核心版本再逐个点解释为什么这么写。!-- src/layout/index.vue -- template div classapp-wrapper :class{ is-collapse: sidebar.collapsed } div classsidebar-container Sidebar / /div div classmain-container div classnavbar-container Navbar / /div div classtags-view-container TagsView / /div div classapp-main router-view v-slot{ Component, route } transition namefade-transform modeout-in keep-alive :includecachedViews component :isComponent :keyroute.path / /keep-alive /transition /router-view /div /div /div /template script setup langts import { computed } from vue import { useAppStore } from /store/modules/app import Sidebar from ./components/Sidebar/index.vue import Navbar from ./components/Navbar/index.vue import TagsView from ./components/TagsView/index.vue import { useTagsViewStore } from /store/modules/tagsView const appStore useAppStore() const tagsViewStore useTagsViewStore() const sidebar computed(() appStore.sidebar) const cachedViews computed(() tagsViewStore.cachedViews) /script几个关键点第一Layout本身不直接写任何业务逻辑。它只做“组装”和“分发”从store里读取侧边栏折叠状态从tagsViewStore里读取缓存页面列表然后按区域渲染组件。你可能会问那菜单数据呢在子组件里获取。这样职责就拆开了Layout管框架Sidebar管菜单Navbar管顶栏。第二为什么要用keep-aliveinclude中后台系统里用户常常在“列表页—详情页—编辑页”之间来回跳转。如果不做页面缓存每次返回列表页都会重新请求数据用户的筛选条件、页码全部丢失体验很糟糕。include绑定的是组件name所以要实现缓存业务页面组件必须显式声明name属性而且得跟路由配置里的name保持一致。这一点特别容易踩坑我后面会专门说。第三:keyroute.path的作用。如果不加这个key当你在同一个router-view位置切换不同路由时Vue会复用组件实例导致组件不重新渲染尤其是“从用户详情页跳转到另一个用户详情页”参数变了但页面内容没变就是这个原因。加上route.path作为key强制不同路径走不同的渲染。2.2 侧边栏的折叠逻辑与CSS技巧侧边栏折叠是Layout的标配功能。这个功能看似简单但涉及“状态管理 样式联动”两个层面的问题。状态管理折叠状态放在Pinia里而不是放在Layout组件内部的ref中。为什么因为折叠状态在多个地方要使用——顶栏的折叠按钮要改它侧边栏要读它有的系统还需要在路由守卫里根据当前路由重新展开或折叠某个菜单。如果放在组件内部这些联动就得一层层传参或者用事件总线维护成本很高。// src/store/modules/app.ts import { defineStore } from pinia export const useAppStore defineStore(app, { state: () ({ sidebar: { collapsed: false, width: 210px, collapsedWidth: 64px } }), actions: { toggleSidebar() { this.sidebar.collapsed !this.sidebar.collapsed } } })样式联动这里我用的方式是在app-wrapper根节点上根据collapsed状态切换一个is-collapseclass然后通过CSS控制侧边栏宽度。.app-wrapper { width: 100%; height: 100%; .sidebar-container { position: fixed; top: 0; left: 0; bottom: 0; width: $sidebar-width; /* 210px */ transition: width 0.28s; overflow: hidden; background: #304156; z-index: 1001; } .main-container { min-height: 100%; margin-left: $sidebar-width; transition: margin-left 0.28s; } .is-collapse { .sidebar-container { width: $sidebar-collapsed-width; } .main-container { margin-left: $sidebar-collapsed-width; } } }侧边栏用position: fixed固定住内容区用margin-left让开位置这样在折叠切换时只有内容区发生位移侧边栏本身是固定不动的性能上损耗最小。transition: width 0.28s和transition: margin-left 0.28s要保持时长一致否则会出现侧边栏已经收完、内容区还在慢慢移动的割裂感这是我实测过的细节。2.3 顶部导航一级模块切换与面包屑联动顶部导航承担两个任务展示一级业务模块以及让用户随时知道“我在哪里”。一级菜单的数据来源有两种方案一种是跟侧边栏菜单一样全部走权限接口下发另一种是在前端路由表里按一级路由的meta信息提取。我推荐第二种但前提是你的路由表设计得足够规整。因为一级模块相对稳定前端写死可以省一次接口请求而且方便做模块级的代码分割懒加载。顶部导航的渲染逻辑很简单从路由表里拿到顶级路由然后过滤掉hidden: true的项渲染成菜单。这里有一个重要的细节顶部导航只渲染一级侧边栏渲染当前一级路由下的二级及以下菜单。两者的联动依靠当前路由的matched数组取第一个匹配项作为当前一级模块标识。!-- src/layout/components/Navbar/index.vue 的核心部分 -- template div classnavbar div classnav-left el-icon classcollapse-btn clickappStore.toggleSidebar Fold v-if!appStore.sidebar.collapsed / Expand v-else / /el-icon el-menu modehorizontal :default-activeactiveTopMenu router classtop-menu el-menu-item v-foritem in topMenus :keyitem.path :indexitem.path {{ item.meta?.title }} /el-menu-item /el-menu /div div classnav-right UserMenu / /div /div /template面包屑则是根据当前路由的matched数组动态生成的。每个路由的meta.title就是面包屑的一节在路由配置里把层级关系定义好面包屑自动就能算出来不需要单独维护一套映射。2.4 为什么要做TagsView页签以及实现思路TagsView多页签是我个人强烈建议在Layout里一步到位做好的功能哪怕你当前的需求文档里没有。因为中后台用户的使用习惯已经被浏览器养成了——他们期望能同时开好几个页面随时切换。没有页签用户就只能反复用浏览器后退键体验非常割裂。TagsView的核心数据是“当前打开过的路由列表”。当路由切换时把新的路由塞进列表当用户点击页签关闭时把对应路由从列表移除并判断当前激活的页签是否需要跳转到相邻页签。这里特别要注意的是TagsView的数据存储在哪我建议放在Pinia里单独建一个tagsViewstore而不是直接放在Layout组件内部。因为这个数据在路由守卫里也要用到——比如路由切换时判断“如果这个路由已经打开过就不再新增页签只更新参数”。放Pinia里路由守卫和组件都能方便地读写。页签关闭时的路由跳转逻辑是比较容易写错的地方。我给的方案是关闭当前页签时如果关闭的是激活页签则跳转到“该页签左侧相邻的页签”通常这是最符合用户预期的类似浏览器的行为。3. 实操过程与核心环节实现3.1 路由配置的动态映射权限如何决定菜单Layout的侧边栏数据不能写死它在用户登录后从后端接口获取返回的数据格式通常是这样的以菜单树为例[ { path: /dashboard, title: 工作台, icon: Odometer, component: dashboard/index }, { path: /user, title: 用户管理, icon: User, children: [ { path: /user/list, title: 用户列表, component: user/list }, { path: /user/role, title: 角色管理, component: user/role } ] } ]前端拿到这份数据后要做两件事一是把这份菜单数据存进Pinia供侧边栏渲染使用二是把菜单中的component字符串映射成真实的组件对象动态添加进路由表。组件的动态映射是这里的关键技术难点。在Vite环境下使用import.meta.glob可以一次性批量加载所有页面组件// src/router/dynamic.ts export function mapMenusToRoutes(menus: MenuItem[]): RouteRecordRaw[] { const modules import.meta.glob(/views/**/*.vue) function walk(items: MenuItem[]): RouteRecordRaw[] { return items.map(item { const route: RouteRecordRaw { path: item.path, name: item.name || item.path, meta: { title: item.title, icon: item.icon, requiresAuth: true } } if (item.component) { const compPath /views/${item.component}.vue route.component modules[compPath] } if (item.children?.length) { route.children walk(item.children) } return route }) } return walk(menus) }这里有一个很容易踩的坑import.meta.glob默认是懒加载模式返回的modules里的每个值都是一个() import(...)函数Vue Router 能直接使用这种懒加载组件所以这样映射出来的路由是支持路由级代码分割的这没问题。但是如果你在映射之前想“同步拿到组件实例”去做什么检查就会拿到undefined。在动态路由这个场景里我们其实不需要同步拿实例只需要把加载函数交给Router由Router在导航时自行调用。3.2 多级菜单的递归渲染SidebarItem的自我调用当菜单层级不固定有两级、也有三级甚至更深时侧边栏组件必须做成递归的。Element Plus的el-menu本身支持嵌套el-sub-menu但模板不能写死层级所以需要让SidebarItem组件根据当前菜单项是否还有children来自我调用。关键实现如下!-- src/layout/components/Sidebar/SidebarItem.vue -- template template v-if!item.hidden !-- 有子菜单进入递归分支 -- el-sub-menu v-ifhasChildren :indexitem.path template #title el-icon v-ifitem.meta?.icon component :isitem.meta.icon / /el-icon span{{ item.meta?.title }}/span /template sidebar-item v-forchild in item.children :keychild.path :itemchild / /el-sub-menu !-- 没有子菜单渲染单个菜单项 -- el-menu-item v-else :indexresolvePath(item.path) el-icon v-ifitem.meta?.icon component :isitem.meta.icon / /el-icon template #title{{ item.meta?.title }}/template /el-menu-item /template /template script setup langts import { computed } from vue const props defineProps({ item: { type: Object, required: true } }) const hasChildren computed(() { const children props.item.children || [] return children.some(child !child.hidden) }) function resolvePath(path: string) { // 处理相对路径的拼接比如子菜单 path 是相对路径时要基于父级路径拼接 if (path.startsWith(/)) return path return /${path} } /script递归组件有几个细节要注意组件在自己的模板里调用自己在Vue SFC里直接用文件名标签即可无需额外注册。hasChildren不能只判断children.length 0还要过滤掉hidden: true的子项。一个用户没权限的菜单后端在下发时可能直接不返回但也可能返回但是打了hidden标记两种情况都要容错。递归深度过深时resolvePath的逻辑要处理好否则子菜单的index和实际路由path对不上菜单高亮就会失效。3.3 菜单高亮与路由联动的坑菜单高亮是Layout里最常见的Bug来源。具体表现是路由已经跳转过去了但侧边栏上对应的菜单项没有高亮或者高亮到了错误的父级菜单。解决这个问题的核心是让el-menu的default-active始终等于当前路由的完整路径。但有一个特殊情况当你的路由是“详情页 /user/detail/123”时侧边栏上可能并没有“详情页”这个菜单项此时高亮应该回退到它的父级菜单“用户列表 /user/list”。处理方式是在计算当前激活菜单时不能直接取route.path而是要从后往前找matched数组里第一个能在菜单数据中匹配到的路径。我的写法类似const activeMenu computed(() { const { meta, path } route // 如果路由meta里指定了activeMenu优先使用适用详情页指到列表页的场景 if (meta.activeMenu) return meta.activeMenu return path })同时在业务路由比如详情页的meta里显式声明activeMenu: /user/list这样无论URL怎么变菜单高亮始终准确。这个方案简单可靠比在组件里维护“菜单匹配算法”要省心得多。3.4 折叠时菜单只剩图标tooltip提示别忽略侧边栏折叠后菜单项只显示图标这时候问题来了用户只看到一个图标不知道它代表什么功能。Element Plus的el-menu在折叠模式下会自动隐藏文字标题但不会自动加tooltip。方案在折叠状态下菜单项标题用自定义tooltip包裹。需要注意这里不能简单依赖el-tooltip包在所有菜单外面因为子菜单的弹出逻辑与tooltip的触发时机容易冲突。我的处理方法是只在“没有子菜单的菜单项”上做“折叠显示tooltip”的逻辑有子菜单的折叠后点击会弹出悬浮子菜单不再额外加tooltip这样可以避免交互冲突。这个优化虽然小但对用户体验提升很明显尤其是菜单多的时候。4. 常见问题与排查技巧实录4.1 页面刷新后Layout左边菜单全没了这个坑在动态路由方案里几乎必踩一次。原因是动态路由是登录后通过接口获取再router.addRoute()添加的但页面刷新时Pinia里的菜单数据被清空了路由表中动态添加的部分也全部失效此时刷新/user/list会直接白屏或404。解决思路是在应用初始化时从本地持久化存储通常是localStorage里恢复菜单数据和用户信息然后重新走一遍动态路由添加流程。我通常会把菜单数据和用户Token一并存储用Pinia插件做持久化或者干脆自己封装一个getMenus()方法Token存在时优先从本地缓存恢复缓存没有再请求接口。如果刷新后“完全没有数据”大概率是你在main.ts里直接app.use(router)然后挂载了应用但没有在任何路由守卫里处理“动态路由重新挂载”的逻辑。建议在router.beforeEach里加一个判断如果已登录但store里没有菜单数据就执行一次拉取和动态添加路由然后next({ ...to, replace: true })重新进入一次导航。4.2 Keep-alive缓存不生效页面数据一直刷新keep-aliveinclude缓存不生效排查顺序是业务页面组件是否设置了name且name是否等于路由配置里的name。这是最常见的问题Vue 3 的script setup默认是匿名组件必须额外使用defineOptions({ name: UserList })单独声明。cachedViews数组里是否放进了这个name。在TagsView添加页签的时候需要把组件name同步塞进cachedViews。路由切换时router-view的key是否会强制组件重建。如果key设置成了route.fullPath则访问同一个路由不同参数时比如/user/detail/1和/user/detail/2组件会被强制重建缓存自然失效。这时候可以把key调整为route.name或者像本文方案一样用route.path让不同参数的同路由组件复用。4.3 顶部一级菜单和侧边菜单联动不上如果你发现点击顶部“用户管理”侧边栏菜单没有变成用户管理下的子菜单多半是因为你侧边栏的数据源是从“全部菜单”里取的而不是从“当前一级菜单对应的children”里取的。正确的联动逻辑侧边栏组件在每次路由变化时根据route.matched[0].path即当前一级路由路径在菜单树里找到对应的节点然后把这个节点下的children传给侧边栏渲染。这里要注意如果一级菜单节点下面没有children而是一个直接的页面侧边栏应该为空或者显示一个默认页面。联动失效的另一个常见原因是route.matched[0]不是一级菜单而是更上层的“根路由”。如果你的路由配置里有一个父路由包着所有业务路由那么matched[0]永远是父路由这时候需要取matched[1]或者给一级业务路由统一加meta.isTop: true做标记这样更保险。4.4 打包后布局异常路由懒加载的坑标题热词里有个“vue 打包后 布局异常”这个现象在加了动态路由的项目里很常见。排查后发现多数原因是动态添加的路由的component字段在import.meta.glob模式下构建时空映射路径不对导致打包后组件的chunk加载失败。定位方法很简单打包后在浏览器控制台看Network找到加载失败的js文件路径看看是不是/views/路径映射成了绝对路径或错误相对路径。解决办法是避免在动态路由里动态拼接组件路径。可以老老实实维护一个“组件映射表”const viewMap { dashboard/index: () import(/views/dashboard/index.vue), user/list: () import(/views/user/list.vue), // ... }虽然啰嗦了一点但打包后绝对路径是准确的而且每次新增页面时要手动维护一次。还有一种方式是写一个脚本在构建前根据views目录结构自动生成这份映射表但为了一个映射表引入构建步骤我个人觉得性价比不高。项目初期页面不多时手写映射表是最稳的。5. 最后一个建议把Layout当成“产品”来设计Layout用上一段时间后我最大的体会是不要把它当成一个“一次性搭完就再也不动”的东西。它是整个系统的门面也是开发人员每天面对时间最长的界面。折叠动画是否顺滑、菜单高亮是否准确、页签切换是否跟手、刷新后状态是否保留这些细节积少成多构成了一个后台系统“好不好用”的第一印象。如果你是从零开始搭RBAC前端架构建议把Layout作为第一个完整的里程碑。它能把路由管理、状态管理、动态权限、组件通信这些核心概念全部串联起来这一个模块跑通了后面所有业务页面都只是“往这个骨架里填充内容”而已。最后分享一个我个人的实践技巧在Layout的主体区我习惯默认放一个“欢迎/引导”页面而不是直接跳到第一个业务页面。用户登录后看到的不是冷冰冰的表格而是一个简单的工作台入口这对系统的专业感提升非常有帮助。成本几乎为零收益却很容易感知你可以试试。
返回列表