后台权限与菜单渲染:基于路由和后端返回的几种实现方式

同学们好,我是 Eugene(尤金),一个拥有多年中后台开发经验的前端工程师~

(Eugene 发音很简单,/juːˈdʒiːn/,大家怎么顺口怎么叫就好)

你是否也有过:明明学过很多技术,一到关键时候却讲不出来、甚至写不出来?

你是否也曾怀疑自己,是不是太笨了,明明感觉会,却总差一口气?

就算想沉下心从头梳理,可工作那么忙,回家还要陪伴家人。

一天只有24小时,时间永远不够用,常常感到力不从心。

技术行业,本就是逆水行舟,不进则退。

如果你也有同样的困扰,别慌。

从现在开始,跟着我一起心态归零 ,利用碎片时间,来一次彻彻底底的基础扫盲

这一次,我们一起慢慢来,扎扎实实变强。

不搞花里胡哨的理论堆砌,只分享看得懂、用得上的前端干货,

咱们一起稳步积累,真正摆脱"面向搜索引擎写代码"的尴尬。

一、先搞清楚:权限到底分几层?

很多人一上来就想"我要做权限控制",但连权限分几层都没理清楚。我们先建立一个清晰的分层认知:

层级 控制什么 典型实现方式
路由级权限 用户能不能访问某个页面 路由守卫 + 动态路由
菜单级权限 侧边栏显示哪些菜单项 后端返回菜单 / 前端根据角色过滤
按钮级权限 页面内某个按钮是否可见/可点 自定义指令 / 组件封装
接口级权限 后端接口是否允许调用 后端网关/中间件拦截(前端兜底)

关键认识:前端权限控制本质上是"体验优化",真正的安全屏障在后端。 前端做的事情是:不该看的别让用户看到,不该点的别让用户点到。但如果有人绕过前端直接调接口,后端必须自己挡住。

二、路由级权限:从静态到动态的三种方案

方案一:最朴素的路由守卫 ------ 路由 meta + 全局前置守卫

适用场景:角色简单(比如只有 admin 和 user 两种),页面不多。

思路 :所有路由在前端写死,通过 meta 字段标记需要的角色,在全局路由守卫里做判断。

完整示例

路由配置:

javascript 复制代码
// router/index.js
import Vue from 'vue'
import VueRouter from 'vue-router'
import Layout from '@/layout/index.vue'

Vue.use(VueRouter)

const routes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    meta: { requiresAuth: false }
  },
  {
    path: '/',
    component: Layout,
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      },
      {
        path: 'user-manage',
        component: () => import('@/views/user-manage.vue'),
        meta: { requiresAuth: true, roles: ['admin'] }
      },
      {
        path: 'order-list',
        component: () => import('@/views/order-list.vue'),
        meta: { requiresAuth: true, roles: ['admin', 'user'] }
      }
    ]
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue')
  }
]

const router = new VueRouter({ routes })

export default router

全局守卫:

javascript 复制代码
// router/permission.js
import router from './index'
import store from '@/store'

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  // 1. 去登录页:有 token 就跳首页,没有就放行
  if (to.path === '/login') {
    token ? next('/') : next()
    return
  }

  // 2. 没有 token,去登录
  if (!token) {
    next(`/login?redirect=${to.path}`)
    return
  }

  // 3. 有 token,但用户信息还没拉取(刷新页面的场景)
  if (!store.getters.userInfo) {
    try {
      await store.dispatch('user/getUserInfo')
    } catch (error) {
      // token 过期或无效,清除后跳登录
      await store.dispatch('user/logout')
      next(`/login?redirect=${to.path}`)
      return
    }
  }

  // 4. 检查角色权限
  if (to.meta.roles) {
    const userRole = store.getters.role
    if (to.meta.roles.includes(userRole)) {
      next()
    } else {
      next('/403')
    }
  } else {
    next()
  }
})

这种方案的优缺点

优点:简单直观,5 分钟就能写完,小项目完全够用。

