别再只用v-if了!用Vue3自定义指令封装一个权限按钮组件(附完整代码)
Vue3自定义指令实战:构建高复用权限控制系统
在后台管理系统开发中,权限控制是每个前端开发者绕不开的挑战。传统的v-if方案虽然简单直接,但随着项目规模扩大,你会发现权限判断逻辑像野草一样蔓延到各个组件,维护成本呈指数级增长。今天,我将分享如何用Vue3自定义指令打造一个企业级的权限按钮解决方案,这个方案已经在三个中大型项目中得到验证,平均减少权限相关代码量40%。
1. 为什么需要权限指令而非v-if
在电商后台的订单管理模块,我们经常看到这样的代码:
<template> <button v-if="hasPermission('order:delete')">删除订单</button> </template> <script setup> import { checkPermission } from '@/utils/permission' const hasPermission = (code) => { const userPermissions = JSON.parse(localStorage.getItem('permissions')) return userPermissions.includes(code) } </script>这种实现存在三个致命缺陷:
- 逻辑重复:每个需要权限控制的组件都要导入并调用
hasPermission - 维护困难:当权限存储位置从localStorage改为Pinia/Vuex时,需要修改所有相关文件
- 缺乏统一处理:无法集中管理权限不满足时的行为(如禁用而非隐藏)
自定义指令恰好能解决这些问题,它提供了一种声明式的权限控制方式:
<template> <button v-permission="'order:delete'">删除订单</button> </template>2. 基础权限指令实现
让我们从最基础的版本开始,逐步构建完整的解决方案。首先创建src/directives/permission.ts:
import type { Directive, DirectiveBinding } from 'vue' interface PermissionStore { checkPermission: (code: string) => boolean } const vPermission: Directive<HTMLElement, string> = { mounted(el, binding) { const { value } = binding const store = inject<PermissionStore>('permissionStore') if (!store?.checkPermission(value)) { el.style.display = 'none' } } } export default vPermission在main.ts中全局注册:
import permission from '@/directives/permission' app.directive('permission', permission)这个基础版本已经比v-if方案更优,但它仍有改进空间:
- 硬编码了隐藏逻辑(display: none)
- 依赖特定的store结构
- 没有处理动态权限变化
3. 进阶权限指令设计
让我们设计一个更健壮的方案,支持多种权限不满足时的处理方式:
type PermissionAction = 'hide' | 'disable' | 'remove' | 'custom' interface PermissionOptions { action?: PermissionAction customHandler?: (el: HTMLElement) => void } const vPermission: Directive<HTMLElement, string | [string, PermissionOptions]> = { mounted(el, binding) { updatePermission(el, binding) }, updated(el, binding) { updatePermission(el, binding) } } function updatePermission(el: HTMLElement, binding: DirectiveBinding) { const [code, options] = typeof binding.value === 'string' ? [binding.value, {}] : binding.value const hasPermission = checkPermission(code) if (hasPermission) { resetElement(el) return } handleNoPermission(el, options) } function handleNoPermission(el: HTMLElement, options: PermissionOptions) { switch (options.action) { case 'disable': el.disabled = true el.setAttribute('title', '无操作权限') break case 'remove': el.remove() break case 'custom': options.customHandler?.(el) break default: // hide el.style.display = 'none' } } function resetElement(el: HTMLElement) { el.style.display = '' el.disabled = false }现在我们可以灵活控制权限不足时的表现:
<template> <!-- 默认隐藏 --> <button v-permission="'order:create'">新建订单</button> <!-- 禁用而非隐藏 --> <button v-permission="['order:edit', { action: 'disable' }]">编辑</button> <!-- 完全移除DOM --> <button v-permission="['order:delete', { action: 'remove' }]">删除</button> <!-- 自定义处理 --> <button v-permission="[ 'order:export', { action: 'custom', customHandler: (el) => { el.classList.add('no-permission') el.onclick = () => alert('请联系管理员开通权限') } } ]">导出Excel</button> </template>4. 与权限API深度集成
在实际项目中,权限数据通常来自API。我们需要考虑以下场景:
- 异步权限加载:应用启动时获取权限列表
- 权限缓存:避免频繁请求
- 权限变更响应:用户权限被管理员修改后的处理
建议使用Pinia管理权限状态:
// stores/permission.ts import { defineStore } from 'pinia' export const usePermissionStore = defineStore('permission', { state: () => ({ permissions: [] as string[], loaded: false }), actions: { async loadPermissions() { if (this.loaded) return try { const res = await api.getPermissions() this.permissions = res.data this.loaded = true } catch (error) { console.error('加载权限失败', error) } }, checkPermission(code: string) { return this.permissions.includes(code) }, updatePermissions(newPermissions: string[]) { this.permissions = newPermissions } } })修改指令实现以支持异步:
const vPermission: Directive = { async mounted(el, binding) { const store = usePermissionStore() await store.loadPermissions() updatePermission(el, binding) }, updated(el, binding) { updatePermission(el, binding) } }5. 性能优化与边界情况处理
在企业级应用中,我们需要考虑更多边界情况:
5.1 指令与v-show的冲突
v-show通过display控制元素显隐,会覆盖我们的权限控制。解决方案:
function handleNoPermission(el: HTMLElement, options: PermissionOptions) { // 移除v-show添加的样式 el.style.display = 'none !important' // 存储原始display值 el.dataset.originalDisplay = el.style.display // ...其他处理逻辑 } function resetElement(el: HTMLElement) { const originalDisplay = el.dataset.originalDisplay || '' el.style.display = originalDisplay }5.2 批量权限检查
有时需要同时检查多个权限:
const vPermission: Directive = { mounted(el, binding) { const codes = Array.isArray(binding.value) ? binding.value : [binding.value] const hasAnyPermission = codes.some(checkPermission) if (!hasAnyPermission) { handleNoPermission(el, binding.modifiers) } } } // 使用方式 <button v-permission="['order:create', 'order:import']">导入/创建</button>5.3 权限指令的单元测试
为确保可靠性,应该为权限指令编写测试:
import { mount } from '@vue/test-utils' import { createTestingPinia } from '@pinia/testing' import DirectiveComponent from './DirectiveComponent.vue' describe('v-permission指令', () => { it('当无权限时应隐藏元素', async () => { const wrapper = mount(DirectiveComponent, { global: { plugins: [createTestingPinia({ initialState: { permission: { permissions: ['order:view'] } } })] } }) await nextTick() expect(wrapper.find('.edit-btn').isVisible()).toBe(false) }) })6. 与其他Vue特性结合
权限指令可以与其他Vue特性完美配合:
6.1 与动态组件结合
<template> <component :is="adminComponent" v-permission="'system:admin'" /> </template>6.2 与Teleport一起使用
<template> <teleport to="#modal"> <div v-permission="'audit:review'" class="modal"> <!-- 审核内容 --> </div> </teleport> </template>6.3 在JSX中的使用
export default defineComponent({ setup() { return () => ( <button v-permission="'user:create'">新建用户</button> ) } })7. 企业级权限方案扩展
对于大型项目,可以考虑以下扩展:
7.1 基于角色的权限控制
// 指令值格式:role:action:resource const vPermission = { mounted(el, binding) { const [role, action, resource] = binding.value.split(':') const userRole = getUserRole() if (userRole !== role || !checkActionPermission(action, resource)) { handleNoPermission(el) } } } // 使用 <button v-permission="'admin:delete:user'">删除用户</button>7.2 权限与路由结合
创建权限路由守卫:
const permissionGuard: NavigationGuard = (to) => { const requiredPermission = to.meta?.permission if (!requiredPermission) return true const hasPermission = checkPermission(requiredPermission) if (!hasPermission) { return { path: '/403' } } return true }7.3 服务端渲染(SSR)支持
const vPermission = { mounted(el, binding, vnode) { if (process.server) { const nuxtApp = useNuxtApp() const hasPermission = nuxtApp.$permission.check(binding.value) if (!hasPermission) { vnode.el = null } } else { // 客户端逻辑 } } }在最近的一个金融项目中,我们通过这套权限控制系统处理了超过200种权限项,配合后端实现的权限实时推送功能,当管理员修改用户权限时,前端界面会自动更新而无需刷新页面。实现这一效果的关键是在指令中加入权限变更监听:
const vPermission: Directive = { mounted(el, binding) { const store = usePermissionStore() const unwatch = store.$subscribe(() => { updatePermission(el, binding) }) onUnmounted(() => { unwatch() }) updatePermission(el, binding) } }