权限指令与响应式角色订阅
概述
后台系统的权限控制通常有两层:路由层决定"能不能进这个页面",视图层决定"页面里这个按钮/菜单能不能看到或点"。路由层一般用导航守卫统一处理;视图层如果用 v-if="hasPermission('x')" 散落各处,会出现两个问题——一是表达式重复且拼写易错,二是权限变化时视图不会自动更新(因为 hasPermission 是普通函数调用,不具备响应式)。
把权限判断做成指令 v-permission,并把角色来源做成响应式订阅,才能让"管理员刚给你加了某权限,界面立刻多出对应按钮"这类体验成立。本文讲权限指令的两种粒度(隐藏 vs 禁用)、响应式角色订阅如何驱动视图更新,以及指令无法覆盖的复杂场景该交给谁。
学习目标
- 理解视图层权限的两种粒度:直接移除 DOM(隐藏)与保留但禁用(disabled)。
- 掌握响应式角色订阅:用全局 reactive 状态 + watchEffect 让权限变化自动刷新指令效果。
- 能实现
v-permission指令,根据绑定值与当前角色比较,决定隐藏或禁用元素。 - 区分指令与路由守卫的分工:页面级进不进交给守卫,元素级看不看到交给指令。
- 知道哪些场景不适合用指令(跨多元素的区块、动态数据行级权限),改用组件或插槽。
一、权限指令的两种粒度
最彻底的粒度是"无权限就移除 DOM":v-permission 比对失败时 el.parentNode.removeChild(el),元素直接从文档消失,连占位都不留。这适合"这个功能你根本不该知道存在"的场景(如内部运维入口)。
更温和的粒度是"保留但禁用":元素还在,只是加 disabled 属性并降透明度,鼠标悬停提示"无权限"。这适合"大家都看得到入口,但你点了没反应"的协作场景,避免界面因元素消失而显得残缺。指令应支持通过 modifier 切换,例如 v-permission.disabled 表示禁用而非移除。
二、响应式角色订阅
权限指令要"自动更新",前提是角色来源是响应式的。典型做法是在一个全局 store(Pinia 或简单的 reactive)里维护 roles 数组,登录后写入,退出或切换角色时更新。指令在 mounted 时用 watchEffect 订阅这个状态:
import { watchEffect, reactive } from 'vue'
export const authState = reactive<{ roles: string[] }>({ roles: [] })
const vPermission: Directive<HTMLElement, any> = {
mounted(el, binding) {
const stop = watchEffect(() => {
const required = Array.isArray(binding.value) ? binding.value : [binding.value]
const ok = required.some((r) => authState.roles.includes(r))
if (!ok) {
if (binding.modifiers.disabled) {
el.setAttribute('disabled', 'true')
el.classList.add('is-disabled')
} else if (el.parentNode) {
el.parentNode.removeChild(el)
}
}
})
el.__stopWatch = stop
},
unmounted(el) {
el.__stopWatch?.()
},
}watchEffect 会在 authState.roles 变化时重新执行回调,自动重算权限结果。注意它必须在 unmounted 里调用返回的 stop 停止,否则副作用会泄漏、并在已卸载元素上尝试操作。
三、指令实现要点
指令比对的是"绑定值要求的角色"与"当前用户拥有的角色"是否有交集。绑定值可以是单个角色字符串,也可以是角色数组(满足其一即可)。失败时按 modifier 决定移除还是禁用。一个易错点是:移除 DOM 后若权限又变回"有",元素已经不在文档里,指令无法自动把元素加回来——因此对于"权限会来回变化"的元素,禁用比移除更安全,移除更适合静态权限判断。
四、与路由守卫的分工
页面级访问控制必须由路由守卫统一做:在 beforeEach 里判断目标路由的 meta.roles,无权限直接重定向到 403 或无权限页。这样做是因为守卫发生在渲染之前,避免"先渲染再删"的闪烁,也能集中审计。指令只负责"已进入页面后,页面内部元素的细粒度显隐"。两者互补:守卫管"门",指令管"门里的家具"。不要试图用指令去拦截整页跳转,那既晚又容易漏。
五、指令覆盖不了的复杂场景
当权限粒度到"某一行数据的某列能否编辑"(行级、字段级),或一段 UI 由多个兄弟元素组成的区块整体受控时,指令的"单元素"模型就不够用了。这类场景更适合用组件封装或插槽:
<template>
<Permission :code="row.editCode">
<template #default>
<el-button @click="onEdit(row)">编辑</el-button>
</template>
<template #fallback>
<span class="muted">无编辑权限</span>
</template>
</Permission>
</template>Permission 组件内部读取响应式角色,有码则渲染 default 插槽,无码渲染 fallback。指令是轻量首选,但遇到"跨元素、随数据变化"的权限,及时升级到组件方案,别硬撑。
六、与服务端权限的同步
前端角色只是服务端下发的快照,真正的权威判断在服务端接口。指令隐藏按钮能挡住"看得见"的操作,但挡不住直接调接口,因此每个受权限保护的写接口必须在服务端二次校验。前端的 v-permission 是体验优化,不是安全边界——这个边界意识要在团队里讲清楚,避免有人以为"藏了按钮就安全了"。
七、指令与 v-if 的取舍对照
有人会问:既然 v-permission 最终也是在隐藏时用 removeChild、禁用时加属性,那和手写 v-if="can(x)" 有什么区别?区别在于"响应式"和"一致性"。手写 v-if 每次都要重新写判断表达式,且 can 若不是响应式引用就不会自动更新;指令把判断收口到一处,并强制走 watchEffect,权限变化必然触发刷新。
代价是指令的隐藏粒度是"整个元素",无法做到"同一按钮在不同状态下切换文字"(那需要组件内 v-if/v-else)。所以取舍很清晰:单纯显隐/禁用交给 v-permission;需要在一处根据权限切换不同 UI 结构的,用组件的插槽方案。两者互补,而不是互相替代。
八、角色数据的初始化时机
响应式订阅要发挥作用,前提是 authState.roles 在指令 mounted 之前就已经填好。常见踩坑是:页面先渲染、指令挂载时角色还是空数组,于是元素被错误移除/禁用,等登录接口返回、角色写入后才靠 watchEffect 补回——但移除型指令补不回来,导致按钮消失。
正确的初始化顺序是:在应用入口(main.ts 或根组件的 setup)里优先拉取用户信息、写入 authState,再 app.mount。如果角色必须异步获取,则指令应区分"未初始化"和"已初始化但无权限"两种状态:未初始化时不急着移除,等首次有效数据到达后再判定,避免闪删。这个边界处理,是权限指令能否上生产的关键。
九、小结
权限指令的核心不是"隐藏",而是"响应式地隐藏"。只要角色来源是响应式的、清理是配对的、初始化时机是对的,它就能在生产环境稳定工作。把它和路由守卫、服务端校验三者配合,才构成完整的权限闭环,而不是把安全寄托在前端藏按钮上。
常见问题
| 问题 | 原因与表现 | 处理方式 |
|---|---|---|
| 权限变了界面没更新 | 用普通函数判断,无响应式追踪 | 订阅全局 reactive 角色,用 watchEffect 驱动 |
| 移除后权限恢复元素不回来 | DOM 已删,指令无法重建 | 会来回变化的用 .disabled 禁用而非移除 |
| 权限判断散落拼写错 | 多处手写 hasPermission 表达式 | 收敛到 v-permission 指令,统一比对逻辑 |
| 整页闪烁后跳转无权限 | 用指令拦截页面跳转太晚 | 页面级交给路由守卫,指令只管页面内元素 |