用装饰器来优化前端权限控制

前言

在某一天逛掘金论坛的时候,看到了一篇讲述权限控制的文章,《面试官问我按钮级别权限怎么控制,我说v-if,面试官说再见》。文章内容整体看下来简单易懂,但是当我翻看到评论的时候,看到热门评论第一的回复中有推荐用装饰器来配置权限的标识。这种说法我也是第一次看到,然后就接着看了下去,但是看的我是云里雾里并没有搞懂是如何实现的。然后这个楼主说了让去看他最新的文章,但是并没有看到这个楼主发布的相关文章。所以就想着自己研究一下具体是如何实现这个功能的。以下就是自己对于装饰器的一些个人见解,以及实现的简单的demo。

装饰器介绍

这里搬运一下 ChatGPT 对于装饰器的描述:

TypeScript 中,装饰器(Decorators)是一种特殊的声明,它修饰的是类及其方法或者修饰属性的方法以及参数。装饰器提供了一种在编译时修改且运行时执行继承后类和类成员的能力,从而实现元编程(metaprogramming)的效果。

装饰器使用了@符号,紧接着是装饰器的名称,放置在被修饰的目标之前。装饰器可以是一个函数或一个类,它接收不同的参数,具体取决于装饰的目标类型。

TypeScript 中,装饰器可以用于以下几个方面:

  1. 类装饰器(Class Decorators):应用于类声明之前,用于修改类的行为。类装饰器接收一个参数,即被修饰的类构造函数,可以用来扩展类的功能、修改类的元数据等。
  2. 方法装饰器(Method Decorators):应用于类方法之前,用于修改方法的行为。方法装饰器接收三个参数,分别是被修饰的类的原型对象、方法的名称和属性描述符。可以用来拦截方法调用、修改方法的元数据等。
  3. 属性装饰器(Property Decorators):应用于类属性之前,用于修改属性的行为。属性装饰器接收两个参数,分别是被修饰的类的原型对象和属性的名称。可以用来拦截属性访问、修改属性的元数据等。
  4. 参数装饰器(Parameter Decorators):应用于函数或类方法的参数之前,用于修改参数的行为。参数装饰器接收三个参数,分别是被修饰的函数或方法的原型对象、参数的名称和参数在函数参数列表中的索引。可以用来拦截参数传递、修改参数的元数据等。

装饰器提供了一种强大的机制,可以在不修改源代码的情况下,通过附加额外的逻辑来改变类和类成员的行为。它在 TypeScript 中广泛应用于诸如依赖注入、路由处理、日志记录等方面的开发中,使得代码更具可读性、可维护性和可扩展性。

特别需要注意点的是装饰器的新特性是在 Typescript 的 5.0 版本开始支持的,所以为了更好地体验此功能建议运行环境版本保持高于 5.0 版本。

装饰器的简单应用

举例说明:假如猫和狗都有一个共同的行为,那就是吃饭、睡觉、打豆豆,那如何使用装饰器来实现这个功能呢?按照一般的思路,我们可以分别创建两个类 CatDog,然后再实现一个父类 Animal 来让子类继承从而实现公共行为。但是现在有了装饰器后,我们就可以分别在两个类上去修饰行为,这样就不用再去写多余的继承逻辑。具体代码如下:

const behaviors = (target: Function, _context: any) => {
    console.log(`${target.name}, 在吃饭`);
    console.log(`${target.name}, 在睡觉`);
    console.log(`${target.name}, 在打豆豆`);
};

@behaviors
class Cat {}

@behaviors
class Dog {}

const cat = new Cat();
console.log(cat);

const dog = new Dog();
console.log(dog);

// 内容输出如下
// Cat, 在吃饭
// Cat, 在睡觉
// Cat, 在打豆豆
// Dog, 在吃饭
// Dog, 在睡觉
// Dog, 在打豆豆

装饰器的实战应用

假如我有一个公共的 Button 组件,那么在引入的时候需要根据权限去判断这个用户是否有相对应的权限。那除了常规的自定义全局指令之外,还可以通过装饰器的功能来做预处理。下面通过 vite 创建一个基于 vue3 的工程,快速实现一个相关功能的 Demo:

创建一个 usePermission 文件用以统一处理事件

首先我们需要定义一个 UserEntity 类用于处理 Button 的相关内容,然后用装饰器对 UserEntity 类进行装饰。我们不仅可以对其权限进行判定,同时也可以做一些其他的工作例如判断其登录状态。我们可以通过装饰器对其原型进行方法拓展,进而判断其所属的权限都有哪些,代码如下:

// 实际应用中的权限最好应该是在store中异步获取,这里做demo的话就简单写死了
const userInfos = {
    name: '张三',
    isLogin: true,
    permission: ['add', 'edit'],
};

