龙王庙 發表於 2026-3-12 16:56:00

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

<h1 data-id="heading-0">🧑‍💻 写在开头</h1>
<p>点赞 + 收藏 === 学会🤣🤣🤣</p>
<div>
<div>
<p>在企业级应用开发中,权限控制是一个绑不开的话题。前端权限控制虽然不能替代后端校验,但能极大提升用户体验——让用户只看到自己能操作的内容,避免无效点击和困惑。</p>
<p>本文将分享一个 Vue 2 自定义指令的设计思路,实现了<strong>声明式</strong>的权限控制方案。</p>
<h2 data-id="heading-0">设计目标</h2>
<p>在动手写代码之前,我先梳理了几个核心诉求:</p>
<ol>
<li><strong>使用简单</strong>:一行代码搞定权限控制,不需要写一堆 <code>v-if</code></li>
<li><strong>性能友好</strong>:同一权限不重复请求,利用缓存</li>
<li><strong>灵活可控</strong>:支持隐藏、禁用、提示等多种交互方式</li>
<li><strong>支持多场景</strong>:既能用在模板里,也能在 JS 逻辑中调用</li>
</ol>
<h2 data-id="heading-1">核心实现</h2>
<h3 data-id="heading-2">1. 权限缓存设计</h3>
<p>权限校验通常需要请求后端接口,如果每个按钮都单独请求一次,那页面性能会非常糟糕。这里采用了 <strong>Promise 缓存</strong> 的方式:</p>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">Vue.prototype.$getPerm = (options) =&gt; {
    const argId = options.type + '-' + (options.type === 'space' ? options.spaceId : options.wikiId)
   
    // 如果缓存中没有,创建新的 Promise
    if (!spaceInfoStore.permissionMap) {
      spaceInfoStore.permissionMap = new Promise((resolve, reject) =&gt; {
            // 请求后端获取权限...
            Auth.getUserPermBySpaceId(options.spaceId).then((res) =&gt; {
                const permissions = res.reduce((acc, category) =&gt; {
                  category.permissions.forEach(permission =&gt; {
                        acc.push(`${category.value}.${permission.authorityKey}`)
                  })
                  return acc
                }, [])
                resolve({ permissions })
            })
      })
    }
    return spaceInfoStore.permissionMap
}</pre>
</div>
<div>
<div>
<p>这个设计的巧妙之处在于:<strong>缓存的是 Promise 本身,而不是结果</strong>。</p>
<p>这样做的好处是,即使多个组件同时调用 <code>$getPerm</code>,也只会发出一次请求。后续的调用会直接拿到同一个 Promise,等待第一次请求的结果。</p>
<h3 data-id="heading-3">2. 双重使用方式</h3>
<p>为了覆盖不同的使用场景,我设计了两种调用方式:</p>
<p><strong>方式一:指令式(模板中使用)</strong></p>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">&lt;button v-perm:="'SPACE.EDIT'"&gt;编辑&lt;/button&gt;</pre>
</div>
<p><strong>方式二:编程式(JS 中使用)</strong></p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">this.$hasPerm({ spaceId: 123 }, 'SPACE.EDIT').then(() =&gt; {
    // 有权限,执行操作
}).catch(() =&gt; {
    // 无权限
})</pre>
</div>
<p>指令式适合静态权限控制,编程式适合需要在逻辑中判断的场景。两者底层共用同一套缓存机制。</p>
<h3 data-id="heading-4">3. DOM 处理策略</h3>
<p>无权限时如何处理 DOM?这里提供了三种策略:</p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">function domHandler (el, binding) {
    let placeholderDom = null
   
    if (binding?.arg?.showTips || binding?.arg?.disabled) {
      // 策略1&amp;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)
}</pre>
</div>
<div>
<div>
<p>为什么用<strong>注释节点</strong>而不是直接 <code>display: none</code>?</p>
<p>因为注释节点不会影响布局,也不会被 CSS 选择器选中。更重要的是,我们需要保留一个"锚点",方便权限变化时把原始元素恢复回去。</p>
<h3 data-id="heading-5">4. 响应式更新</h3>
<p>权限可能会动态变化(比如用户被授权后刷新),所以指令需要同时监听 <code>inserted</code> 和 <code>update</code> 钩子:</p>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">Vue.directive('perm', {
    inserted (el, binding) {
      handlerPerm(el, binding)
    },
    update (el, binding) {
      handlerPerm(el, binding)
    }
})</pre>
</div>
<p>恢复元素的逻辑也很简单:</p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">function restoreElement (el) {
    if (el.placeholderDom &amp;&amp; el.placeholderDom.parentNode) {
      el.placeholderDom.parentNode.replaceChild(el, el.placeholderDom)
    }
    el.placeholderDom = null
    return true
}</pre>
</div>
<h3 data-id="heading-6">5. 支持布尔值快捷方式</h3>
<p>有时候权限结果已经在外部计算好了,不需要再走一遍接口校验。这种场景下,支持直接传布尔值会更方便:</p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">&lt;button v-perm:="hasPermission"&gt;操作&lt;/button&gt;
</pre>
</div>
<p>  </p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">if (typeof binding.value === 'boolean') {
    if (binding.value === false) {
      domHandler(el, binding)
    } else {
      restoreElement(el)
    }
    return
}</pre>
</div>
<h2 data-id="heading-7">使用示例</h2>
<h3 data-id="heading-8">基础用法</h3>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">&lt;template&gt;
    &lt;div&gt;
      &lt;!-- 无权限时隐藏 --&gt;
      &lt;button v-perm:="'SPACE.DELETE'"&gt;删除&lt;/button&gt;
      
      &lt;!-- 无权限时禁用并提示 --&gt;
      &lt;button v-perm:="'SPACE.EDIT'"&gt;编辑&lt;/button&gt;
    &lt;/div&gt;
