从 0 搭建企业级 Vue3 权限路由与按钮级权限指令深度实践

摘要 :在 B 端/SaaS 系统中,权限控制是核心命脉。传统的 Vue 项目常常面临"刷新页面路由丢失"、"静态路由与动态菜单不匹配"、"按钮级权限依赖繁琐的 v-if"等痛点。本文基于 Vue3 + Pinia + Vue Router 4,深度剖析一套"动态路由表"与"自定义指令"相结合的企业级权限架构。我们将从底层源码出发,解决路由死循环、Token 失效拦截、以及菜单树形渲染等极端场景问题。

一、 引言:前端权限控制的"三座大山"

在开发企业级应用时,权限控制绝非简单的"登录后跳转"。它通常包含以下三个维度的复杂诉求:

  1. 路由级(页面级)权限 :用户不能通过在浏览器输入 URL 的方式,访问他没有权限的页面。
  2. 按钮级(操作级)权限 :同一个页面,管理员能看到"删除"按钮,普通员工看不到。
  3. 菜单级(展示级)权限 :左侧导航栏必须根据用户的真实权限动态渲染,而不是把无权访问的菜单隐藏或置灰。

为了实现这三层控制,我们抛弃了传统的"前端写死全量路由"的做法,采用了基于 RBAC(基于角色的访问控制)模型的前后端分离动态路由方案 。

二、 架构设计:动态路由的拆解与合并

Vue Router 4 提供了极其强大的 addRoute API。我们的核心设计思想是:静态路由包含基础页面,动态路由由后端下发。

1. 路由的分类

在 router/index.ts 中,我们将路由分为两部分:

  • 静态路由(Static Routes):如登录页、404、403 以及主 Layout(框架外壳)。任何人都可以访问。

  • 动态路由(Dynamic Routes):如工作台、项目管理、系统配置。这些路由并没有直接写进 createRouter 的 routes 数组中,而是需要通过接口向后台请求。

2. 权限数据结构设计

为了配合前端渲染,后端需要返回两类数据:菜单树(用于渲染侧边栏) 和 权限编码列表(用于按钮级控制) 。

复制代码
// 后端返回的权限数据格式
interface PermissionResponse {
  // 1. 菜单树:包含 path, component, meta 等信息
  menus: MenuTreeItem[]; 
  // 2. 权限编码集合:如 ['api:create', 'project:edit']
  permissions: string[]; 
}

  

三、 核心实现一:全局路由守卫(Router Guards)与动态注册

很多新手写路由守卫,最容易出现两个致命 Bug:刷新页面动态路由丢失 和 死循环重定向。

我们的解决方案是在 guards.ts 中实现极致的状态管理:

复制代码
// src/router/guards.ts
import type { Router } from 'vue-router'
import { useUserStore } from '@/stores/user'
import { usePermissionStore } from '@/stores/permission'

export function setupRouterGuards(router: Router) {
  router.beforeEach(async (to, from, next) => {
    const userStore = useUserStore()
    const permissionStore = usePermissionStore()

    // 1. 白名单放行(无需登录的页面)
    if (to.meta.requiresAuth === false) {
      return next()
    }

    // 2. 判断是否已登录(是否有 Token)
    if (!userStore.token) {
      return next({ path: '/login', query: { redirect: to.fullPath } })
    }

    // 3. 核心:判断是否已拉取过权限
    // 第一次进入,或者刷新页面时,permissionStore.loaded 为 false
    if (!permissionStore.loaded) {
      try {
        // 拉取用户信息和权限路由,并动态 addRoute
        await permissionStore.fetchPermissions()
        // 关键操作:路由注册完成后,必须使用 replace 重新进入当前路由
        // 否则会出现白屏或者 404
        return next({ ...to, replace: true })
      } catch (error) {
        // 如果获取权限失败(如 Token 过期),清除凭证并踢回登录页
        userStore.clearToken()
        return next('/login')
      }
    }

    // 4. 按钮级权限的细粒度路由校验(可选)
    const requiredPermission = to.meta?.permission as string
    if (requiredPermission && !permissionStore.hasPermission(requiredPermission)) {
      return next('/403')
    }

    next()
  })
}

  

关键点:为什么不直接把路由 next() 过去?
因为首次访问时,路由尚未 addRoute 完成,此时直接 next() 依然会匹配到 404。使用 next({ ...to, replace: true }) 可以中断当前导航,等注册完毕后,基于新的路由表重新发起一次导航。

踩坑实录一:被 next() 搞崩溃的下午

刚上动态路由时,本地开发极其丝滑,一放测试环境,只要一刷新页面,浏览器就直接卡死,甚至提示 Maximum call stack size exceeded(栈溢出)。