function checkPermission(target: Function, _context: any) {
    /**
     * 这里只是简单处理用户的登录情况,实际在开发过程中需要根据业务逻辑来判断
     */
    if (!userInfos.isLogin) {
        return alert('用户未登录');
    }
    target.prototype.getAddPermission = (): boolean => {
        return userInfos.permission.includes('add');
    };

    target.prototype.getDeletePermission = (): boolean => {
        return userInfos.permission.includes('delete');
    };

    target.prototype.getEditPermission = (): boolean => {
        return userInfos.permission.includes('edit');
    };
}

@checkPermission
export class UserEntity {
    getAddPermission!: () => boolean;
    getDeletePermission!: () => boolean;
    getEditPermission!: () => boolean;
    constructor() {}
}

创建一个 Button 组件

基于对 el-button 的二次封装,组件接收三个传参分别是:type、text、以及 userEntity 类。组件内部通过判断父组件传递来的 type 进行判断,内部根据 type 所对应的值调用对应装饰器的函数即可。

<template>
    <el-button type="button" :disabled="!disabled">{{ props.text }}</el-button>
</template>

<script lang="ts" setup>
import {PropType, ref} from 'vue';
import UserEntity from '../useHooks/usePermission.ts';

const props = defineProps({
    type: {
        type: String,
        required: true,
    },
    text: {
        type: String,
        required: true,
    },
    userEntity: {
        type: Object as PropType<typeof UserEntity>,
        required: true,
    },
});

const userEntity = new props.userEntity();
const disabled = ref(true);
console.log(userEntity);
if (props.type === 'Add') {
    disabled.value = userEntity.getAddPermission();
} else if (props.type === 'Edit') {
    disabled.value = userEntity.getEditPermission();
} else if (props.type === 'Delete') {
    disabled.value = userEntity.getDeletePermission();
}
</script>

引入自定义组件

<template>
    <h1>{{ msg }}</h1>

    <div class="card">
        <AButton type="ADD" text="新增用户" :userEntity="UserEntity"></AButton>
        <AButton type="Edit" text="编辑用户" :userEntity="UserEntity"></AButton>
        <AButton type="Delete" text="删除用户" :userEntity="UserEntity"></AButton>
        <div class="splitter"></div>
        <AForm :userEntity="UserFormEntity"></AForm>
    </div>
</template>

实现效果

可以看到下图的按钮,没有权限是灰色不可点击,有权限则是激活状态可以点击。

用装饰器来优化前端权限控制

装饰器功能拓展

上一章节只是简单地实现了装饰器对于权限控制的一种方式,既然使用了装饰器的功能,那么为什么我们不拓展其更多的功能呢?比如我们可以通过装饰器去拓展其属性参数等一些结构,最明显的就是对表单的封装。如果我们写够了表单的话,何不尝试一下使用装饰器的快乐呢?下面来实现一个简单表单的封装实现:

添加一个表单组件

我们先简单创建一个表单,表单里面有用户名和备注两项(剩下的都是大同小异,简单写俩输入框)。常规写法我们可能会直接开始拷贝组件的内容,但是是否可以通过装饰器的方式来决定这个表单的渲染逻辑呢?
下面的代码是完整版本的,其核心的代码就是下面两句话,通过使用元数据的方式,取得具体属性的数据。
元数据的使用方式可以参考此文档

const nameFormField = userEntity.getFormField('name');
const commentFormField = userEntity.getFormField('comment');

完整代码如下:

<template>
    <el-form :model="form" label-width="120px">
        <el-form-item :label="userEntity.name" :required="nameFormField.required">
            <el-input
                v-model="form.name"
                :type="nameFormField.type"
                :show-word-limit="nameFormField.showLimit"
                :maxlength="nameFormField.maxLength"
                :placeholder="nameFormField.placeholder"
            />
        </el-form-item>
        <el-form-item :label="userEntity.comment">
            <el-input
                v-model="form.desc"
                :show-word-limit="commentFormField.showLimit"
                :type="commentFormField.type"
                :maxlength="commentFormField.maxLength"
                :placeholder="commentFormField.placeholder"
            />
        </el-form-item>
        <el-form-item>
            <el-button type="primary" @click="onSubmit" :disabled="!userEntity.getEditPermission()">Create</el-button>
            <el-button>Cancel</el-button>
        </el-form-item>
    </el-form>
</template>

<script setup lang="ts">
import {PropType, reactive} from 'vue';
const props = defineProps({
    userEntity: {
        type: Function as PropType<any>,
        required: true,
    },
});

// do not use same name with ref
const form = reactive({
    name: '',
    desc: '',
});

function onSubmit() {
    console.log('submit!');
}

const userEntity = new props.userEntity();
const nameFormField = userEntity.getFormField('name');
const commentFormField = userEntity.getFormField('comment');
console.log(commentFormField);
</script>

创建一个表单类

