同学们好,我是 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 太粗暴了。确实,这种方式有个问题:一旦移除了就不会再回来。如果你的权限数据是异步获取的,指令执行时权限还没拿到,按钮就被误删了。
解决方案有两种:
- 确保权限数据一定在组件渲染前就位(在路由守卫里获取完用户信息再放行)。
- 不用
removeChild,改成el.style.display = 'none',然后在update钩子里重新检查。
坑 2:指令方式无法与 v-if / v-show 配合。 如果你在同一个元素上同时用了 v-permission 和 v-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>
总结
- 权限分层:路由级、菜单级、按钮级、接口级,各有各的实现方式,别混在一起。
- 路由方案选型:小项目用 meta 守卫,中项目用前端过滤,大项目让后端返回路由表。
- 菜单渲染:优先"路由即菜单",除非有特殊展示需求才分离。
- 按钮权限:指令、组件、函数三种方式可以并存,按场景选择。
- 前端权限只是体验优化,后端一定要有自己的鉴权,不要把安全寄托在前端。
权限这块东西不难,但坑很多,而且大多数坑只有在刷新页面、切换角色、token 过期这些"非正常路径"才会暴露出来。所以写完权限逻辑后,一定要多测这几个场景:
- 刷新页面后,菜单和路由是否正常
- 直接输入 URL 访问无权限页面,是否正确拦截
- 退出登录 → 换角色登录,菜单是否正确更新
- Token 过期后的操作,是否平滑跳转登录页
学习本就是一场持久战,不需要急着一口吃成胖子。哪怕今天你只记住了一点点,这都是实打实的进步。
后续我还会继续用这种大白话、讲实战方式,带大家扫盲更多前端基础。
关注我,不迷路,咱们把那些曾经模糊的知识点,一个个彻底搞清楚。
如果你觉得这篇内容对你有帮助,不妨点赞+收藏,下次写代码卡壳时,拿出来翻一翻,比搜引擎更靠谱。
我是 Eugene,你的电子学友,我们下一篇干货见~