告别满屏 v-if:用一个自定义指令搞定 Vue 前端权限控制

在企业级应用开发中,权限控制是一个绑不开的话题。前端权限控制虽然不能替代后端校验,但能极大提升用户体验------让用户只看到自己能操作的内容,避免无效点击和困惑。

本文将分享一个 Vue 2 自定义指令的设计思路,实现了声明式的权限控制方案。

设计目标

在动手写代码之前,我先梳理了几个核心诉求:

  1. 使用简单 :一行代码搞定权限控制,不需要写一堆 v-if
  2. 性能友好:同一权限不重复请求,利用缓存
  3. 灵活可控:支持隐藏、禁用、提示等多种交互方式
  4. 支持多场景:既能用在模板里,也能在 JS 逻辑中调用

核心实现

1. 权限缓存设计

权限校验通常需要请求后端接口,如果每个按钮都单独请求一次,那页面性能会非常糟糕。这里采用了 Promise 缓存 的方式:

javascript 复制代码
Vue.prototype.$getPerm = (options) => {
    const argId = options.type + '-' + (options.type === 'space' ? options.spaceId : options.wikiId)
    
    // 如果缓存中没有,创建新的 Promise
    if (!spaceInfoStore.permissionMap[argId]) {
        spaceInfoStore.permissionMap[argId] = new Promise((resolve, reject) => {
            // 请求后端获取权限...
            Auth.getUserPermBySpaceId(options.spaceId).then((res) => {
                const permissions = res.reduce((acc, category) => {
                    category.permissions.forEach(permission => {
                        acc.push(`${category.value}.${permission.authorityKey}`)
                    })
                    return acc
                }, [])
                resolve({ permissions })
            })
        })
    }
    return spaceInfoStore.permissionMap[argId]
}

这个设计的巧妙之处在于:缓存的是 Promise 本身,而不是结果

这样做的好处是,即使多个组件同时调用 $getPerm,也只会发出一次请求。后续的调用会直接拿到同一个 Promise,等待第一次请求的结果。

2. 双重使用方式

为了覆盖不同的使用场景,我设计了两种调用方式:

方式一:指令式(模板中使用)

html 复制代码
<button v-perm:[permOptions]="'SPACE.EDIT'">编辑</button>

方式二:编程式(JS 中使用)

javascript 复制代码
this.$hasPerm({ spaceId: 123 }, 'SPACE.EDIT').then(() => {
    // 有权限,执行操作
}).catch(() => {
    // 无权限
})

指令式适合静态权限控制,编程式适合需要在逻辑中判断的场景。两者底层共用同一套缓存机制。

3. DOM 处理策略

无权限时如何处理 DOM?这里提供了三种策略:

javascript 复制代码
function domHandler (el, binding) {
    let placeholderDom = null
    
    if (binding?.arg?.showTips || binding?.arg?.disabled) {
        // 策略1&2:保留元素,但禁用或添加点击提示
        placeholderDom = el.cloneNode(true)
        
        if (binding?.arg?.showTips) {
            placeholderDom.onclick = function () {
                Vue.prototype.$bkMessage({
                    message: binding?.arg?.tipsText || '没有权限',
                    theme: 'warning'
                })
            }
        }
        
        if (binding?.arg?.disabled) {
            placeholderDom.classList.add('disabled')
        }
    } else {
        // 策略3:完全隐藏,用注释节点占位
        placeholderDom = document.createComment('permission-placeholder')
    }
    
    el.placeholderDom = placeholderDom
    el.parentNode.replaceChild(placeholderDom, el)
}

为什么用注释节点 而不是直接 display: none

因为注释节点不会影响布局,也不会被 CSS 选择器选中。更重要的是,我们需要保留一个"锚点",方便权限变化时把原始元素恢复回去。

4. 响应式更新

权限可能会动态变化(比如用户被授权后刷新),所以指令需要同时监听 insertedupdate 钩子:

javascript 复制代码
Vue.directive('perm', {
    inserted (el, binding) {
        handlerPerm(el, binding)
    },
    update (el, binding) {
        handlerPerm(el, binding)
    }
})

恢复元素的逻辑也很简单:

javascript 复制代码
function restoreElement (el) {
    if (el.placeholderDom && el.placeholderDom.parentNode) {
        el.placeholderDom.parentNode.replaceChild(el, el.placeholderDom)
    }
    el.placeholderDom = null
    return true
}

5. 支持布尔值快捷方式

有时候权限结果已经在外部计算好了,不需要再走一遍接口校验。这种场景下,支持直接传布尔值会更方便:

html 复制代码
<button v-perm:[options]="hasPermission">操作</button>
javascript 复制代码
if (typeof binding.value === 'boolean') {
    if (binding.value === false) {
        domHandler(el, binding)
    } else {
        restoreElement(el)
    }
    return
}

使用示例

基础用法

html 复制代码
<template>
    <div>
        <!-- 无权限时隐藏 -->
        <button v-perm:[permConfig]="'SPACE.DELETE'">删除</button>
        
        <!-- 无权限时禁用并提示 -->
        <button v-perm:[permConfigWithTips]="'SPACE.EDIT'">编辑</button>
    </div>
</template>

<script>
export default {
    computed: {
        permConfig() {
            return {
                spaceId: this.currentSpaceId,
                type: 'space'
            }
        },
        permConfigWithTips() {
            return {
                spaceId: this.currentSpaceId,
                type: 'space',
                disabled: true,
                showTips: true,
                tipsText: '您没有编辑权限,请联系管理员'
            }
        }
    }
}
</script>

编程式调用

javascript 复制代码
// 在执行敏感操作前校验
async handleDelete() {
    try {
        await this.$hasPerm({ spaceId: this.spaceId }, 'SPACE.DELETE')
        // 有权限,继续执行删除逻辑
        await this.doDelete()
    } catch {
        // 无权限,$hasPerm 内部已经弹出提示
    }
}

清除缓存重新加载

当权限发生变化时(比如管理员授权后),可以清除缓存重新加载:

javascript 复制代码
this.$getPerm({ 
    spaceId: this.spaceId, 
    clearCache: true 
})

设计总结

特性 实现方式
性能优化 Promise 缓存,同一权限只请求一次
使用方式 指令式 + 编程式双重支持
DOM 处理 隐藏 / 禁用 / 提示三种策略
响应式 inserted + update 钩子联动
灵活性 支持布尔值、清除缓存、自定义提示

可以优化的点

  1. Vue 3 适配 :Vue 3 的指令钩子函数名称有变化(mountedupdated),迁移时需要调整
  2. TypeScript 支持 :可以为 PermissionOptions 添加完整的类型定义
  3. 批量权限查询:如果页面上有大量权限点,可以考虑合并成一次批量查询
  4. 权限预加载:在路由守卫中预加载权限数据,减少页面白屏时间

以上就是这个权限指令的完整设计思路。核心思想是:用缓存换性能,用指令换简洁。希望对你有所启发,欢迎交流讨论 🙌

完整代码

最后贴一下完整代码,可以直接拿去用,根据自己项目的接口改一下就行:

javascript 复制代码
// permission.js
import { useSpaceInfoStore } from '@/store/modules/spaceInfo'
import Auth from '@/api/modules/auth'

let spaceInfoStore = null
setTimeout(() => {
    spaceInfoStore = useSpaceInfoStore()
}, 40)

/**
 * @typedef {Object} PermissionOptions
 * @property {string|number} [wikiId] - 文档id
 * @property {string|number} [spaceId] - 空间id
 * @property {string} [type] - 权限类型:空间/文档   space/wiki
 * @property {boolean} [disabled] - 是否禁用元素
 * @property {boolean} [showTips] - 是否显示提示信息
 * @property {string} [tipsText] - 提示文本内容
 * @property {boolean} [clearCache] - 是否清除缓存
 */