首先先创建一个 UserFormEntity 类,然后同样的我们使用权限函数 checkPermission 装饰一下这个类,接着再创建一些属性的装饰器用于装饰表单。FieldName 主要是用于返回当前表单元素的label,同时也给接下来的元数据提供一个载体。FormField 函数通过借用库reflect-metadata来保存信息,方便后续获取对应的元数据。

import 'reflect-metadata';

// 实际应用中的权限最好应该是在store中异步获取,这里做demo的话就简单写死了
const userInfos = {
    name: '张三',
    isLogin: true,
    permission: ['add', 'edit'],
};

function checkPermission(target: Function, _context: any) {
    /**
     * 这里只是简单处理用户的登录情况,实际在开发过程中需要根据业务逻辑来判断
     */
    if (!userInfos.isLogin) {
        return alert('用户未登录');
    }
    target.prototype.getAddPermission = (): boolean => {
        return userInfos.permission.includes('add')
    };

    target.prototype.getDeletePermission = (): boolean => {
        return userInfos.permission.includes('delete')
    };

    target.prototype.getEditPermission = (): boolean => {
        return userInfos.permission.includes('edit')
    };
}

function FieldName(fieldName: string) {
    return function (target: any, propertyName: any) {
        Object.defineProperty(target, propertyName, {
            get: () => {
                return fieldName;
            },
            set: (val) => {
                fieldName = val;
            },
        });
    };
}

function FormField(object: any) {
    return function (target: any, propertyName: any) {
        Reflect.defineMetadata(target.name, object, target, propertyName);
    };
}

@checkPermission
export class UserFormEntity {
    getAddPermission!: () => boolean;
    getDeletePermission!: () => boolean;
    getEditPermission!: () => boolean;

    @FormField({
        type: 'text',
        required: true,
        showLimit: true,
        maxLength: 10,
        placeholder: '请输入用户名',
    })
    @FieldName('用户名')
    name!: string;

    @FormField({
        type: 'textarea',
        required: false,
        showLimit: true,
        maxLength: 300,
        placeholder: '请输入备注内容',
    })
    @FieldName('备注')
    comment!: string;

    constructor() {}

    getFormField(fieldName: string) {
        const formField = Reflect.getMetadata(this.name, this, fieldName);
        return formField;
    }
}

实现效果

用装饰器来优化前端权限控制

一个小波折

我在刚开始写代码的时候是在hooks目录下临时创建了一个对于 .ts 的 watch task,目的是为了能够方便我快速执行代码结果。

用装饰器来优化前端权限控制

但是当我删除掉这个目录下的配置文件后,突然发现已经写好的功能不再 work 了,然后经过一上午的排查发现问题在于 tsconfig.ts 的配置项的问题,vite 工具创建的开发模板中有一个关于 class 的配置 useDefineForClassFields ,删除或者改为 false 即可。

ChatGPT 关于对于 useDefineForClassFields 这个配置的描述如下:

useDefineForClassFields 的作用是确定在定义类字段时使用何种语法。它是 ECMAScript 2022 中的一个选项,用于指定在声明和初始化类字段时采用的语法。通过设置 useDefineForClassFields 选项,您可以控制是否使用新的语法来定义类字段。如果启用了该选项,就可以使用 ECMAScript 2022 的语法,否则将会退到旧的类构造函数语法。

完整的 tsconfig.ts 文件内容如下:

{
    "compilerOptions": {
        "target": "ES2020",
        "useDefineForClassFields": true, // 这一项的存在会影响现有的装饰器逻辑
        "module": "ESNext",
        "lib": ["ES2020", "DOM", "DOM.Iterable"],
        "skipLibCheck": true,

        "moduleResolution": "bundler",
        "allowImportingTsExtensions": true,
        "resolveJsonModule": true,
        "isolatedModules": true,
        "noEmit": true,
        "jsx": "preserve",

        "strict": true,
        "noUnusedLocals": true,
        "noUnusedParameters": true,
        "noFallthroughCasesInSwitch": true
    },
    "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
    "references": [{"path": "./tsconfig.node.json"}]
}

总结

装饰器的方式看起来就是一种继承的语法糖,只是通过简单的写法,让我们的类具有更灵活的功能。其实通过权限控制的方法有很多,这种装饰器的方式只是其中一个功能的拓展,在我看来通过使用装饰器来实现一些实体类进行页面控制更符合装饰器的存在意义。

参考文档

TypeScript 5.0 将支持全新的装饰器写法!
zhuanlan.zhihu.com/p/603820333

Typescript 装饰器 Decorators 学习
www.cnblogs.com/bleaka/p/16…

本文中出现的相关代码
github.com/TommyCat008…

原文链接:https://juejin.cn/post/7258970835045646392 作者:BEFE团队

(0)
上一篇 2023年7月23日 上午11:10
下一篇 2023年7月24日 上午10:05

相关推荐

发表回复

登录后才能评论