记录四年前的一次前端项目权限方案改造

后台系统做权限,几乎都会碰到同一种场景:权限接口是查询式的 。前端第一反应往往是「进页前把本页权限一次性查完」。这很自然,但也极易演变成一种极难维护的结构------权限码声明在路由,实际使用在按钮和子组件里,两头永远对不齐。

无论你自己写代码、做架构设计,还是让 AI 给你生成方案,这种「先外层汇总、再下层使用」的结构会反复出现。

希望读完这篇文章,你能了解到:

  1. 直观的实现可能使用上并不直观

  2. 什么是代码中的"坏味道",为什么,以及如何避免

  3. 请求聚合(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 的 computedRef,当某个码从 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>

进页时,流程依然非常高效:

  1. 守卫检查 resource:list,触发 flush() 发起第 1 次网络请求;

  2. 页面挂载,resource:createresource:export 自动推入队列,在下一个 tick/微任务中被 debounce 合并为第 2 次网络请求;

  3. 后续任何页面再次使用过这几个权限码,全量直接命中 data Map 缓存,不再触发任何网络请求!

六、 新旧方案全方位对比

维度 旧方案(路由预拉取) 新方案(聚合查询)
查询时机 进页前,按路由清单整包拉取 哪里用,哪里登记,底层聚合后查询
声明位置 路由写一份 + 组件写一份(两头维护) 只写在组件/使用处
漏写后果 按钮直接显示为"无权限"(假 Bug) 自动补发查询,结果依然准确
组件复用 须翻阅源码找齐权限码,补到新路由 随插随用,组件自身携带权限逻辑
请求次数 每页固定 1 次 每页通常 2 次(进页门槛 1 次 + 页内组件聚合 1 次)
缓存机制 按页面 Path 隔离,跨页无法复用 按权限码全局缓存,跨页直接复用

新方案确实比旧方案多了一次 HTTP 请求------即页面挂载后,组件各自冒出来时合并发起的第 2 次请求。

但这多出来的一次请求,对用户来说毫无感知,对后端服务器压力几乎为零。而它换来的,是权限逻辑与页面结构的徹底解耦:路由不再充当"权限清单",组件不再有"隐式依赖",新增/修改功能只需要修改对应的 UI 代码即可。

旧方案表面上省下了 1 次请求,实则是将成本转移给了后期的维护者,这笔账怎么算都不划算。

七、 总结:从这件小事看架构设计

四年后回头看,当年写出旧方案的人并不是不会写代码,而是在面对「查询式接口」时,顺着习惯走了最直观的第一步,然后就停住了。

「先在最外层把数据准备好,再传给下层组件用」在很多业务场景下是成立的。但权限场景不成立

权限码的真实来源是使用它的组件,路由上那份清单只是一份极易过期的「副本」。副本一旦失效,界面就会用"假无权限"对用户撒谎。

在做架构设计或参考 AI 给出的方案时,一定要警惕这种结构:

  • 下层组件需要一份数据;
  • 为了避免多次请求,要求在最外层预先汇总一份清单;
  • 随着需求迭代,外层清单和下层实际使用逐渐分叉、腐化。

一旦发现「先声明完整列表,再在别处消费」的代码模式,不妨停下来想一想:能不能改成"使用处自己登记",再由运行时去自动合并请求?

不仅权限可以这么做,前端很多涉及"先拉全量再分发"的状态管理(如字典映射、用户简图批量查询),都可以用这套「局部声明 + 运行时聚合 + 强制冲刷」的思路重构一遍。

相关推荐
Liora_Yvonne1 小时前
为什么每次发版,总有用户看到白屏?
前端
磐链科技1 小时前
交易所开发新范式:从社交钱包隐私保护中汲取的五大架构启示
架构·区块链
风月说与山鬼1 小时前
四、浏览器存储
前端·vue.js
Logintern091 小时前
Celery 的底层架构正是进程池、事件循环、epoll 和协程全部组合在了一起
python·架构·消息队列·进程·celery·事件循环
liuxiaocheng1 小时前
上下文工程:LangChain 怎么组织「喂给模型的东西」
前端·后端
breeze jiang1 小时前
CSS 三栏布局怎么写:Flex、Grid 完整方案与 BFC 区别
前端·css
码艺-Alimjan1 小时前
JWT 的本质、误区与正确落地:一套不依赖任何框架的架构方法论-微信小程序代码案例-Api 安全性拉满
微信小程序·架构·notepad++
郭邯1 小时前
手写一个Cron表达式解析器:从需求分析到AI辅助实现
前端
阿黎梨梨1 小时前
Docker 容器化实战:从零搭建 Web 服务与反向代理
前端·后端·docker