\#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>
虽然勉强能用, 但有较大的局限性:
- 这个东西不能用在FunctionalComponent上, 只要没有DOM的元素都不能用
- SSR中也不能用, 需要写一个SSRDirectiveTransformer, 在水合完成前仍然会显示在界面上
- 可能会与
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%的情况:
- 如果在模板之外的地方存在
v-perm="xxx"
的字符串呢? - 如果这只是一个普通的字符串呢
想要解决上面这两个问题, 就需要做非常复杂, 非常严谨的判断, 才能保证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) => {
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 ssrTransformIf = ctx.nodeTransforms[0];
node.props.push({
...dir,
name: "if",
exp: createSimpleExpression(`$perm.has(${dir.exp.loc.source})`)
});
ssrTransformIf(node, ctx);
return;
}
const wrap = createConditionalExpression(
createCallExpression("_ctx.$perm.has", [dir.exp.loc.source]),
createVNodeCall(
ctx,
ctx.helper(FRAGMENT),
undefined,
[node],
"64" /* STABLE_FRAGMENT */,
undefined,
undefined,
true,
false,
false,
node.loc
),
createCallExpression(
ctx.helper(CREATE_COMMENT),
[`"v-permission"`, "true"],
node.loc
)
);
ctx.replaceNode(wrap as unknown as TemplateChildNode);
return () => {
if (!node.codegenNode) traverseNode(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>;
}
}
自此, 我们已经实现了一个较为完善的前端权限控制, 并且有较好的开发体验. 当然, 实际需求只会更复杂, 还是需要针对实际需求对代码做出一点点改变.