缺点

  • 所有路由都注册了,只是守卫拦着不让进。用户在浏览器地址栏敲地址虽然会被拦截,但路由本身是存在的。
  • 角色和路由的对应关系写死在前端,改权限就得改代码、重新发版。
  • 菜单渲染还得另外写一套过滤逻辑。

踩坑点

坑 1:刷新页面时 userInfo 丢失。 Vuex 的状态刷新后就没了,所以守卫里必须有"重新获取用户信息"这一步。很多人一开始忘了这一步,导致刷新后直接跳登录页。

坑 2:next() 多次调用。 在一个守卫函数里,next() 只应该被调用一次。如果你的 if-else 分支写得不够严谨,可能会出现 next() 被调用多次的情况,导致诡异的跳转。上面示例里每个分支都 return 了,就是为了避免这个问题。


方案二:动态路由 ------ 前端存完整路由表,登录后按角色过滤

适用场景:角色较多,但角色和权限的对应关系前端可以维护。

思路 :前端维护一份"完整路由表"和一份"基础路由表"。用户登录后,根据角色从完整路由表中过滤出有权限的路由,通过 router.addRoutes()(Vue Router 3)或 router.addRoute()(Vue Router 4)动态添加。

完整示例

先把路由分成两份:

javascript 复制代码
// router/routes.js

// 基础路由 ------ 所有人都能访问
export const constantRoutes = [
  {
    path: '/login',
    component: () => import('@/views/login.vue'),
    hidden: true  // 菜单里不显示
  },
  {
    path: '/403',
    component: () => import('@/views/403.vue'),
    hidden: true
  }
]

// 动态路由 ------ 需要根据角色过滤
export const asyncRoutes = [
  {
    path: '/',
    component: () => import('@/layout/index.vue'),
    redirect: '/dashboard',
    children: [
      {
        path: 'dashboard',
        component: () => import('@/views/dashboard.vue'),
        meta: { title: '首页', icon: 'home', roles: ['admin', 'user', 'editor'] }
      }
    ]
  },
  {
    path: '/system',
    component: () => import('@/layout/index.vue'),
    meta: { title: '系统管理', icon: 'setting', roles: ['admin'] },
    children: [
      {
        path: 'user',
        component: () => import('@/views/system/user.vue'),
        meta: { title: '用户管理', roles: ['admin'] }
      },
      {
        path: 'role',
        component: () => import('@/views/system/role.vue'),
        meta: { title: '角色管理', roles: ['admin'] }
      }
    ]
  },
  {
    path: '/content',
    component: () => import('@/layout/index.vue'),
    meta: { title: '内容管理', icon: 'document' },
    children: [
      {
        path: 'article',
        component: () => import('@/views/content/article.vue'),
        meta: { title: '文章管理', roles: ['admin', 'editor'] }
      },
      {
        path: 'comment',
        component: () => import('@/views/content/comment.vue'),
        meta: { title: '评论管理', roles: ['admin'] }
      }
    ]
  }
]

过滤函数:

javascript 复制代码
// utils/permission.js

/**
 * 判断用户角色是否匹配路由要求
 */
function hasPermission(route, role) {
  if (route.meta && route.meta.roles) {
    return route.meta.roles.includes(role)
  }
  // 没有设置 roles 的路由,默认所有人可访问
  return true
}

/**
 * 递归过滤路由表
 * 注意:这里要深拷贝,不能污染原始路由表
 */
export function filterAsyncRoutes(routes, role) {
  const result = []

  routes.forEach(route => {
    // 浅拷贝一份,避免修改原对象
    const tmp = { ...route }

    if (hasPermission(tmp, role)) {
      // 如果有子路由,递归过滤
      if (tmp.children) {
        tmp.children = filterAsyncRoutes(tmp.children, role)
      }
      result.push(tmp)
    }
  })

  return result
}

在 Vuex 中集成(也可以用 Pinia,思路一样):

javascript 复制代码
// store/modules/permission.js
import { constantRoutes, asyncRoutes } from '@/router/routes'
import { filterAsyncRoutes } from '@/utils/permission'

const state = {
  routes: [],        // 最终的完整路由(用于渲染菜单)
  addedRoutes: []    // 动态添加的部分
}

