一文学会vue的动态权限控制

一、核心原理与流程总览

动态权限控制的本质是:用户登录后,从后端获取其权限数据,前端根据此数据动态地构建出只属于该用户的可访问路由和菜单,并在视图层面(按钮)进行权限控制。

整个流程可以分为以下几个核心步骤,下图清晰地展示了其工作原理和闭环流程:

flowchart TD A[用户登录] --> B[获取用户权限数据JSON] B -- 解析为前端所需结构 --> C[生成动态路由] C -- addRoute添加到路由器 --> D[路由器Router] D -- 根据当前路由生成 --> E[侧边栏菜单
(动态菜单组件)] E -- 点击菜单项触发路由切换 --> D F[访问路由] --> G{路由守卫检查权限} G -- 有权限 --> H[正常渲染组件] G -- 无权限 --> I[跳转404或登录页] H -- 组件内按钮 --> J{按钮权限指令v-permission} J -- 权限码匹配 --> K[显示按钮] J -- 权限码不匹配 --> L[移除按钮DOM]

下面,我们将按照这个流程中的每一个环节,进行详细的原理说明和代码实现。


二、详细步骤与代码实现

步骤 1: 定义权限数据结构与状态管理

首先,我们需要在后端和前端约定好权限数据的结构。

1.1 后端返回的权限数据示例 (GET /api/user/permissions):

通常,后端会返回一个树形结构,包含前端定义的路由和权限点。

json 复制代码
{
  "code": 200,
  "data": {
    "userInfo": { "name": "Alice", "avatar": "" },
    "permissions": [
      {
        "id": 1,
        "parentId": 0,
        "path": "/system",
        "name": "System",
        "meta": { "title": "系统管理", "icon": "setting", "requiresAuth": true },
        "children": [
          {
            "id": 2,
            "parentId": 1,
            "path": "user",
            "name": "UserManagement",
            "meta": { "title": "用户管理", "requiresAuth": true },
            "btnPermissions": ["user:add", "user:edit", "user:delete"] // 按钮级权限标识
          }
        ]
      },
      {
        "id": 3,
        "parentId": 0,
        "path": "/about",
        "name": "About",
        "meta": { "title": "关于", "icon": "info", "requiresAuth": false }
      }
    ]
  }
}

1.2 前端定义静态路由和动态路由

我们将路由分为两类:

  • 静态路由 (Constant Routes): 无需权限即可访问的路由,如 /login, /404
  • 动态路由 (Dynamic Routes / Async Routes): 需要根据权限动态添加的路由。

/src/router/index.js

javascript 复制代码
import { createRouter, createWebHistory } from 'vue-router';
import { useUserStore } from '@/stores/user';

// 静态路由
export const constantRoutes = [
  {
    path: '/login',
    name: 'Login',
    component: () => import('@/views/Login.vue'),
    meta: { title: '登录', hidden: true } // hidden 表示不在侧边栏显示
  },
  {
    path: '/404',
    name: 'NotFound',
    component: () => import('@/views/404.vue'),
    meta: { title: '404', hidden: true }
  }
];

// 动态路由(初始化为空,后续根据权限添加)
// 注意:这里不是直接定义,而是提供一个和后台数据匹配的模板
export const asyncRoutesMap = {
  'UserManagement': {
    path: 'user', // 会拼接到父路由的 path 上
    name: 'UserManagement',
    component: () => import('@/views/system/UserManagement.vue'), // 需要提前创建好组件
    meta: { title: '用户管理', requiresAuth: true }
  },
  'RoleManagement': {
    path: 'role',
    name: 'RoleManagement',
    component: () => import('@/views/system/RoleManagement.vue'),
    meta: { title: '角色管理', requiresAuth: true }
  }
  // ... 其他所有可能的路由
};

const router = createRouter({
  history: createWebHistory(),
  routes: constantRoutes // 初始化时只挂载静态路由
});

export default router;

1.3 使用 Pinia 存储权限状态
/src/stores/user.js

javascript 复制代码
import { defineStore } from 'pinia';
import { ref } from 'vue';
import { getPermission } from '@/api/user';
import { asyncRoutesMap } from '@/router';
import { generateRoutes, generateMenu } from '@/utils/permission';

export const useUserStore = defineStore('user', () => {
  const token = ref('');
  const userInfo = ref({});
  const permissions = ref([]); // 存储原始权限数据
  const dynamicRoutes = ref([]); // 存储生成后的动态路由对象
  const menus = ref([]); // 存储用于生成导航菜单的数据

  // 获取用户权限信息
  const getUserPermissions = async () => {
    try {
      const res = await getPermission();
      permissions.value = res.data.permissions;
      userInfo.value = res.data.userInfo;

      // 核心:根据权限数据生成动态路由和菜单
      const { routes, menuList } = generateRoutesAndMenus(permissions.value, asyncRoutesMap);
      dynamicRoutes.value = routes;
      menus.value = menuList;

      return dynamicRoutes.value;
    } catch (error) {
      console.error('获取权限失败', error);
      return [];
    }
  };

  // 退出登录清空状态
  const logout = () => {
    token.value = '';
    userInfo.value = {};
    permissions.value = [];
    dynamicRoutes.value = [];
    menus.value = [];
  };

  return {
    token,
    userInfo,
    permissions,
    dynamicRoutes,
    menus,
    getUserPermissions,
    logout
  };
});

