前端权限控制 RBAC 实战:路由守卫与按钮级鉴权

在中后台系统里,前端权限控制决定了一个用户登录之后能看见哪些菜单、能打开哪些页面、能点击哪些按钮。基于 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,拿到 rolespermissions 数组。
  • 把权限数据存入 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 反向代理实战前端路由原理与实战

八、落地清单

环节是否完成检查点
登录拿 tokenJWT 无感续期可用
拉取权限信息roles + permissions 到位
路由守卫未登录跳登录、无权限跳 403
动态菜单/路由刷新不丢态
按钮级鉴权危险按钮对无权人不可见
后端兜底校验写接口服务端再验一次

把以上六步串起来,你就拥有了一套可维护的前端权限控制体系。RBAC 的价值不在于多高级,而在于把「谁能干什么」变成可配置的数据,而不是散落在代码里的 if/else。最后再强调一次:前端收敛是体验,后端校验才是安全,两者缺一不可。

上一篇 混沌工程实战:用故障演练锻造系统韧性
下一篇 大模型推理成本战 2026:API 调用还是自建推理?