const mutations = {
  SET_ROUTES(state, routes) {
    state.addedRoutes = routes
    state.routes = constantRoutes.concat(routes)
  }
}

const actions = {
  generateRoutes({ commit }, role) {
    return new Promise(resolve => {
      let accessedRoutes

      // admin 拥有全部权限,直接用完整路由表
      if (role === 'admin') {
        accessedRoutes = asyncRoutes
      } else {
        accessedRoutes = filterAsyncRoutes(asyncRoutes, role)
      }

      commit('SET_ROUTES', accessedRoutes)
      resolve(accessedRoutes)
    })
  }
}

export default {
  namespaced: true,
  state,
  mutations,
  actions
}

在路由守卫里动态添加:

javascript 复制代码
// router/permission.js
import router from './index'
import store from '@/store'

const whiteList = ['/login', '/403']

router.beforeEach(async (to, from, next) => {
  const token = store.getters.token

  if (token) {
    if (to.path === '/login') {
      next('/')
      return
    }

    // 判断是否已经生成过动态路由
    const hasRoutes = store.getters.addedRoutes && store.getters.addedRoutes.length > 0

    if (hasRoutes) {
      next()
    } else {
      try {
        // 获取用户信息(含角色)
        const { role } = await store.dispatch('user/getUserInfo')

        // 根据角色生成可访问路由
        const accessRoutes = await store.dispatch('permission/generateRoutes', role)

        // 动态添加路由(Vue Router 3 用 addRoutes,4 用 addRoute)
        // Vue Router 3:
        router.addRoutes(accessRoutes)

        // Vue Router 4 的写法:
        // accessRoutes.forEach(route => {
        //   router.addRoute(route)
        // })

        // 用 replace 确保 addRoutes 生效后再跳转
        // hack:{ ...to } 会重新解析路由,确保新加的路由能匹配到
        next({ ...to, replace: true })
      } catch (error) {
        await store.dispatch('user/logout')
        next(`/login?redirect=${to.path}`)
      }
    }
  } else {
    if (whiteList.includes(to.path)) {
      next()
    } else {
      next(`/login?redirect=${to.path}`)
    }
  }
})

踩坑点

坑 1:next({ ...to, replace: true }) 是必须的。 这一行容易被忽略。addRoutes 是异步生效的,如果你直接 next(),此时新路由可能还没注册完,就会匹配到 404。next({ ...to, replace: true }) 相当于"用当前目标地址重新走一次路由匹配",此时新路由已经注册好了。

坑 2:刷新页面后动态路由丢失。 addRoutes 添加的路由在刷新后就没了(因为是运行时加的,不是写死在 router 实例化时的)。所以守卫里用 hasRoutes 标志位来判断,如果没了就重新走一遍 generateRoutes → addRoutes 的流程。

坑 3:过滤路由时污染原始数据。 filterAsyncRoutes 一定要拷贝一份再操作。如果你直接改 asyncRoutes 里的对象,下次退出登录换个角色重新登录,过滤就乱了------因为原始路由表已经被改过了。


方案三:完全由后端控制路由表 ------ 前端动态生成路由

适用场景:大型后台系统、权限管理非常灵活、角色和菜单由运营/管理员后台配置。

思路:后端返回当前用户有权限的菜单/路由数据(JSON),前端拿到后转换成 Vue Router 能识别的路由对象,然后动态添加。

后端返回的数据长什么样(典型格式)

json 复制代码
[
  {
    "id": 1,
    "parentId": 0,
    "path": "/dashboard",
    "component": "views/dashboard",
    "name": "Dashboard",
    "meta": { "title": "首页", "icon": "home" }
  },
  {
    "id": 2,
    "parentId": 0,
    "path": "/system",
    "component": "layout/index",
    "name": "System",
    "meta": { "title": "系统管理", "icon": "setting" },
    "children": [
      {
        "id": 3,
        "parentId": 2,
        "path": "user",
        "component": "views/system/user",
        "name": "UserManage",
        "meta": { "title": "用户管理" }
      },
      {
        "id": 4,
        "parentId": 2,
        "path": "role",
        "component": "views/system/role",
        "name": "RoleManage",
        "meta": { "title": "角色管理" }
      }
    ]
  }
]

