一文学会vue的动态权限控制
一、核心原理与流程总览
动态权限控制的本质是:用户登录后,从后端获取其权限数据,前端根据此数据动态地构建出只属于该用户的可访问路由和菜单,并在视图层面(按钮)进行权限控制。
整个流程可以分为以下几个核心步骤,下图清晰地展示了其工作原理和闭环流程:
flowchart TDA[用户登录] --> B[获取用户权限数据JSON]B -- 解析为前端所需结构 --> C[生成动态路由]C -- addRoute添加到路由器 --> D[路由器Router]D -- 根据当前路由生成 --> E[侧边栏菜单<br>(动态菜单组件)]E -- 点击菜单项触发路由切换 --> DF[访问路由] --> G{路由守卫检查权限}G -- 有权限 --> H[正常渲染组件]G -- 无权限 --> I[跳转404或登录页]H -- 组件内按钮 --> J{按钮权限指令v-permission}J -- 权限码匹配 --> K[显示按钮]J -- 权限码不匹配 --> L[移除按钮DOM]
下面,我们将按照这个流程中的每一个环节,进行详细的原理说明和代码实现。
二、详细步骤与代码实现
步骤 1: 定义权限数据结构与状态管理
首先,我们需要在后端和前端约定好权限数据的结构。
1.1 后端返回的权限数据示例 (GET /api/user/permissions):
通常,后端会返回一个树形结构,包含前端定义的路由和权限点。
{"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
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
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
<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. 执行登录请求,获取 tokenconst 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
(在原有代码上追加)
// ... 之前的导入和路由初始化代码 ...// 路由守卫
router.beforeEach(async (to, from, next) => {const userStore = useUserStore();const token = userStore.token;// 1. 判断是否有 tokenif (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. 没有 tokenif (to.meta.requiresAuth === false || to.path === '/login') {// 如果目标路由不需要权限或者是登录页,则放行next();} else {// 否则,跳转到登录页,并记录重定向地址next(`/login?redirect=${to.path}`);}}
});
步骤 4: 根据权限数据生成动态菜单
使用上面 Pinia 中生成的 menus
来循环生成侧边栏菜单。
/src/components/Layout/Sidebar.vue
<template><el-menu:default-active="$route.path"routerunique-openedbackground-color="#304156"text-color="#bfcbd9"active-text-color="#409EFF"><sidebar-itemv-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
(递归组件)
<template><!--- 如果有子菜单,渲染 el-sub-menu --><el-sub-menuv-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-itemv-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
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 中// 这里假设我们已经有了 currentRouteBtnPermissionsif (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
// ...
import permissionDirective from '@/directives/permission';const app = createApp(App);
app.directive('permission', permissionDirective);
// ...
5.2 在 Pinia 中提供获取当前路由按钮权限的方法
修改 /src/stores/user.js
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
<template><div><el-buttontype="primary"v-permission="'user:add'"@click="handleAdd">新增用户</el-button><el-buttontype="warning"v-permission="'user:edit'"@click="handleEdit">编辑</el-button><el-buttontype="danger"v-permission="'user:delete'"@click="handleDelete">删除</el-button><el-table :data="tableData"><!-- ... --></el-table></div>
</template>
三、注意事项与优化
- 路由组件加载: 确保
component: () => import(...)
中的路径正确,Webpack/Vite 会将这些组件打包到独立的 chunk 中实现懒加载。 - 404 路由处理: 动态添加路由后,一定要确保
router.addRoute({ path: '/:pathMatch(.*)*', redirect: '/404' })
是最后一个添加的路由。 - 按钮权限的存储: 上述指令示例中,按钮权限是从当前路由的
meta
中获取。你需要确保在路由导航守卫或生成动态路由时,将每个路由对应的btnPermissions
正确地设置到其meta
中。 - 权限更新: 如果系统支持用户动态更改权限(如切换角色),需要在权限变更后调用
router.go(0)
刷新页面或手动重置路由状态。 - 安全性: 前端权限控制只是为了用户体验和基础防护,真正的权限校验必须在后端 API 层面严格执行。