Zapic's Blog
使用Vue Directive实现的Vue页面权限控制
2023-12-08
编程
查看标签

\#0 权限控制? 前端?

前端的权限控制不是为了阻止用户做什么, 而是为了告诉用户不能做什么, 实际阻止用户去做什么应该由后端完成, 把按钮藏起来并不能阻止用户触发那个按钮背后的实际后端逻辑.
前端的权限控制是为了告诉用户不能做什么, 把用户无权限操作的按钮隐藏起来, 可以降低用户无知点下按钮后权限不足造成的挫败感.

接下来, 假设我们已经通过暴露到全局的 $perm.has: (permissionNode: string): boolean 实现了权限的判断, 来思考如何以一种开发友好的方式控制元素显示.

\#1 v-if?

这应该是最容易最直接想到的办法:

<button v-if="$perm.has('i.am.admin')">Admin action!</button>

但如果需要给这段代码添加权限控制呢?

<button v-if="
  !isLoading 
  && !isEmpty 
  && allowAdminAction 
  || userAllowAdminAction
">
  Admin action!
</button>

本来这段代码已经足够长了, 直接拼接就会导致:

<button v-if="
  $perm.has('i.am.admin')
  && (!isLoading 
  && !isEmpty 
  && allowAdminAction 
  || userAllowAdminAction)
">
  Admin action!
</button>

这就完全没法看了, 而且如果有很多要控制权限的代码, 就会有大量的 $perm.has 在代码中, 有点繁琐.
因此思考:

方案1

<template v-if="$perm.has('i.am.admin')">
  <button v-if="
    !isLoading 
    && !isEmpty 
    && allowAdminAction 
    || userAllowAdminAction
  ">
    Admin action!
  </button>
</template>

虽然可读性有所增加, 但是多套一次template多少还是有些繁琐

方案2

<button v-if="
  !isLoading 
  && !isEmpty 
  && allowAdminAction 
  || userAllowAdminAction
" v-perm="'i.am.admin'">
  Admin action!
</button>

通过一个自定义指令 v-perm , 直接传入一个权限节点字符串, 就可以控制这个元素是否显示了.
这个指令就大幅的提升了可读性, 也将权限表达式从 v-if 中抽出来, 不再使 v-if 变长.
显然这个方案比较好, 看起来舒服写起来爽, 但这个指令需要我们自己去实现.

\#2 实现 v-perm

直接用Runtime Directive实现

Vue的Custom Directive可以很方便的直接操作DOM, 故思考, 我们应该可以通过控制元素的 display 样式来控制元素的显示和隐藏(类似 v-show ):
在应用入口注册:

const createPermDirective = ($perm: Permission) => {
  const vPerm: Directive = (el, binding, vnode) => {
    const permNode = binding.value;
    if ($perm.has(permNode)) {
      el.style.display = "";
    } else {
      el.style.display = "none";
    }
  };
  return vPerm;
}
// ... createApp(App), permission
app.directive("perm", createPermDirective(permissionInstance));
// ... app.mount("#app")

在模板中:

<button v-if="
  !isLoading 
  && !isEmpty 
  && allowAdminAction 
  || userAllowAdminAction
" v-perm="'i.am.admin'">
  Admin action!
</button>

虽然勉强能用, 但有较大的局限性:

  1. 这个东西不能用在FunctionalComponent上, 只要没有DOM的元素都不能用
  2. SSR中也不能用, 需要写一个SSRDirectiveTransformer, 在水合完成前仍然会显示在界面上
  3. 可能会与 v-show 或者原本的 :style 绑定冲突

有点委屈, 看来还是得换个方法.

使用Vite(Rollup)插件将 v-perm 转成 v-show / v-if

换个暴力点的思路, 既然将 $perm.has 直接写进 v-if 中会影响可读性和美观性, 那我可以在编译期再放进去嘛.
写一个Rollup插件, 在编译期将 v-perm="xxx" 转成 v-if="$perm.has(xxx) && (...)" :
插件本体:

import { Plugin } from "vite";

export default () =>
  ({
    name: "permission-directive-transformer",
    transform(code, id) {
      if (!id.endsWith(".vue")) return; // 只处理.vue文件
      const matches = code.matchAll(/<[^<]*?(v-perm="(.*?)")[^]*?>/g); // 匹配有v-perm指令的元素标签
      let result = code;
      for (const match of matches) {
        let tag = match[0]; // 整个标签
        const permDirective = match[1]; // 指令本体
        const perm = match[2]; // 指令参数
        if (perm.length === 0) {
          console.warn("Empty permission directive.");
          continue;
        }
        if (tag.includes("v-if=") || tag.includes("v-else")) { // 如果原本就有v-if v-else-if v-else
          const vIf = /v(-else)?-if="(.*?)"/.exec(tag); // 提取v-if
          if (vIf) { // 如果是 v-if v-else-if 这种带表达式的
            const vIfCondition = vIf[1];
            tag = tag.replace(
              vIf[0],
              vIf[0].replace(vIf[2], `$perm.has(${perm}) && (${vIfCondition})`) // 组合 $perm.has(permission) && (orignalCondition)
            );
          } else { // v-else不带表达式
            tag = tag.replace("v-else", `v-else-if="$perm.has(${perm})"`); // 直接把v-else替换成v-else-if="$perm.has()"
          }
          tag = tag.replace(permDirective, ""); // 处理完成, 移除v-perm
        } else { // 没有v-if v-else v-else-if
          tag = tag.replace(permDirective, `v-if="$perm.has(${perm})"`); // 直接把v-perm替换成v-if="$perm.has()"
        }
        result = result.replace(match[0], tag); // 替换原本的元素
      }
      return {
        code: result,
        map: null
      };
    },
    options: {
      order: "pre",
      handler: () => null
    }
  } as Plugin);