注意:后端返回的 component 是一个字符串路径 ,不是真正的组件。前端需要自己把这个字符串映射成 () => import(...) 的动态导入。

核心:字符串转组件的映射函数

javascript 复制代码
// utils/route-helper.js

// 方式一:用 import() 的动态拼接
// 注意:Webpack 的 import() 不支持完全动态的字符串,必须有一部分是静态的
function loadComponent(componentPath) {
  // 这里 '@/' 是静态前缀,后面拼动态部分,Webpack 才能正确分析
  return () => import(`@/${componentPath}.vue`)
}

// 方式二(更推荐):维护一个显式映射表,更可控
const componentMap = {
  'layout/index': () => import('@/layout/index.vue'),
  'views/dashboard': () => import('@/views/dashboard.vue'),
  'views/system/user': () => import('@/views/system/user.vue'),
  'views/system/role': () => import('@/views/system/role.vue'),
  'views/content/article': () => import('@/views/content/article.vue'),
  // ...根据项目页面逐步维护
}

function loadComponentByMap(componentPath) {
  const loader = componentMap[componentPath]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath},将渲染 404 页面`)
    return () => import('@/views/404.vue')
  }
  return loader
}

/**
 * 把后端返回的路由数据转换成 Vue Router 格式
 */
export function transformRoutes(backendRoutes) {
  return backendRoutes.map(route => {
    const tmp = { ...route }

    // 字符串组件路径 → 真实组件
    if (tmp.component) {
      tmp.component = loadComponentByMap(tmp.component)
    }

    // 递归处理子路由
    if (tmp.children && tmp.children.length > 0) {
      tmp.children = transformRoutes(tmp.children)
    }

    return tmp
  })
}

在权限 store 中使用:

javascript 复制代码
// store/modules/permission.js
import { constantRoutes } from '@/router/routes'
import { transformRoutes } from '@/utils/route-helper'
import { getUserMenus } from '@/api/user'

const actions = {
  async generateRoutes({ commit }) {
    // 从后端获取当前用户的菜单/路由数据
    const { data: backendRoutes } = await getUserMenus()

    // 将后端数据转换成 Vue Router 路由对象
    const accessedRoutes = transformRoutes(backendRoutes)

    commit('SET_ROUTES', accessedRoutes)
    return accessedRoutes
  }
}

路由守卫的写法和方案二基本一样,只是 generateRoutes 不再需要传角色了------后端已经帮你过滤好了。

踩坑点

坑 1:Webpack 的 import() 不能用完全动态的变量。 比如 import(componentPath) 这样写是不行的,Webpack 需要至少一个静态的目录前缀来确定搜索范围。所以要么写成 import(`@/views/${componentPath}.vue`),要么像上面那样用映射表。Vite 的场景下 可以用 import.meta.glob 来实现更优雅的批量导入,后面会提到。

坑 2:后端返回的树形结构可能是扁平的。 有些后端返回的不是嵌套好的 tree,而是一个带 parentId 的扁平数组。这时候你需要先在前端组装成树形结构:

javascript 复制代码
/**
 * 扁平数组 → 树形结构
 */
export function buildTree(flatList) {
  const map = {}
  const tree = []

  // 第一遍:建立 id → item 的映射
  flatList.forEach(item => {
    map[item.id] = { ...item, children: [] }
  })

  // 第二遍:根据 parentId 挂到父节点的 children 下
  flatList.forEach(item => {
    const node = map[item.id]
    if (item.parentId === 0) {
      tree.push(node)
    } else {
      const parent = map[item.parentId]
      if (parent) {
        parent.children.push(node)
      }
    }
  })

  return tree
}

坑 3:Vite 环境下 import() 的写法不同。 如果你用的是 Vite(Vue 3 项目大概率是),可以用 import.meta.glob 来做组件映射:

javascript 复制代码
// Vite 专用写法
const modules = import.meta.glob('@/views/**/*.vue')

function loadComponent(componentPath) {
  const key = `/src/${componentPath}.vue`
  const loader = modules[key]
  if (!loader) {
    console.warn(`[路由警告] 找不到组件: ${componentPath}`)
    return modules['/src/views/404.vue']
  }
  return loader
}

import.meta.glob 返回的本身就是 { 路径: () => import(...) } 的映射对象,天然适合做这个事情,而且不需要手动维护映射表。


三种方案对比总结

维度 方案一:meta 守卫 方案二:前端过滤 方案三:后端返回
复杂度 ⭐⭐ ⭐⭐⭐
灵活度 低,改权限要发版 中,角色固定时够用 高,运营后台可动态配置
安全性 路由全暴露 路由全暴露(只是不添加) 前端只有有权限的路由
菜单渲染 需另外过滤 过滤后的路由即菜单 后端数据即菜单
适合场景 内部小工具 中型项目 大型后台 / SaaS

我的建议:如果你的项目超过 10 个菜单项,或者权限角色超过 3 种,直接上方案三。前期多花半天时间,后期能省几周的维护成本。

三、菜单渲染:路由即菜单 vs 菜单和路由分离

方式一:路由即菜单

这是最常见的做法------侧边栏菜单直接根据路由表渲染。方案二和方案三天然支持这种方式:过滤后的路由表就是菜单数据。

html 复制代码
<!-- layout/Sidebar.vue -->
<template>
  <div class="sidebar">
    <template v-for="route in menuRoutes">
      <!-- 只有一个子菜单或没有子菜单:直接渲染为菜单项 -->
      <router-link
        v-if="!route.children || route.children.length <= 1"
        :key="route.path"
        :to="route.children ? route.children[0].path : route.path"
        class="menu-item"
      >
        <i :class="route.meta?.icon" />
        <span>{{ route.meta?.title || route.children?.[0]?.meta?.title }}</span>
      </router-link>

      <!-- 多个子菜单:渲染为可展开的菜单组 -->
      <div v-else :key="route.path" class="submenu">
        <div class="submenu-title">
          <i :class="route.meta?.icon" />
          <span>{{ route.meta?.title }}</span>
        </div>
        <router-link
          v-for="child in route.children.filter(c => !c.hidden)"
          :key="child.path"
          :to="`${route.path}/${child.path}`"
          class="menu-item"
        >
          <span>{{ child.meta?.title }}</span>
        </router-link>
      </div>
    </template>
  </div>
</template>

<script>
export default {
  computed: {
    menuRoutes() {
      // 从 store 拿过滤后的路由,排除 hidden 的
      return this.$store.getters.routes.filter(r => !r.hidden)
    }
  }
}
</script>

优点:菜单和路由保持一致,不会出现"菜单有但页面 404"或"页面有但菜单没显示"的错位问题。

缺点:菜单的层级、排序完全受路由结构限制。如果产品经理说"这个页面属于 A 模块,但菜单要放在 B 模块下面",你就麻烦了。

方式二:菜单和路由分离

后端分别返回两套数据:一套是菜单数据 (控制侧边栏显示),一套是权限标识(控制路由注册和按钮权限)。

javascript 复制代码
// 后端返回的菜单数据(只关心展示)
const menus = [
  {
    title: '首页',
    icon: 'home',
    path: '/dashboard'
  },
  {
    title: '运营中心',    // 这是一个虚拟的分组,不对应任何路由
    icon: 'operation',
    children: [
      { title: '文章管理', path: '/content/article' },
      { title: '订单列表', path: '/order/list' }   // 注意:订单本来在"订单模块",但菜单放在了"运营中心"
    ]
  }
]

// 后端返回的权限标识(控制路由和按钮)
const permissions = [
  'dashboard',
  'content:article',
  'content:article:edit',
  'content:article:delete',
  'order:list',
  'order:detail'
]

优点:菜单的展示结构完全灵活,不受路由层级约束。

缺点 :要同时维护菜单和路由两套东西,且必须保证菜单的 path 和路由的 path 对得上,否则会出现点菜单跳 404 的情况。

我的建议:除非产品对菜单的展示结构有特殊要求,否则优先用"路由即菜单"。简单就是美。

四、按钮级权限:自定义指令 vs 组件封装

这是权限控制里最细粒度的一层。典型场景:同一个页面,管理员能看到"编辑"和"删除"按钮,普通用户只能看到"查看"。

方式一:自定义指令 v-permission

思路:写一个自定义指令,绑定在按钮上。指令内部检查当前用户的权限列表,如果没权限就把这个 DOM 元素移除。

javascript 复制代码
// directives/permission.js
import store from '@/store'

export default {
  // Vue 2 写法
  inserted(el, binding) {
    const { value: requiredPermission } = binding
    const permissions = store.getters.permissions  // 用户的权限标识列表

    if (!requiredPermission) return

    // 支持传单个字符串或数组
    const requiredList = Array.isArray(requiredPermission)
      ? requiredPermission
      : [requiredPermission]

    // 检查用户是否拥有所需权限中的至少一个
    const hasPermission = requiredList.some(p => permissions.includes(p))

    if (!hasPermission) {
      // 没权限:移除 DOM 元素
      el.parentNode && el.parentNode.removeChild(el)
    }
  }

  // Vue 3 写法(钩子名不同):
  // mounted(el, binding) { ... }  // 对应 Vue 2 的 inserted
}

全局注册:

javascript 复制代码
// main.js
import permissionDirective from '@/directives/permission'

// Vue 2
Vue.directive('permission', permissionDirective)

// Vue 3
app.directive('permission', permissionDirective)

使用:

html 复制代码
<template>
  <div>
    <button v-permission="'content:article:edit'" @click="handleEdit">
      编辑
    </button>
    
    <button v-permission="'content:article:delete'" @click="handleDelete">
      删除
    </button>

    <!-- 也支持传数组:拥有其中任意一个权限即可 -->
    <button v-permission="['content:article:edit', 'content:article:publish']">
      编辑或发布
    </button>
  </div>
</template>

踩坑点

坑 1(非常重要):用 v-if 还是操作 DOM? 很多人觉得指令里直接 removeChild 太粗暴了。确实,这种方式有个问题:一旦移除了就不会再回来。如果你的权限数据是异步获取的,指令执行时权限还没拿到,按钮就被误删了。

解决方案有两种:

  1. 确保权限数据一定在组件渲染前就位(在路由守卫里获取完用户信息再放行)。
  2. 不用 removeChild,改成 el.style.display = 'none',然后在 update 钩子里重新检查。

坑 2:指令方式无法与 v-if / v-show 配合。 如果你在同一个元素上同时用了 v-permissionv-if,逻辑会变得混乱。建议二选一。

方式二:组件封装 <Permission>

思路:封装一个函数式组件,通过插槽来控制内容的渲染。

html 复制代码
<!-- components/Permission.vue -->
<script>
export default {
  name: 'Permission',
  functional: true,   // Vue 2 函数式组件,性能更好
  props: {
    value: {
      type: [String, Array],
      required: true
    }
  },
  render(h, context) {
    const { value } = context.props
    const permissions = context.parent.$store.getters.permissions

    const requiredList = Array.isArray(value) ? value : [value]
    const hasPermission = requiredList.some(p => permissions.includes(p))

    // 有权限则渲染插槽内容,否则渲染空
    return hasPermission ? context.children : null
  }
}
</script>

Vue 3 的 Composition API 写法:

html 复制代码
<!-- components/Permission.vue (Vue 3) -->
<template>
  <slot v-if="hasPermission" />
</template>

<script setup>
import { computed } from 'vue'
import { useStore } from 'vuex'  // 或者 import { usePermissionStore } from '@/stores/permission'

const props = defineProps({
  value: {
    type: [String, Array],
    required: true
  }
})

const store = useStore()

const hasPermission = computed(() => {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(props.value) ? props.value : [props.value]
  return requiredList.some(p => permissions.includes(p))
})
</script>

使用:

html 复制代码
<template>
  <div>
    <Permission value="content:article:edit">
      <button @click="handleEdit">编辑</button>
    </Permission>

    <Permission :value="['content:article:delete']">
      <button @click="handleDelete">删除</button>
    </Permission>
  </div>
</template>

方式三(补充):直接用函数判断

有时候权限逻辑比较复杂(比如同时要判断角色 + 数据归属),指令和组件都不太方便。这时候最朴素的 v-if + 工具函数反而最好用:

javascript 复制代码
// utils/permission.js
import store from '@/store'

export function hasPermission(permission) {
  const permissions = store.getters.permissions
  const requiredList = Array.isArray(permission) ? permission : [permission]
  return requiredList.some(p => permissions.includes(p))
}

export function hasRole(role) {
  return store.getters.role === role
}
html 复制代码
<template>
  <div>
    <!-- 简单场景 -->
    <button v-if="hasPermission('content:article:edit')" @click="handleEdit">
      编辑
    </button>

    <!-- 复杂场景:不仅要有权限,还要是自己的文章 -->
    <button
      v-if="hasPermission('content:article:edit') && article.authorId === userId"
      @click="handleEdit"
    >
      编辑
    </button>
  </div>
</template>

<script>
import { hasPermission } from '@/utils/permission'

export default {
  methods: {
    hasPermission
  }
}
</script>

三种方式对比

维度 自定义指令 组件封装 函数 + v-if
简洁性 ⭐⭐⭐ 一行搞定 ⭐⭐ 需要包一层 ⭐⭐ 需要导入函数
灵活性 低,只能控制显隐 ⭐⭐⭐ 可组合复杂逻辑
响应式 需手动处理 天然响应式 天然响应式
推荐场景 纯显隐控制 团队规范统一 复杂业务逻辑

我的实战建议:项目中三种可以并存。简单的用指令,需要统一规范的用组件,复杂条件的用函数。别非要"只用一种"------工具是为业务服务的。

五、完整的权限流程串联

最后,我们把上面所有内容串起来,看一个完整的权限控制流程是怎么跑的:

bash 复制代码
用户打开浏览器,访问 /dashboard
        │
        ▼
  路由守卫拦截,检查 token
        │
    ┌───┴───┐
    │ 无token │──────→ 跳转 /login
    └───┬───┘
        │ 有token
        ▼
  是否已拉取用户信息?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取 userInfo + permissions
    └───┬───┘
        │ 已有
        ▼
  是否已生成动态路由?
        │
    ┌───┴───┐
    │  还没有  │──────→ 调接口获取菜单数据
    └───┬───┘         → transformRoutes 转换
        │             → router.addRoute 注册
        │             → next({ ...to, replace: true })
        │ 已有
        ▼
  正常进入页面
        │
        ▼
  侧边栏根据 store 里的 routes 渲染菜单
        │
        ▼
  页面内按钮根据 permissions 做显隐控制

在代码层面,一个典型项目的文件组织大概是这样的:

bash 复制代码
src/
├── router/
│   ├── index.js          # 创建 router 实例,只注册 constantRoutes
│   ├── routes.js         # constantRoutes 和 asyncRoutes(方案二用)
│   └── permission.js     # 全局路由守卫
├── store/
│   └── modules/
│       ├── user.js       # 用户信息、token、登录/登出
│       └── permission.js # 路由/权限数据、generateRoutes
├── api/
│   └── user.js           # getUserInfo、getUserMenus 等接口
├── directives/
│   └── permission.js     # v-permission 自定义指令
├── components/
│   └── Permission.vue    # 权限组件(可选)
├── utils/
│   ├── permission.js     # hasPermission 工具函数
│   └── route-helper.js   # transformRoutes、buildTree
└── layout/
    ├── index.vue         # 整体布局
    └── Sidebar.vue       # 侧边栏菜单

六、常见问题 FAQ

Q1:退出登录后需要做什么清理?

javascript 复制代码
// store/modules/user.js
async logout({ commit, dispatch }) {
  await logoutApi()               // 调后端登出接口
  commit('SET_TOKEN', '')         // 清 token
  commit('SET_USER_INFO', null)   // 清用户信息

  // 重点:重置路由!
  // Vue Router 3 没有 removeRoute,通常的做法是重新创建 router 实例
  resetRouter()

  // 清除 permission store
  dispatch('permission/resetRoutes', null, { root: true })
}

resetRouter 的实现(Vue Router 3 的经典 hack):

javascript 复制代码
// router/index.js
const createRouter = () => new VueRouter({
  routes: constantRoutes
})

const router = createRouter()

export function resetRouter() {
  const newRouter = createRouter()
  router.matcher = newRouter.matcher  // 用新 matcher 替换旧的,相当于清除了动态路由
}

export default router

Vue Router 4 就优雅多了,有 router.removeRoute() 可以用。

Q2:Token 过期怎么处理?

建议在 axios 响应拦截器里统一处理:

javascript 复制代码
// utils/request.js
service.interceptors.response.use(
  response => response,
  error => {
    if (error.response?.status === 401) {
      // token 过期或无效
      // 避免多个请求同时触发多次弹窗
      if (!isRefreshing) {
        isRefreshing = true
        MessageBox.confirm('登录已过期,请重新登录', '提示', {
          confirmButtonText: '重新登录',
          cancelButtonText: '取消',
          type: 'warning'
        }).then(() => {
          store.dispatch('user/logout').then(() => {
            location.reload()   // 简单粗暴但有效:刷新页面让路由守卫重新走流程
          })
        }).finally(() => {
          isRefreshing = false
        })
      }
    }
    return Promise.reject(error)
  }
)

Q3:同一个页面需要根据权限展示不同的布局怎么办?

不要用 v-permission(它是非此即彼的),用函数方式更灵活:

html 复制代码
<template>
  <div>
    <!-- 管理员看到完整表单 -->
    <FullForm v-if="hasRole('admin')" />
    <!-- 普通用户看到精简表单 -->
    <SimpleForm v-else />
  </div>
</template>

总结

  1. 权限分层:路由级、菜单级、按钮级、接口级,各有各的实现方式,别混在一起。
  2. 路由方案选型:小项目用 meta 守卫,中项目用前端过滤,大项目让后端返回路由表。
  3. 菜单渲染:优先"路由即菜单",除非有特殊展示需求才分离。
  4. 按钮权限:指令、组件、函数三种方式可以并存,按场景选择。
  5. 前端权限只是体验优化,后端一定要有自己的鉴权,不要把安全寄托在前端。

权限这块东西不难,但坑很多,而且大多数坑只有在刷新页面、切换角色、token 过期这些"非正常路径"才会暴露出来。所以写完权限逻辑后,一定要多测这几个场景:

  • 刷新页面后,菜单和路由是否正常
  • 直接输入 URL 访问无权限页面,是否正确拦截
  • 退出登录 → 换角色登录,菜单是否正确更新
  • Token 过期后的操作,是否平滑跳转登录页

学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。

后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。

关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。

如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。

我是 Eugene,你的电子学友,我们下一篇干货见~

相关推荐
兆子龙1 小时前
WebSocket 入门:是什么、有什么用、脚本能帮你做什么
前端·架构
csdn飘逸飘逸1 小时前
Autojs基础-全局函数与变量(globals)
javascript
月弦笙音2 小时前
【浏览器】这几点必须懂
前端
SuperEugene2 小时前
弹窗与抽屉组件封装:如何做一个全局可控的 Dialog 服务
前端·javascript·vue.js
UrbanJazzerati2 小时前
事件传播机制详解(附直观比喻和代码示例)
前端
青青家的小灰灰2 小时前
透视 React 内核:Diff 算法、合成事件与并发特性的深度解析
前端·javascript·react.js
北冥有鱼2 小时前
JSON或代码对比的工具-vue
vue.js
SuperEugene2 小时前
组合式函数 、 Hooks(Vue2 mixin 、 Vue3 composables)的实战封装
前端·javascript·vue.js
乡村中医2 小时前
AI Chat实现第一步,流式输出,教你如何实现打字流
前端