// 工具函数:递归处理权限数据,生成路由和菜单
export const generateRoutesAndMenus = (permissionList, routeMap) => {
  const routes = [];
  const menuList = [];

  const traverse = (nodes, isChild = false) => {
    nodes.forEach(node => {
      // 1. 生成菜单项
      const menuItem = {
        path: node.path,
        name: node.name,
        meta: { ...node.meta, btnPermissions: node.btnPermissions }, // 保存按钮权限
        children: []
      };
      if (isChild) {
        menuList[menuList.length - 1]?.children.push(menuItem);
      } else {
        menuList.push(menuItem);
      }

      // 2. 生成路由项 (只处理有 component 的节点,即叶子节点或需要布局的节点)
      // 如果后端返回的节点名称能在我们的映射表 asyncRoutesMap 中找到,说明是有效路由
      if (routeMap[node.name]) {
        const route = {
          ...routeMap[node.name], // 展开映射表中的预设配置(最重要的是component)
          path: node.path,
          name: node.name,
          meta: { ...node.meta, btnPermissions: node.btnPermissions }
        };
        routes.push(route);
      }

      // 3. 递归处理子节点
      if (node.children && node.children.length > 0) {
        traverse(node.children, true);
      }
    });
  };

  traverse(permissionList);
  return { routes, menuList };
};

步骤 2: 登录与获取权限数据

/src/views/Login.vue

vue 复制代码
<script setup>
import { ref } from 'vue';
import { useRouter } from 'vue-router';
import { useUserStore } from '@/stores/user';

const router = useRouter();
const userStore = useUserStore();

const loginForm = ref({ username: '', password: '' });

const handleLogin = async () => {
  try {
    // 1. 执行登录请求,获取 token
    const loginRes = await api.login(loginForm.value);
    userStore.token = loginRes.data.token;

    // 2. 获取用户权限信息
    const dynamicRoutes = await userStore.getUserPermissions();

    // 3. 动态添加路由
    dynamicRoutes.forEach(route => {
      // 注意:addRoute 可以接受父路由的 name 作为第一个参数,来实现嵌套路由的添加
      // 这里假设我们的权限数据已经是一个平铺的数组,或者使用其他方式匹配父路由
      // 一种更复杂的实现需要递归处理嵌套路由的添加,这里简化演示
      router.addRoute(route); // 添加到根路由
      // 如果路由有父级,例如:router.addRoute('ParentRouteName', route);
    });

    // 4. 添加一个兜底的 404 路由(必须放在最后)
    router.addRoute({
      path: '/:pathMatch(.*)*',
      name: 'CatchAll',
      redirect: '/404'
    });

    // 5. 跳转到首页
    router.push('/');
  } catch (error) {
    console.error('登录失败', error);
  }
};
</script>

步骤 3: 路由守卫进行权限校验

/src/router/index.js (在原有代码上追加)

javascript 复制代码
// ... 之前的导入和路由初始化代码 ...

// 路由守卫
router.beforeEach(async (to, from, next) => {
  const userStore = useUserStore();
  const token = userStore.token;

  // 1. 判断是否有 token
  if (token) {
    // 2. 如果是访问登录页,直接跳转到首页
    if (to.path === '/login') {
      next('/');
    } else {
      // 3. 判断是否已经拉取过用户权限信息
      if (userStore.permissions.length === 0) {
        try {
          // 4. 如果没有获取权限,则获取权限并添加动态路由
          const dynamicRoutes = await userStore.getUserPermissions();
          dynamicRoutes.forEach(route => {
            router.addRoute(route);
          });
          // 5. 添加完动态路由后,需要重定向到目标路由 to
          // replace: true 防止重复添加路由导致导航失败
          next({ ...to, replace: true });
        } catch (error) {
          // 6. 如果获取失败,可能是 token 过期,清除状态并跳回登录页
          userStore.logout();
          next(`/login?redirect=${to.path}`);
        }
      } else {
        // 7. 如果已经有权限信息,直接放行
        next();
      }
    }
  } else {
    // 8. 没有 token
    if (to.meta.requiresAuth === false || to.path === '/login') {
      // 如果目标路由不需要权限或者是登录页,则放行
      next();
    } else {
      // 否则,跳转到登录页,并记录重定向地址
      next(`/login?redirect=${to.path}`);
    }
  }
});

步骤 4: 根据权限数据生成动态菜单