vite.config.ts:

import createPermissionTransformer from "./perm-transformer.ts"
export default defineConfig({
  plugins: [
    createPermissionTransformer(),
    vue()
  ]
});

在模板中:

<button v-if="
  !isLoading 
  && !isEmpty 
  && allowAdminAction 
  || userAllowAdminAction
" v-perm="'i.am.admin'">
  Admin action!
</button>

很直接很暴力, 在编译后代码中不会存在任何 v-perm 指令, 不会存在Runtime Directive中的问题.
但是基于正则表达式的代码转换肯定存在缺陷, 鲁棒性不是特别好, 只能够解决99%的情况:

  1. 如果在模板之外的地方存在 v-perm="xxx" 的字符串呢?
  2. 如果这只是一个普通的字符串呢

想要解决上面这两个问题, 就需要做非常复杂, 非常严谨的判断, 才能保证100%不会破坏代码.
当然, 绝大部分情况下也够了, 这当然也不失为一种合格的方案.

有没有不那么暴力的, 优雅一点的方案呢?
直到写出上面这个方案的8个月后, 我才找到解决方案.

使用Vue Directive Transformer实现

想要用这个方法实现, 就得去了解Vue是如何编译SFC文件组件的.
但显然, 这可能对大多数人(包括我在内)有点过于复杂了.
为了避免强迫自己让知识滑过大脑, 可能还是需要找点捷径来解决这个问题.
直到最近几天看到 @antfu 写的一个新项目 v-lazy-show , 代码只有不到150行.
我发现这个项目与我想做的事情非常类似, 主要需要解决的问题就是如何把自定义指令转换成Vue的v-if v-show这类内置指令.
经过亿点点的参考, 得出了第三版的 v-perm:

import {
  createStructuralDirectiveTransform,
  createSimpleExpression,
  traverseNode
} from "@vue/compiler-core";

export default createStructuralDirectiveTransform(
  /^perm$/,
  (node, dir, ctx) => {
    const conditionExp = dir.exp;

    node.props.forEach(prop => {
      if (
        "exp" in prop &&
        prop.exp &&
        "content" in prop.exp &&
        prop.exp.loc.source
      )
        prop.exp = createSimpleExpression(prop.exp.loc.source);
    });

    // 将原本的权限节点字符串转为$perm.has表达式
    if (conditionExp.loc.source) {
      dir.exp = createSimpleExpression(`$perm.has(${conditionExp.loc.source})`);
    }
    
    // 复制v-perm指令的信息, 作为一个v-if指令推入prop中
    node.props.push({
      ...dir,
      name: "if"
    });
    if (ctx.ssr || ctx.inSSR) {
      // transformSSRIf在nodeTransforms的index基于@vue/compiler-ssr的实现
      // https://github.com/vuejs/core/blob/f811dc2b60ba7efdbb9b1ab330dcbc18c1cc9a75/packages/compiler-ssr/src/index.ts#L58
      const transformSSRIf = ctx.nodeTransforms[0];
      transformSSRIf(node, ctx);
    } else {
      if (!node.codegenNode) traverseNode(node, ctx);
      // transformIf在nodeTransforms的index基于@vue/compiler-core的实现
      // https://github.com/vuejs/core/blob/f811dc2b60ba7efdbb9b1ab330dcbc18c1cc9a75/packages/compiler-core/src/compile.ts#L33
      const transformIf = ctx.nodeTransforms[1];
      transformIf(node, ctx);
    }
  }
);

vite.config.ts:

import transformPermissionDirective from "./perm-transformer.ts"
export default defineConfig({
  plugins: [
    vue({
      template: {
        compilerOptions: {
          nodeTransforms: [transformPermissionDirective]
        }
      }
    })
  ]
});

在模板中:

<button v-if="
  !isLoading 
  && !isEmpty 
  && allowAdminAction 
  || userAllowAdminAction
" v-perm="'i.am.admin'">
  Admin action!
</button>

嗯, 说实话, 虽然我理解这段代码在做什么, 但我其实并不清楚其中的大多数代码为什么要这样做.
即便如此, 我们已经稀里糊涂的写出了一个基于AST的Directive Transformer, 并且cover住了SSR和Client.
就结果而言, 已经足够好了.

\#4 搞定类型

这个简单, 随便找个 src/ 下的 d.ts :

declare module "@vue/runtime-core" {
  export interface ComponentCustomProperties {
    vPermission: Directive<undefined, string>;
  }
}

自此, 我们已经实现了一个较为完善的前端权限控制, 并且有较好的开发体验. 当然, 实际需求只会更复杂, 还是需要针对实际需求对代码做出一点点改变.