我扒开 router.beforeEach 的日志一看,发现它一直在 login 和 dashboard 之间疯狂反复横跳。

原因分析:
当刷新页面时,permissionStore.loaded 初始是 false。路由拦截后去拉接口,拉完把 loaded 置为 true,然后 next() 放行。但我漏了一个致命逻辑:如果此时 token 过期了,接口返回 401。
我的 Axios 拦截器捕获到 401,把 token 清空,并跳转 /login。这时候路由守卫再次触发,发现没有 token,又拦截跳 /login;但由于路由匹配混乱,或者 next({...to}) 的写法不对,导致它在死胡同里疯狂打转。

真实解决方案:
在守卫里,拉取权限的动作必须加 try...catch 并配合强制跳出。

复制代码
if (!permissionStore.loaded) {
  try {
    await permissionStore.fetchPermissions()
    // 坑点:千万别写 next(),必须重新触发一次导航,让新挂载的路由生效
    return next({ ...to, replace: true }) 
  } catch (err) {
    // 坑点:这里如果不手动清理 token,死循环绝对跑不掉
    userStore.clearToken()
    return next({ path: '/login' })
  }
}

  
四、 核心实现二:Pinia 状态管理与动态路由解析
在 permission.ts 中,我们需要处理后端传来的菜单树,将其转换为 Vue Router 能够识别的RouteRecordRaw 格式。

复制代码
// src/stores/permission.ts
import { defineStore } from 'pinia'
import { ref } from 'vue'
import router from '@/router'
import type { RouteRecordRaw } from 'vue-router'

export const usePermissionStore = defineStore('permission', () => {
  // 状态
  const loaded = ref(false)
  const permissions = ref<string[]>([])
  const dynamicRoutes = ref<RouteRecordRaw[]>([])

  // 动态导入组件的黑魔法
  const modules = import.meta.glob('@/views/**/*.vue')

  // 将后端菜单转换为 Vue Router 格式
  const transformMenuToRoute = (menus: any[]): RouteRecordRaw[] => {
    return menus.map(menu => {
      const route: RouteRecordRaw = {
        path: menu.path,
        name: menu.name,
        // 核心:通过 glob 映射,避免后端返回字符串导致无法加载组件
        component: modules[`/src/views/${menu.component}.vue`], 
        meta: {
          title: menu.meta?.title || menu.name,
          icon: menu.meta?.icon,
          permission: menu.meta?.permission
        },
        children: menu.children ? transformMenuToRoute(menu.children) : []
      }
      return route
    })
  }

  // 拉取权限并动态挂载
  const fetchPermissions = async () => {
    // 模拟 API 请求
    const res = await api.getPermissions() 
    permissions.value = res.permissions
    dynamicRoutes.value = transformMenuToRoute(res.menus)

    // 关键:将解析后的路由,动态添加到主 Layout 路由下
    dynamicRoutes.value.forEach(route => {
      router.addRoute('Layout', route) 
    })

    loaded.value = true
  }

  const hasPermission = (code: string) => {
    return permissions.value.includes(code)
  }

  return { loaded, permissions, dynamicRoutes, fetchPermissions, hasPermission }
})

 关键点:后端返回的 component 只能是字符串(如 "system/user/index")。千万不要用 import(menu.component) 这种动态字符串拼接,Vite 在打包时会将其作为变量处理,导致组件丢失。必须使用 import.meta.glob 预加载所有视图文件。

踩坑实录二:Vite 动态导入的"死局"

在刚写完动态路由解析,我兴冲冲地加了一个新菜单测试。结果一点击刷新,页面直接白屏,控制台报错:Failed to fetch dynamically imported module: /src/views/system/user/index.vue。

我当时排查了整整 4 个小时,差点以为是 Vite 的 Bug。后来才发现,Vue Router 原生的 component: () => import(XXX) 只支持静态字符串。由于后端返回的 menu.component 是一个变量(如 'system/user/index'),Vite 在打包时根本无法分析这个变量到底指向哪个文件,导致打包后这个组件压根就没被包含进去。

真实解决方案(绝杀版):
必须使用 Vite 提供的 import.meta.glob。但这里还有一个极易踩的坑:glob 的路径拼接。如果在 Windows 系统下,路径会变成反斜杠。我最终的代码是这样的: 

复制代码
// 解决前: modules[`/src/views/${menu.component}.vue`] 容易因为大小写或斜杠报错
// 解决后:加入容错处理,并在路径中强制替换
const modules = import.meta.glob('../../views/**/*.vue')