使用上面 Pinia 中生成的 menus 来循环生成侧边栏菜单。

/src/components/Layout/Sidebar.vue

vue 复制代码
<template>
  <el-menu
    :default-active="$route.path"
    router
    unique-opened
    background-color="#304156"
    text-color="#bfcbd9"
    active-text-color="#409EFF"
  >
    <sidebar-item
      v-for="menu in userStore.menus"
      :key="menu.path"
      :item="menu"
    />
  </el-menu>
</template>

<script setup>
import SidebarItem from './SidebarItem.vue';
import { useUserStore } from '@/stores/user';

const userStore = useUserStore();
</script>

/src/components/Layout/SidebarItem.vue (递归组件)

vue 复制代码
<template>
  <!--- 如果有子菜单,渲染 el-sub-menu -->
  <el-sub-menu
    v-if="item.children && item.children.length > 0"
    :index="item.path"
  >
    <template #title>
      <el-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 -->
  <el-menu-item v-else :index="resolvePath(item.path)">
    <el-icon><component :is="item.meta.icon" /></el-icon>
    <template #title>{{ item.meta.title }}</template>
  </el-menu-item>
</template>

<script setup>
import { resolve } from 'path-browserify';

const props = defineProps({
  item: {
    type: Object,
    required: true
  },
  basePath: {
    type: String,
    default: ''
  }
});

// 处理完整路径(如果需要处理嵌套路径)
function resolvePath(routePath) {
  return resolve(props.basePath, routePath);
}
</script>

步骤 5: 实现按钮级权限控制

有两种常见方式:自定义指令函数组件。这里展示更优雅的自定义指令方式。

5.1 创建权限指令 v-permission
/src/directives/permission.js

javascript 复制代码
import { useUserStore } from '@/stores/user';

// 按钮权限检查函数
function checkPermission(el, binding) {
  const { value } = binding; // 指令的绑定值,例如 v-permission="'user:add'"
  const userStore = useUserStore();
  const btnPermissions = userStore.currentRouteBtnPermissions; // 需要从当前路由元信息中获取按钮权限

  // 从当前路由的 meta 中获取按钮权限列表
  // 注意:需要在路由守卫或菜单生成时,将 btnPermissions 存储到当前路由的 meta 中
  // 这里假设我们已经有了 currentRouteBtnPermissions

  if (value && Array.isArray(btnPermissions)) {
    const hasPermission = btnPermissions.includes(value);
    if (!hasPermission) {
      // 如果没有权限,则移除该元素
      el.parentNode && el.parentNode.removeChild(el);
    }
  } else {
    throw new Error(`需要指定权限标识,如 v-permission="'user:add'"`);
  }
}

export default {
  mounted(el, binding) {
    checkPermission(el, binding);
  },
  updated(el, binding) {
    checkPermission(el, binding);
  }
};

/src/main.js

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

const app = createApp(App);
app.directive('permission', permissionDirective);
// ...

5.2 在 Pinia 中提供获取当前路由按钮权限的方法

修改 /src/stores/user.js

javascript 复制代码
import { useRoute } from 'vue-router';
// ...
export const useUserStore = defineStore('user', () => {
  // ... 其他状态 ...
  
  // 计算属性:获取当前路由的按钮权限
  const currentRouteBtnPermissions = computed(() => {
    const route = useRoute();
    return route.meta.btnPermissions || []; // 从当前路由的元信息中获取
  });

  return {
    // ... 其他返回 ...
    currentRouteBtnPermissions
  };
});

5.3 在组件中使用指令
/src/views/system/UserManagement.vue

vue 复制代码
<template>
  <div>
    <el-button
      type="primary"
      v-permission="'user:add'"
      @click="handleAdd"
    >新增用户</el-button>

    <el-button
      type="warning"
      v-permission="'user:edit'"
      @click="handleEdit"
    >编辑</el-button>

    <el-button
      type="danger"
      v-permission="'user:delete'"
      @click="handleDelete"
    >删除</el-button>

    <el-table :data="tableData">
      <!-- ... -->
    </el-table>
  </div>
</template>

三、注意事项与优化

  1. 路由组件加载: 确保 component: () => import(...) 中的路径正确,Webpack/Vite 会将这些组件打包到独立的 chunk 中实现懒加载。
  2. 404 路由处理: 动态添加路由后,一定要确保 router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' }) 是最后一个添加的路由。
  3. 按钮权限的存储: 上述指令示例中,按钮权限是从当前路由的 meta 中获取。你需要确保在路由导航守卫或生成动态路由时,将每个路由对应的 btnPermissions 正确地设置到其 meta 中。
  4. 权限更新: 如果系统支持用户动态更改权限(如切换角色),需要在权限变更后调用 router.go(0) 刷新页面或手动重置路由状态。
  5. 安全性: 前端权限控制只是为了用户体验和基础防护,真正的权限校验必须在后端 API 层面严格执行