&lt;/template&gt;

&lt;script&gt;
export default {
    computed: {
      permConfig() {
            return {
                spaceId: this.currentSpaceId,
                type: 'space'
            }
      },
      permConfigWithTips() {
            return {
                spaceId: this.currentSpaceId,
                type: 'space',
                disabled: true,
                showTips: true,
                tipsText: '您没有编辑权限,请联系管理员'
            }
      }
    }
}
&lt;/script&gt;</pre>
</div>
<h3 data-id="heading-9">编程式调用</h3>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">// 在执行敏感操作前校验
async handleDelete() {
    try {
      await this.$hasPerm({ spaceId: this.spaceId }, 'SPACE.DELETE')
      // 有权限,继续执行删除逻辑
      await this.doDelete()
    } catch {
      // 无权限,$hasPerm 内部已经弹出提示
    }
}</pre>
</div>
<h3 data-id="heading-10">清除缓存重新加载</h3>
<p>当权限发生变化时(比如管理员授权后),可以清除缓存重新加载:</p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">this.$getPerm({
    spaceId: this.spaceId,
    clearCache: true
})</pre>
</div>
<h2 data-id="heading-11">设计总结</h2>
<p><img src="https://img2024.cnblogs.com/blog/2149129/202603/2149129-20260312165451614-1824023626.png" alt="ScreenShot_2026-03-12_165436_374" loading="lazy"></p>
<p>&nbsp;</p>
<div>
<div>
<h2 data-id="heading-12">可以优化的点</h2>
<ol>
<li><strong>Vue 3 适配</strong>:Vue 3 的指令钩子函数名称有变化(<code>mounted</code>、<code>updated</code>),迁移时需要调整</li>
<li><strong>TypeScript 支持</strong>:可以为 <code>PermissionOptions</code> 添加完整的类型定义</li>
<li><strong>批量权限查询</strong>:如果页面上有大量权限点,可以考虑合并成一次批量查询</li>
<li><strong>权限预加载</strong>:在路由守卫中预加载权限数据,减少页面白屏时间</li>
</ol><hr>
<p>以上就是这个权限指令的完整设计思路。核心思想是:<strong>用缓存换性能,用指令换简洁</strong>。希望对你有所启发,欢迎交流讨论 🙌</p>
<h2 data-id="heading-13">完整代码</h2>
<p>最后贴一下完整代码,可以直接拿去用,根据自己项目的接口改一下就行:</p>
</div>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">// permission.js
import { useSpaceInfoStore } from '@/store/modules/spaceInfo'
import Auth from '@/api/modules/auth'

let spaceInfoStore = null
setTimeout(() =&gt; {
    spaceInfoStore = useSpaceInfoStore()
}, 40)

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

function install (Vue) {
    /**
   * 获取权限信息
   * @param {PermissionOptions} options - 权限选项
   * @returns {Promise&lt;any&gt;}
   */
    Vue.prototype.$getPerm = (options) =&gt; {
      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 = false
      }
      if (!spaceInfoStore.permissionMap) {
            spaceInfoStore.permissionMap = new Promise((resolve, reject) =&gt; {
                if (options.type === 'space') {
                  Auth.getUserPermBySpaceId(options.spaceId).then((res) =&gt; {
                        // 组合权限生成唯一key
                        const permissions = res.reduce((acc, category) =&gt; {
                            category.permissions.forEach(permission =&gt; {
                              acc.push(`${category.value}.${permission.authorityKey}`)
                            })
                            return acc
                        }, [])
                        resolve({ permissions })
                  })
                } else {
                  Auth.getWikiPermissionDetail(options.spaceId, options.wikiId).then((res) =&gt; {
                        // 组合权限生成唯一key
                        const permissions = res.map(item =&gt; item.authorityKey)
                        resolve({ permissions })
                  })
                }
            })
      }
      return spaceInfoStore.permissionMap
    }

    /**
   * 检查是否有权限
   * @param {PermissionOptions} options - 权限选项
   * @param {string} perm - 权限码
   * @returns {Promise&lt;boolean&gt;}
   */
    Vue.prototype.$hasPerm = (options, perm) =&gt; {
      if (!Object.prototype.hasOwnProperty.call(options, 'showTips')) {
            options.showTips = true
      }
      return new Promise((resolve, reject) =&gt; {
            if (!options.spaceId) {
                resolve(true)
                return
            }
            const promise = Vue.prototype.$getPerm(options)
            promise.then((res) =&gt; {
                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 &amp;&amp; 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) =&gt; {
            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 }</pre>
</div>
<p>在&nbsp;<code>main.js</code>&nbsp;里注册一下就能用了:</p>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">import permission from '@/directives/permission'
Vue.use(permission)
</pre>
</div>
<p>  </p>
<div>
<h3 id="tid-D8HBxE">如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。</h3>
</div>
<p><em><img src="https://img2024.cnblogs.com/blog/2149129/202501/2149129-20250122165814748-630765389.png" alt="" loading="lazy"></em></p>
</div>
</div>
</div><br><br>
来源:https://www.cnblogs.com/smileZAZ/p/19709429
頁: [1]
查看完整版本: 告别满屏 v-if:用一个自定义指令搞定 Vue 前端权限控制