在中后台系统里,前端权限控制决定了一个用户登录之后能看见哪些菜单、能打开哪些页面、能点击哪些按钮。基于 RBAC(Role-Based Access Control,基于角色的访问控制)模型,配合路由守卫、动态菜单与按钮级鉴权,是绝大多数后台系统的标准解法。本文用 Vue 与 React 双栈示例,讲清从登录拿到权限到界面逐级收敛的完整链路,并厘清一个关键认知:前端权限是体验层收敛,真正的安全边界永远在后端。
一、RBAC 模型:权限不该写死在代码里
很多初学者会写出 if (user.name === 'admin') showMenu() 这种代码,用户一多就失控。RBAC 把权限抽象成三层:用户 → 角色 → 权限。一个用户拥有若干角色,一个角色挂载若干权限点(permission code)。这样新增岗位时只需新建角色并挂权限,不用改前端代码。
除了 RBAC,业界还有两种常见模型,选型时容易混淆,先做个对照:
| 模型 | 核心思路 | 适用场景 |
|---|---|---|
| ACL(访问控制列表) | 直接给用户分配资源权限,关系扁平 | 小规模、资源固定的系统 |
| RBAC | 用户→角色→权限三层,按角色批量授权 | 中后台、企业多岗位系统 |
| ABAC(属性基) | 按用户/资源/环境属性动态计算策略 | 细粒度、策略复杂的平台 |
后端登录后通常会返回两部分数据:一是菜单树(决定渲染哪些导航项),二是权限码列表(如 ["user:list","user:delete"],决定按钮是否可见)。前端拿到这两份数据后,才开始真正的权限收敛。
二、整体流程:从登录到渲染的权限链路
把权限控制想成一条流水线,每一步都缺一不可:
- 用户登录,后端下发
token(推荐用 JWT,配合JWT 续期与无感刷新避免频繁重新登录)。 - 前端携带 token 请求
/api/user/info,拿到roles与permissions数组。 - 把权限数据存入 Pinia / Redux / 全局状态,作为全局「能力清单」。
- 路由守卫拦截每一次跳转,未登录跳登录页,无权限跳 403。
- 根据菜单树动态生成路由与导航,用户只看到自己该看的。
- 按钮渲染前用权限码做最终判断,实现按钮级鉴权。
三、路由守卫:第一道闸门
路由守卫是前端权限的咽喉。下面以 Vue Router 为例,核心是 beforeEach 全局前置守卫:
// router/index.js
const whiteList = ["/login", "/403"]
router.beforeEach(async (to, from, next) => {
const token = store.getters.token
if (!token) {
// 未登录,放行白名单,其余跳登录
return whiteList.includes(to.path) ? next() : next("/login")
}
if (to.path === "/login") return next("/")
// 首次进入时拉取用户信息(含权限码),只拉一次
if (!store.getters.permissions) {
await store.dispatch("user/getInfo")
// 拉完信息后动态注册路由,再重进目标路由
return next({ ...to, replace: true })
}
// 已登录但目标路由要求特定权限
if (to.meta?.permission && !hasPerm(to.meta.permission)) {
return next("/403")
}
next()
})
React 没有内置守卫,但思路一致:用 PrivateRoute 组件包裹受保护路由,或在数据加载层(如 React Router 的 loader)里做权限判定。关键是把判断集中在入口,而不是散落在每个页面。
四、动态菜单与路由:后端给什么,前端渲染什么
4.1 根据菜单树注册路由
不要在前端硬编码所有路由。拿到后端的菜单树后,过滤出有权限的节点,再用 router.addRoute 动态挂载:
// 把后端菜单映射为路由
function buildRoutes(menuList) {
return menuList.map((m) => ({
path: m.path,
name: m.name,
component: () => import(`@/views/${m.component}`),
meta: { title: m.title, permission: m.permission },
}))
}
// 在守卫里注册
store.getters.menus.forEach((m) => router.addRoute("layout", ...buildRoutes([m])))
4.2 刷新后动态路由丢失的坑
浏览器刷新时前端状态清空,动态注册的路由随之丢失,直接 404。解决方案是在 beforeEach 里判断:如果权限信息还没初始化,就先拉取并重新注册,再用 next({ ...to, replace: true }) 重进一次目标路由(见第三节代码)。这是动态路由最常见的翻车点。
五、按钮级鉴权:让「删除」「导出」只对正确的人出现
菜单和路由拦得住页面,但拦不住页面里的危险按钮。按钮级鉴权要做到「无权限的人连按钮都看不见」。两种主流实现:
5.1 Vue:自定义指令 v-auth
// directives/auth.js
Vue.directive("auth", {
inserted(el, binding) {
const perms = store.getters.permissions
if (!perms.includes(binding.value)) {
el.parentNode && el.parentNode.removeChild(el)
}
},
})
// 使用:<button v-auth="'user:delete'">删除</button>
5.2 React:usePermission Hook + 包裹组件
function usePermission(code) {
const perms = useStore((s) => s.permissions)
return perms.includes(code)
}
function Permission({ code, children }) {
const ok = usePermission(code)
return ok ? children : null
}
// 使用:<Permission code="user:export"><button>导出</button></Permission>
三种鉴权粒度的取舍如下,按需求组合使用:
| 粒度 | 实现手段 | 作用 |
|---|---|---|
| 路由级 | 路由守卫拦截 | 防止无权限访问整个页面 |
| 菜单级 | 按权限过滤菜单树 | 导航栏只展示有权限项 |
| 按钮级 | 指令 / Hook / 包裹组件 | 隐藏危险操作按钮 |
六、权限数据从哪来:与后端约定接口
前端权限能不能做对,七分看后端约定。推荐 /api/user/info 返回如下结构,前端据此收敛:
{
"username": "zhangsan",
"roles": ["editor"],
"permissions": ["user:list", "user:edit", "user:delete"]
}
权限码建议用「资源:动作」的语义化命名(如 user:delete),可读、可审计。后端的真实鉴权(拦截越权请求)请参考Spring Security 认证授权与 JWT 实战,前端的判断只是体验优化,绝不能替代服务端校验。
七、常见坑与最佳实践
- 前端权限防君子不防小人。任何人都能在控制台改掉
permissions数组,所以每个写接口后端都必须再验一次权限,详见Spring Security。 - 权限码语义化。用
user:list而非perm_1024,出问题一眼能定位。 - 动态路由记得处理刷新丢态。在守卫里重拉并
next({...to, replace:true})重进。 - 指令 vs 组件二选一即可。Vue 用
v-auth指令更轻;React 用<Permission>包裹组件更声明式,团队统一就好。 - 生产部署注意 history 模式。Vue/React 的 history 路由刷新会 404,需要 Nginx 把所有路径兜底到
index.html,可参考Nginx 反向代理实战与前端路由原理与实战。
八、落地清单
| 环节 | 是否完成 | 检查点 |
|---|---|---|
| 登录拿 token | ☐ | JWT 无感续期可用 |
| 拉取权限信息 | ☐ | roles + permissions 到位 |
| 路由守卫 | ☐ | 未登录跳登录、无权限跳 403 |
| 动态菜单/路由 | ☐ | 刷新不丢态 |
| 按钮级鉴权 | ☐ | 危险按钮对无权人不可见 |
| 后端兜底校验 | ☐ | 写接口服务端再验一次 |
把以上六步串起来,你就拥有了一套可维护的前端权限控制体系。RBAC 的价值不在于多高级,而在于把「谁能干什么」变成可配置的数据,而不是散落在代码里的 if/else。最后再强调一次:前端收敛是体验,后端校验才是安全,两者缺一不可。




