后台系统做权限,几乎都会碰到同一种场景:权限接口是查询式的 。前端第一反应往往是「进页前把本页权限一次性查完」。这很自然,但也极易演变成一种极难维护的结构------权限码声明在路由,实际使用在按钮和子组件里,两头永远对不齐。
无论你自己写代码、做架构设计,还是让 AI 给你生成方案,这种「先外层汇总、再下层使用」的结构会反复出现。
希望读完这篇文章,你能了解到:
-
直观的实现可能使用上并不直观
-
什么是代码中的"坏味道",为什么,以及如何避免
-
请求聚合(Batch/Debounce)的实际使用场景,它给架构带来了哪些优化
一、 权限接口为什么是查询式的?
权限系统通常不会在登录时把全量权限一次性吐给前端。一个大型后台,菜单可能有几百个,挂在上面的权限码可能有几千个。全量返回既没必要,也不现实。
因此,后端的权限接口大多设计为查询式:前端传一批权限码,后端返回当前用户拥有的集合。
Plaintext
前端问:['resource:list', 'resource:create', 'resource:export']
后端回:['resource:list', 'resource:create'] // 说明没有 export 权限
前端拿到的不是「我的全量权限表」,而是「我刚才问过的这几个里,哪些是 true」。
面对这种接口,最顺理成章的做法就是:进入页面前,把这个页面用到的所有权限码凑成一个数组,一次性发请求。 旧方案就是这么做的。
二、 旧方案:进页前,把本页权限一次查完
在旧方案里,路由配置被拆成了两份:
-
enterPermission:进入该页面必须具备的权限(路由守卫拦截用) -
pagePermission:该页面下,所有按钮、操作栏、弹窗会用到的全量权限码清单
JavaScript
// router.js
{
path: '/resource/list',
meta: {
enterPermission: ['resource:list'],
pagePermission: [
'resource:list',
'resource:create',
'resource:detail',
'resource:update',
'resource:publish',
'resource:export'
]
}
}
在全局路由守卫中,如果发现该页面还没查过权限,就把 pagePermission 整包丢给接口,结果按页面 path 缓存到 Store 中:
JavaScript
// guard.js
router.beforeEach(async (to) => {
const { meta, path } = to
if (meta.enterPermission && !store.permission[path]) {
// 进页前整包查询
const owned = await authenticate(meta.pagePermission)
store.permission[path] = owned
}
})
到了页面内部,按钮或指令不再发起任何请求,只做一个静态判断:我声明的码,在不在 Store 里的「当前页结果集」中?
JavaScript
// 按钮内部校验
const cached = store.permission[route.path] || []
const hasPermission = permissionList.every((code) => cached.includes(code))
进页请求一次,页内全走缓存。在查询式接口下,这看起来是极其优雅的实现。
三、 问题不在请求次数,在「声明」与「使用」被割裂
这套方案最大的痛点在于:清单写在路由,而真正使用权限的却是页面深处的按钮、表格操作列、弹窗和嵌套子组件。
这两处必须依靠人工同步,只要是人工同步,就一定会出错。
1. 假无权限(Bug 极难排查)
漏写的表现不是报错,也不是多打一次请求,而是组件直接显示为无权限。
因为缓存里存储的是「本次问过、且用户拥有」的码。一旦某个新功能按钮的权限码忘记补进路由的 pagePermission 数组里,它就根本没被后端查过。组件在缓存里找不到它,逻辑上和"用户真的没有权限"长得一模一样。
2. 破坏组件复用(隐式依赖陷阱)
假设你封装了一个通用的 ActionHeader.vue(操作栏组件),里面写死了几个控制按钮显示的权限码。
表面上这个组件可以随插随用,但实际上你根本不敢直接复用它 。每次将其丢进一个新页面,你都必须把这个组件(甚至它的子组件)源码展开,把里面用到的所有权限码逐个找出来,手动誊写到新页面的路由 pagePermission 里。
核心矛盾:
权限明明是在组件里使用的,却硬要跑去路由里声明。这种「隐式依赖」直接拉高了代码的复用成本。
四、 改造目标:哪里用,哪里查,底层自动聚合
目标非常明确:权限码写在使用处。 按钮上写、指令上写、组件里写。写在哪,就在哪查,路由不再维护任何本页权限清单。
但我们不能让每个组件一挂载就发一次 HTTP 请求。一屏如果挂载 10 个带权限的按钮,按「一个码发一次请求」页面会瞬间卡死。
所以底层必须具备自动聚合能力(Request Batching) :使用处各自登记自己的码,底层利用防抖(Debounce),把同一波挂载的权限码收成一批,合并成一次请求发出去。
Plaintext
[组件 A 登记 code1] ──┐
[组件 B 登记 code2] ──┼─► 【等待队列】 ─(lodash/debounce)─► [合并请求: code1, code2, code3]
[指令 C 登记 code3] ──┘
五、 完整 TypeScript 代码实现
核心逻辑分为三部分:三态响应式缓存 -> Debounce 聚合 -> Flush 同步冲刷。
1. 全局按权限码缓存与 Debounce 队列
结果按权限码(string)存储。每个码在响应式 Ref 中有三种明确的状态:
undefined:查询中 / 未查询true:有权限false:无权限
利用 Vue 3 的 computed 和 Ref,当某个码从 undefined 变为 true/false 时,所有订阅了该码的按钮组件会自动完成重新渲染。
TypeScript
// auth/use-auth.ts
import { ref, computed, watch, provide, InjectionKey, Ref } from 'vue'
import { debounce } from 'lodash-es'
import { authenticate } from '@/service/auth'
// 1. 全局缓存 Map:code -> Ref<boolean | undefined>
const data = new Map<string, Ref<boolean | undefined>>()
// 2. 待查询队列与 Debounce 调度器
const waitForQueryPermissions: string[] = []
const queryPermissions = debounce(() => {
const permissions = waitForQueryPermissions
.splice(0, waitForQueryPermissions.length)
.filter(p => p && typeof p === 'string')
if (!permissions.length) return
authenticate(permissions).then(({ data: checkedPermissions }) => {
if (!Array.isArray(checkedPermissions)) return
// 查回来的码标记为 true,其余标记为 false
permissions.forEach(permission => {
data.get(permission)!.value = checkedPermissions.includes(permission)
})
})
})
2. 单码与多码的响应式获取
TypeScript
// 获取单个权限码状态
const getOnePermission = (permission: string): Ref<boolean | undefined> => {
// 空权限判定为默认有权
if (!permission) return ref(true)
if (!data.has(permission)) {
const result = ref<boolean | undefined>()
data.set(permission, result)
// 压入队列并触发 debounce 调度
waitForQueryPermissions.push(permission)
queryPermissions()
}
return data.get(permission)!
}
/**
* 核心 API:获取单个或多个权限状态
* @param permission 权限码或权限码数组
* @param flush 是否需要立即发起 HTTP 请求(冲刷 debounce 队列)
*/
export const getPermissions = (
permission: string | string[],
flush: boolean = false
) => {
const result = typeof permission === 'string'
? getOnePermission(permission)
: computed(() => {
const list = permission.map(one => getOnePermission(one).value)
// 只要有一个还在查(undefined),整体返回 undefined
if (list.some(auth => auth === undefined)) return undefined
// 全为 true 才算有权限
return list.every(auth => auth === true)
})
if (flush) {
// 强制访问 .value 触发 computed 计算,确保其进入 pending 队列
!!result.value
// 立即执行发请求,不等 debounce 延迟
queryPermissions.flush()
}
return result
}
3. 路由守卫需要的 Promise 异步转化
由于 router.beforeEach 守卫必须同步或通过 Promise 决定是否放行,我们通过 getAsyncPermissions 函数将响应式状态转换为 Promise,并通过 flush = true 强制唤醒 HTTP 发送:
TypeScript
/**
* 专供路由守卫/异步逻辑调用的方法
*/
export const getAsyncPermissions = (
permission: string | string[],
flush: boolean = false
) => {
const result = getPermissions(permission, flush)
// 如果缓存里已有确切结果(true/false),直接 Resolve
if (typeof result.value === 'boolean') {
return Promise.resolve(result.value)
}
// 否则监听响应式 Ref 变化,直到拿到 true / false
return new Promise<boolean>((resolve) => {
const stopWatch = watch(() => result.value, () => {
if (typeof result.value === 'boolean') {
resolve(result.value)
stopWatch() // 拿到结果立即解绑
}
})
})
}
4. 路由守卫与组件调用的彻底简化
全局路由守卫(router.js):
路由守卫只负责拦截「进入页面的门槛权限」,通过传递 true 参数强制 flush() 冲刷防抖队列,确保路由跳转的实时性:
TypeScript
// router/index.ts
import { getAsyncPermissions } from '@/auth/use-auth'
router.beforeEach((to) => {
// 1. 页面不需要进页权限,直接放行
if (!Array.isArray(to.meta.permissions) || !to.meta.permissions.length) {
return true
}
// 2. 拦截进页权限(传 true 触发 flush 立即发送请求)
return getAsyncPermissions(to.meta.permissions, true).then((hasAuth) => {
if (hasAuth) return true
return { name: 'no-permission' } // 403 页面
})
})
业务组件侧:
组件只需要声明它依赖哪些权限码。不管是按钮、列表操作列还是微前端子应用,直接使用即可:
html
<!-- 路由定义仅保留进页权限 -->
<!-- path: '/resource/list', meta: { permissions: ['resource:list'] } -->
<template>
<div class="actions">
<!-- 组件随插随用,自带权限码 -->
<BaseButton :permissions="['resource:create']">新增</BaseButton>
<BaseButton :permissions="['resource:export']">导出</BaseButton>
</div>
</template>
进页时,流程依然非常高效:
-
守卫检查
resource:list,触发flush()发起第 1 次网络请求; -
页面挂载,
resource:create和resource:export自动推入队列,在下一个 tick/微任务中被debounce合并为第 2 次网络请求; -
后续任何页面再次使用过这几个权限码,全量直接命中
dataMap 缓存,不再触发任何网络请求!
六、 新旧方案全方位对比
| 维度 | 旧方案(路由预拉取) | 新方案(聚合查询) |
|---|---|---|
| 查询时机 | 进页前,按路由清单整包拉取 | 哪里用,哪里登记,底层聚合后查询 |
| 声明位置 | 路由写一份 + 组件写一份(两头维护) | 只写在组件/使用处 |
| 漏写后果 | 按钮直接显示为"无权限"(假 Bug) | 自动补发查询,结果依然准确 |
| 组件复用 | 须翻阅源码找齐权限码,补到新路由 | 随插随用,组件自身携带权限逻辑 |
| 请求次数 | 每页固定 1 次 | 每页通常 2 次(进页门槛 1 次 + 页内组件聚合 1 次) |
| 缓存机制 | 按页面 Path 隔离,跨页无法复用 | 按权限码全局缓存,跨页直接复用 |
新方案确实比旧方案多了一次 HTTP 请求------即页面挂载后,组件各自冒出来时合并发起的第 2 次请求。
但这多出来的一次请求,对用户来说毫无感知,对后端服务器压力几乎为零。而它换来的,是权限逻辑与页面结构的徹底解耦:路由不再充当"权限清单",组件不再有"隐式依赖",新增/修改功能只需要修改对应的 UI 代码即可。
旧方案表面上省下了 1 次请求,实则是将成本转移给了后期的维护者,这笔账怎么算都不划算。
七、 总结:从这件小事看架构设计
四年后回头看,当年写出旧方案的人并不是不会写代码,而是在面对「查询式接口」时,顺着习惯走了最直观的第一步,然后就停住了。
「先在最外层把数据准备好,再传给下层组件用」在很多业务场景下是成立的。但权限场景不成立。
权限码的真实来源是使用它的组件,路由上那份清单只是一份极易过期的「副本」。副本一旦失效,界面就会用"假无权限"对用户撒谎。
在做架构设计或参考 AI 给出的方案时,一定要警惕这种结构:
- 下层组件需要一份数据;
- 为了避免多次请求,要求在最外层预先汇总一份清单;
- 随着需求迭代,外层清单和下层实际使用逐渐分叉、腐化。
一旦发现「先声明完整列表,再在别处消费」的代码模式,不妨停下来想一想:能不能改成"使用处自己登记",再由运行时去自动合并请求?
不仅权限可以这么做,前端很多涉及"先拉全量再分发"的状态管理(如字典映射、用户简图批量查询),都可以用这套「局部声明 + 运行时聚合 + 强制冲刷」的思路重构一遍。