Vue3+Element Plus搭建RBAC动态菜单Layout布局实战
2026/9/19 1:37:31 网站建设 项目流程

开篇先聊聊:为什么一个“页面骨架”值得单独写一篇。

我做了好几年的前端,几乎每个中后台项目的第一步都是搭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 组合式API(<script setup>语法);
  • Vue Router 4,路由模式为createWebHistory
  • Pinia 做全局状态管理;
  • 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 class="app-wrapper" :class="{ 'is-collapse': sidebar.collapsed }"> <div class="sidebar-container"> <Sidebar /> </div> <div class="main-container"> <div class="navbar-container"> <Navbar /> </div> <div class="tags-view-container"> <TagsView /> </div> <div class="app-main"> <router-view v-slot="{ Component, route }"> <transition name="fade-transform" mode="out-in"> <keep-alive :include="cachedViews"> <component :is="Component" :key="route.path" /> </keep-alive> </transition> </router-view> </div> </div> </div> </template> <script setup lang="ts"> 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-alive+include中后台系统里,用户常常在“列表页—详情页—编辑页”之间来回跳转。如果不做页面缓存,每次返回列表页都会重新请求数据,用户的筛选条件、页码全部丢失,体验很糟糕。include绑定的是组件name,所以要实现缓存,业务页面组件必须显式声明name属性,而且得跟路由配置里的name保持一致。这一点特别容易踩坑,我后面会专门说。

第三,:key="route.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.28stransition: margin-left 0.28s要保持时长一致,否则会出现侧边栏已经收完、内容区还在慢慢移动的割裂感,这是我实测过的细节。

2.3 顶部导航:一级模块切换与面包屑联动

顶部导航承担两个任务:展示一级业务模块,以及让用户随时知道“我在哪里”。

一级菜单的数据来源有两种方案:一种是跟侧边栏菜单一样,全部走权限接口下发;另一种是在前端路由表里按一级路由的meta信息提取。我推荐第二种,但前提是你的路由表设计得足够规整。因为一级模块相对稳定,前端写死可以省一次接口请求,而且方便做模块级的代码分割(懒加载)。

顶部导航的渲染逻辑很简单,从路由表里拿到顶级路由,然后过滤掉hidden: true的项,渲染成菜单。这里有一个重要的细节:顶部导航只渲染一级,侧边栏渲染当前一级路由下的二级及以下菜单。两者的联动依靠当前路由的matched数组,取第一个匹配项作为当前一级模块标识。

<!-- src/layout/components/Navbar/index.vue 的核心部分 --> <template> <div class="navbar"> <div class="nav-left"> <el-icon class="collapse-btn" @click="appStore.toggleSidebar"> <Fold v-if="!appStore.sidebar.collapsed" /> <Expand v-else /> </el-icon> <el-menu mode="horizontal" :default-active="activeTopMenu" router class="top-menu" > <el-menu-item v-for="item in topMenus" :key="item.path" :index="item.path" > {{ item.meta?.title }} </el-menu-item> </el-menu> </div> <div class="nav-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-if="hasChildren" :index="item.path"> <template #title> <el-icon v-if="item.meta?.icon"> <component :is="item.meta.icon" /> </el-icon> <span>{{ item.meta?.title }}</span> </template> <sidebar-item v-for="child in item.children" :key="child.path" :item="child" /> </el-sub-menu> <!-- 没有子菜单,渲染单个菜单项 --> <el-menu-item v-else :index="resolvePath(item.path)"> <el-icon v-if="item.meta?.icon"> <component :is="item.meta.icon" /> </el-icon> <template #title>{{ item.meta?.title }}</template> </el-menu-item> </template> </template> <script setup lang="ts"> 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-menudefault-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-alive+include缓存不生效,排查顺序是:

  • 业务页面组件是否设置了name,且name是否等于路由配置里的name。这是最常见的问题,Vue 3 的<script setup>默认是匿名组件,必须额外使用defineOptions({ name: 'UserList' })单独声明。
  • cachedViews数组里是否放进了这个name。在TagsView添加页签的时候,需要把组件name同步塞进cachedViews
  • 路由切换时,router-viewkey是否会强制组件重建。如果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的主体区,我习惯默认放一个“欢迎/引导”页面,而不是直接跳到第一个业务页面。用户登录后看到的不是冷冰冰的表格,而是一个简单的工作台入口,这对系统的专业感提升非常有帮助。成本几乎为零,收益却很容易感知,你可以试试。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询