const getComponent = (componentPath: string) => {
  const path = `../../views/${componentPath}.vue`
  // 如果找不到,就给个 404 占位组件,防止整个应用崩溃
  return modules[path] || (() => import('../../views/error/404.vue'))
}

  (当时因为少写了一个 ../../,我对着屏幕蒙了。)

五、 核心实现三:按钮级权限指令(自定义 Directive)

对于按钮级别的权限控制,如果使用 v-if="hasPermission('xxx')",页面代码会变得非常丑陋,且不易维护。

我们通过封装自定义指令 v-permission 来实现优雅的控制。这个指令不仅要在挂载时判断,在权限变化时(虽然极少发生)也要能响应。

复制代码
// src/directives/permission.ts
import { type Directive, type DirectiveBinding } from 'vue'
import { usePermissionStore } from '@/stores/permission'

export const permission: Directive = {
  mounted(el: HTMLElement, binding: DirectiveBinding) {
    const { value } = binding
    const permissionStore = usePermissionStore()

    // 支持传入数组,如 v-permission="['user:add', 'user:edit']" (满足其一即可)
    const requiredPermissions = Array.isArray(value) ? value : [value]

    if (requiredPermissions.length > 0) {
      const hasPerm = requiredPermissions.some(perm => permissionStore.hasPermission(perm))

      if (!hasPerm) {
        // 如果没有权限,直接从 DOM 树中移除该元素
        el.parentNode?.removeChild(el)
      }
    } else {
      throw new Error('v-permission 指令需要指定权限编码,如 v-permission="\'user:add\'"')
    }
  }
}

  在主入口(main.ts)中注册该指令:

复制代码
app.directive('permission', permission)

  在业务代码中,使用极其简洁:

复制代码
<el-button v-permission="'api:create'" type="primary">新建接口</el-button>

  

踩坑实录三:仅靠前端指令是不够的

我写完 v-permission 指令后,兴冲冲地拿去演示:管理员能看到"删除"按钮,普通测试人员登录后,按钮直接消失了。
但我的前端 Leader(或者自我反思)直接打开 F12 开发者工具,在元素审查里,把被移除的 <button> 标签手动改了回去,或者直接在控制台调用了删除接口 fetch('/api/v1/delete'),依然能把数据删掉!

原因分析 :
v-permission 只是掩耳盗铃,它只是把 DOM 元素从树上拔掉了,但现代浏览器的 SPA(单页应用),用户可以轻易修改内存和 DOM。

真实解决方案 :
前端的权限指令只是用户体验(UX)优化,真正的安全拦截必须在后端(就是之前发的 RBAC 文章里的 Depends)。
但我这里优化了一下指令:为了防止用户误操作报错,我加上了 display: none 而不是 removeChild。

复制代码
// 优化后的指令挂载
mounted(el: HTMLElement, binding: DirectiveBinding) {
  const { value } = binding
  const permissionStore = usePermissionStore()
  const requiredPermissions = Array.isArray(value) ? value : [value]
  
  const hasPerm = requiredPermissions.some(perm => permissionStore.hasPermission(perm))
  if (!hasPerm) {
    // 不用 removeChild,直接隐藏,避免 Vue 在 Diff 时找不到节点报错
    el.style.display = 'none' 
  }
}

  

六、 性能优化与极端场景分析

  1. 多标签页(Tab)重复添加路由:
    如果用户点击刷新,或者多次调用 fetchPermissions,router.addRoute 可能会重复添加。
    解决方案:在 permission.ts 中引入 router.hasRoute(name) 判断,或者使用 router.removeRoute(name) 清理旧路由。

  2. 401/403 状态码拦截:
    前端 Axios 拦截器必须与路由守卫联动。当接口返回 401 时,清空 userStore.token 和 permissionStore.loaded,并使用 window.location.href = '/login' 强刷页面,彻底销毁前端残留状态。

  3. 深拷贝导致的性能问题:
    在对菜单树进行递归转换时,尽量使用生成新对象的方式,避免直接修改后端返回的原始数据。

七、 总结

前端权限控制不仅仅是几行代码,它是对前端架构能力的综合考量。一套优雅的权限体系必须具备"静若处子(静态路由兜底)、动如脱兔(动态路由加载)、无感切换(无缝刷新不丢状态)"的特性。

以上方案已在我们独立开发的全栈智测平台(AITest V1.0)中稳定运行,处理数百个菜单节点与数十种角色分配时,依然丝滑流畅。希望这篇深度实践能帮你在 Vue3 的进阶之路上,绕开权限控制的那些坑。

**我是 Jason,独立开发者。专注全栈开发与架构设计,AI 智能落地。个人主页有我连载的 AITest 实战复盘,欢迎关注,随时交流。**