function install (Vue) {
    /**
     * 获取权限信息
     * @param {PermissionOptions} options - 权限选项
     * @returns {Promise<any>}
     */
    Vue.prototype.$getPerm = (options) => {
        if (!options.spaceId) return
        // 如果没有传type,则根据是否有文档id判断
        options.type = options.type || (options.wikiId ? 'wiki' : 'space')
        const argId = options.type + '-' + (options.type === 'space' ? options.spaceId : options.wikiId)
        // 清除缓存权限,可重新加载
        if (options.clearCache) {
            spaceInfoStore.permissionMap[argId] = false
        }
        if (!spaceInfoStore.permissionMap[argId]) {
            spaceInfoStore.permissionMap[argId] = new Promise((resolve, reject) => {
                if (options.type === 'space') {
                    Auth.getUserPermBySpaceId(options.spaceId).then((res) => {
                        // 组合权限生成唯一key
                        const permissions = res.reduce((acc, category) => {
                            category.permissions.forEach(permission => {
                                acc.push(`${category.value}.${permission.authorityKey}`)
                            })
                            return acc
                        }, [])
                        resolve({ permissions })
                    })
                } else {
                    Auth.getWikiPermissionDetail(options.spaceId, options.wikiId).then((res) => {
                        // 组合权限生成唯一key
                        const permissions = res.map(item => item.authorityKey)
                        resolve({ permissions })
                    })
                }
            })
        }
        return spaceInfoStore.permissionMap[argId]
    }

    /**
     * 检查是否有权限
     * @param {PermissionOptions} options - 权限选项
     * @param {string} perm - 权限码
     * @returns {Promise<boolean>}
     */
    Vue.prototype.$hasPerm = (options, perm) => {
        if (!Object.prototype.hasOwnProperty.call(options, 'showTips')) {
            options.showTips = true
        }
        return new Promise((resolve, reject) => {
            if (!options.spaceId) {
                resolve(true)
                return
            }
            const promise = Vue.prototype.$getPerm(options)
            promise.then((res) => {
                if (res.isAdmin) {
                    resolve(true)
                    return
                }
                if (res.permissions.includes(perm)) {
                    resolve(true)
                    return
                }
                if (options.showTips) {
                    Vue.prototype.$bkMessage({
                        message: options.tipsText || '没有权限',
                        theme: 'warning'
                    })
                }
                reject(new Error(''))
            })
        })
    }

    /**
     * DOM 处理函数 - 处理无权限时的元素显示
     * @param {HTMLElement} el - DOM 元素
     * @param {Object} binding - 指令绑定对象
     */
    function domHandler (el, binding) {
        let placeholderDom = null
        if (binding?.arg?.showTips || binding?.arg?.disabled) {
            placeholderDom = el.cloneNode(true)
            if (binding?.arg?.showTips) {
                placeholderDom.onclick = function () {
                    Vue.prototype.$bkMessage({
                        message: binding?.arg?.tipsText || '没有权限',
                        theme: 'warning'
                    })
                }
            }
            if (binding?.arg?.disabled) {
                placeholderDom.classList.add('disabled')
            }
        } else {
            placeholderDom = document.createComment('permission-placeholder')
        }
        if (el.parentNode) {
            el.placeholderDom = placeholderDom
            el.parentNode.replaceChild(placeholderDom, el)
        }
    }

    /**
     * 将元素恢复到原始位置
     * @param {HTMLElement} el - DOM 元素
     * @returns {boolean}
     */
    function restoreElement (el) {
        el.placeholderDom && el.placeholderDom.parentNode.replaceChild(el, el.placeholderDom)
        el.placeholderDom = null
        return true
    }

    /**
     * 权限处理函数
     * @param {HTMLElement} el - DOM 元素
     * @param {Object} binding - 指令绑定对象
     */
    function handlerPerm (el, binding) {
        // 通过直接传递boolean值,也可以进行权限校验
        if (typeof binding.value === 'boolean') {
            if (binding.value === false) {
                domHandler(el, binding)
            } else {
                restoreElement(el)
            }
            return
        }
        // 判断权限入参是否完善
        if (!binding?.arg?.spaceId || !binding?.value) return restoreElement(el)
        const promise = Vue.prototype.$getPerm({ ...binding.arg })
        promise.then((res) => {
            if (res.isAdmin) return restoreElement(el)
            if (res.permissions.includes(binding.value)) return restoreElement(el)
            domHandler(el, binding)
        })
    }

    Vue.directive('perm', {
        inserted (el, binding) {
            handlerPerm(el, binding)
        },
        update (el, binding) {
            handlerPerm(el, binding)
        }
    })
}

export default { install }

main.js 里注册一下就能用了:

javascript 复制代码
import permission from '@/directives/permission'
Vue.use(permission)
相关推荐
X_Eartha_8152 小时前
前端学习—HTML基础语法(1)
前端·学习·html
如果你好2 小时前
一文搞懂事件冒泡与阻止方法:event.stopPropagation() 实战指南
前端·javascript
用户8168694747252 小时前
深入 useMemo 与 useCallback 的底层实现
前端·react.js
AAA简单玩转程序设计2 小时前
救命!Java 进阶居然还在考这些“小儿科”?
java·前端
www_stdio2 小时前
我的猫终于打上冰球了——一个 Vue3 + Coze AI 项目的完整落地手记
javascript·vue.js·coze
MediaTea2 小时前
思考与练习(第十章 文件与数据格式化)
java·linux·服务器·前端·javascript
JarvanMo2 小时前
别用英语和你的大语言模型说话
前端
江公望2 小时前
Vue3的 nextTick API 5分钟讲清楚
前端·javascript·vue.js
weixin_446260852 小时前
深入了解 MDN Web Docs:打造更好的互联网
前端