MK
摩柯社区 - 一个极简的技术知识社区
AI 面试

自定义TypeScript装饰器的设计与开发

2022-09-047.6k 阅读

TypeScript装饰器基础概念

在深入探讨自定义TypeScript装饰器的设计与开发之前,我们先来回顾一下装饰器的基本概念。装饰器是一种特殊类型的声明,它可以附加到类声明、方法、属性或参数上,用于修改类的行为或者为其添加额外的功能。TypeScript从ES7的装饰器提案借鉴了这一概念,并提供了自己的实现。

装饰器本质上是一个函数,它以被装饰的目标(类、方法、属性等)作为参数,并返回一个新的目标或者对原目标进行修改。在TypeScript中,装饰器的语法使用@符号,紧跟在装饰器函数名之后,例如:

function myDecorator(target: any) {
    // 对target进行操作
}

@myDecorator
class MyClass {
    // 类的定义
}

上述代码中,myDecorator是一个简单的装饰器函数,它接收MyClass类作为target参数。这里的target就是被装饰的目标。

不同类型的装饰器

类装饰器

类装饰器应用于类的定义。它接收类的构造函数作为参数。类装饰器可以用于修改类的行为,例如添加新的属性或方法,或者修改类的继承关系。

function classDecorator(target: Function) {
    target.prototype.newMethod = function() {
        console.log('This is a new method added by the class decorator');
    };
}

@classDecorator
class MyClass {
    constructor() {}
}

const instance = new MyClass();
(instance as any).newMethod(); 

在上述代码中,classDecorator装饰器给MyClass类添加了一个新的方法newMethod。当我们创建MyClass的实例并调用这个新方法时,就能看到装饰器生效的效果。

方法装饰器

方法装饰器应用于类的方法。它接收三个参数:类的原型对象(prototype)、方法名和一个描述符对象(PropertyDescriptor)。描述符对象包含了方法的一些属性,如value(方法的实际函数)、writable(是否可写)、enumerable(是否可枚举)和configurable(是否可配置)。

function methodDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function() {
        console.log('Before method execution');
        const result = originalMethod.apply(this, arguments);
        console.log('After method execution');
        return result;
    };
    return descriptor;
}

class MyClass {
    @methodDecorator
    myMethod() {
        console.log('Inside myMethod');
    }
}

const instance = new MyClass();
instance.myMethod(); 

在这段代码中,methodDecorator装饰器在方法执行前后添加了日志输出。它通过保存原始方法,然后重新定义描述符的value属性来实现这一功能。当调用myMethod时,就能看到先输出“Before method execution”,然后是“Inside myMethod”,最后是“After method execution”。

属性装饰器

属性装饰器应用于类的属性。它接收两个参数:类的原型对象和属性名。属性装饰器可以用于对属性进行验证、添加访问控制等操作。

function propertyDecorator(target: any, propertyKey: string) {
    let value: any;
    const getter = function() {
        return value;
    };
    const setter = function(newValue: any) {
        if (typeof newValue === 'number' && newValue > 0) {
            value = newValue;
        } else {
            throw new Error('Value must be a positive number');
        }
    };
    Object.defineProperty(target, propertyKey, {
        get: getter,
        set: setter,
        enumerable: true,
        configurable: true
    });
}

class MyClass {
    @propertyDecorator
    myProperty: number;
}

const instance = new MyClass();
instance.myProperty = 10; 
console.log(instance.myProperty); 
try {
    instance.myProperty = -5; 
} catch (error) {
    console.error(error); 
}

在上述代码中,propertyDecorator装饰器对myProperty属性进行了值的验证。只有当设置的值是正整数时,才会成功设置,否则会抛出错误。

参数装饰器

参数装饰器应用于类方法的参数。它接收三个参数:类的原型对象、方法名和参数在参数列表中的索引。参数装饰器可以用于记录参数信息、验证参数等。

function parameterDecorator(target: any, propertyKey: string, parameterIndex: number) {
    const method = target[propertyKey];
    target[propertyKey] = function() {
        console.log(`Parameter at index ${parameterIndex} is:`, arguments[parameterIndex]);
        return method.apply(this, arguments);
    };
}

class MyClass {
    myMethod(@parameterDecorator param: string) {
        console.log('Inside myMethod with parameter:', param);
    }
}

const instance = new MyClass();
instance.myMethod('Hello'); 

在这段代码中,parameterDecorator装饰器在方法执行前输出了指定索引位置的参数值。当调用myMethod并传入参数时,会先输出参数信息,然后执行方法的正常逻辑。

自定义装饰器的设计原则

单一职责原则

每个自定义装饰器应该只负责一个特定的功能。例如,一个装饰器专门用于日志记录,另一个装饰器专门用于权限验证。这样可以提高装饰器的可复用性和可维护性。如果一个装饰器承担过多的职责,当其中一个功能需要修改时,可能会影响到其他功能,增加维护成本。

可读性和可理解性

装饰器的代码应该清晰易懂。在命名装饰器函数时,要使用有意义的名称,能够准确反映其功能。同时,在装饰器内部的代码逻辑也应该尽量简洁。避免使用过于复杂的逻辑和嵌套,这样其他开发人员在阅读和使用这些装饰器时能够快速理解其作用。

灵活性和扩展性

设计自定义装饰器时要考虑到未来的扩展需求。装饰器应该能够方便地与其他装饰器组合使用,并且在面对新的业务需求时,能够容易地进行修改和扩展。例如,可以通过传入参数的方式,让装饰器在不同的场景下表现出不同的行为。

自定义装饰器开发实践

日志记录装饰器

日志记录是一个常见的需求,我们可以通过自定义装饰器来实现方法调用的日志记录。

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function() {
        console.log(`Calling method ${propertyKey}`);
        const result = originalMethod.apply(this, arguments);
        console.log(`Method ${propertyKey} returned:`, result);
        return result;
    };
    return descriptor;
}

class MathUtils {
    @log
    add(a: number, b: number) {
        return a + b;
    }
}

const mathUtils = new MathUtils();
const sum = mathUtils.add(2, 3); 

在上述代码中,log装饰器记录了方法的调用和返回值。当调用add方法时,会先输出“Calling method add”,然后输出“Method add returned: 5”。

权限验证装饰器

在许多应用中,需要对某些方法进行权限验证,只有具有特定权限的用户才能调用。我们可以创建一个权限验证装饰器。

interface User {
    role: string;
}

function checkPermission(requiredRole: string) {
    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function(user: User) {
            if (user.role === requiredRole) {
                return originalMethod.apply(this, arguments);
            } else {
                throw new Error('Permission denied');
            }
        };
        return descriptor;
    };
}

class AdminPanel {
    @checkPermission('admin')
    deleteUser(user: { id: number }) {
        console.log(`Deleting user with id ${user.id}`);
    }
}

const adminUser: User = { role: 'admin' };
const regularUser: User = { role: 'user' };

const adminPanel = new AdminPanel();
adminPanel.deleteUser({ id: 1 }); 
try {
    adminPanel.deleteUser({ id: 2 }); 
} catch (error) {
    console.error(error); 
}

在这段代码中,checkPermission装饰器接收一个requiredRole参数,只有当用户的rolerequiredRole匹配时,才能调用被装饰的方法。这里deleteUser方法只有admin角色的用户才能调用。

缓存装饰器

有时候,一些方法的计算成本较高,我们可以通过缓存装饰器来缓存方法的返回结果,避免重复计算。

function cache(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    const cache: { [key: string]: any } = {};
    descriptor.value = function() {
        const key = JSON.stringify(arguments);
        if (cache[key]) {
            return cache[key];
        }
        const result = originalMethod.apply(this, arguments);
        cache[key] = result;
        return result;
    };
    return descriptor;
}

class ExpensiveCalculation {
    @cache
    calculate(a: number, b: number) {
        // 模拟一个耗时的计算
        for (let i = 0; i < 1000000; i++);
        return a * b;
    }
}

const calculation = new ExpensiveCalculation();
const result1 = calculation.calculate(2, 3); 
const result2 = calculation.calculate(2, 3); 

在上述代码中,cache装饰器通过一个对象cache来存储方法的计算结果。当相同参数再次调用calculate方法时,直接从缓存中返回结果,而不需要再次执行耗时的计算。

装饰器的组合使用

在实际应用中,我们经常需要将多个装饰器应用到同一个目标上。例如,我们可能既需要对一个方法进行日志记录,又需要进行权限验证。

function log(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function() {
        console.log(`Calling method ${propertyKey}`);
        const result = originalMethod.apply(this, arguments);
        console.log(`Method ${propertyKey} returned:`, result);
        return result;
    };
    return descriptor;
}

function checkPermission(requiredRole: string) {
    return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        const originalMethod = descriptor.value;
        descriptor.value = function(user: { role: string }) {
            if (user.role === requiredRole) {
                return originalMethod.apply(this, arguments);
            } else {
                throw new Error('Permission denied');
            }
        };
        return descriptor;
    };
}

class SecureService {
    @log
    @checkPermission('admin')
    sensitiveOperation() {
        console.log('Performing sensitive operation');
    }
}

const adminUser = { role: 'admin' };
const regularUser = { role: 'user' };

const secureService = new SecureService();
secureService.sensitiveOperation.call(adminUser); 
try {
    secureService.sensitiveOperation.call(regularUser); 
} catch (error) {
    console.error(error); 
}

在这段代码中,sensitiveOperation方法同时应用了logcheckPermission装饰器。先进行权限验证,只有权限通过后才会执行日志记录和方法的实际逻辑。当使用adminUser调用时,方法正常执行并记录日志;当使用regularUser调用时,会抛出权限不足的错误。

装饰器在大型项目中的应用场景

依赖注入

在大型项目中,依赖注入是一种常用的设计模式,用于解耦组件之间的依赖关系。装饰器可以方便地实现依赖注入。

interface Database {
    connect(): void;
}

class MySQLDatabase implements Database {
    connect() {
        console.log('Connected to MySQL database');
    }
}

function injectDatabase(target: any, propertyKey: string) {
    const database = new MySQLDatabase();
    Object.defineProperty(target, propertyKey, {
        value: database,
        enumerable: true,
        configurable: true
    });
}

class UserService {
    @injectDatabase
    database: Database;

    getUser() {
        this.database.connect();
        console.log('Getting user data');
    }
}

const userService = new UserService();
userService.getUser(); 

在上述代码中,injectDatabase装饰器将MySQLDatabase实例注入到UserService类的database属性中。这样UserService就可以使用注入的数据库实例进行操作,而不需要在内部手动创建数据库连接,实现了依赖的解耦。

面向切面编程(AOP)

AOP是一种编程范式,它将横切关注点(如日志记录、事务管理等)从业务逻辑中分离出来。装饰器非常适合实现AOP。

function transaction(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function() {
        console.log('Starting transaction');
        try {
            const result = originalMethod.apply(this, arguments);
            console.log('Transaction committed');
            return result;
        } catch (error) {
            console.log('Transaction rolled back');
            throw error;
        }
    };
    return descriptor;
}

class FinancialService {
    @transaction
    transferMoney(from: string, to: string, amount: number) {
        console.log(`Transferring ${amount} from ${from} to ${to}`);
    }
}

const financialService = new FinancialService();
financialService.transferMoney('Account1', 'Account2', 100); 

在这段代码中,transaction装饰器为transferMoney方法添加了事务管理的逻辑。在方法执行前开始事务,执行成功后提交事务,执行失败时回滚事务。通过这种方式,将事务管理的逻辑从业务逻辑中分离出来,使得代码更加清晰和可维护。

数据验证和转换

在处理用户输入或者从外部数据源获取的数据时,需要进行数据验证和转换。装饰器可以方便地实现这一功能。

function validateNumber(target: any, propertyKey: string) {
    let value: any;
    const getter = function() {
        return value;
    };
    const setter = function(newValue: any) {
        if (typeof newValue === 'number' && isFinite(newValue)) {
            value = newValue;
        } else {
            throw new Error('Invalid number');
        }
    };
    Object.defineProperty(target, propertyKey, {
        get: getter,
        set: setter,
        enumerable: true,
        configurable: true
    });
}

class Order {
    @validateNumber
    quantity: number;

    constructor(quantity: number) {
        this.quantity = quantity;
    }
}

try {
    const order = new Order(5); 
    console.log('Order quantity:', order.quantity); 
    const invalidOrder = new Order('not a number'); 
} catch (error) {
    console.error(error); 
}

在上述代码中,validateNumber装饰器对quantity属性进行了数据验证。只有当设置的值是有效的数字时,才会成功设置,否则会抛出错误。这样可以确保数据的正确性,避免在业务逻辑中出现因数据类型错误而导致的问题。

注意事项和常见问题

装饰器的执行顺序

当多个装饰器应用到同一个目标时,装饰器的执行顺序可能会影响到最终的效果。在类装饰器中,装饰器从最靠近类定义的地方开始向外执行;在方法、属性和参数装饰器中,装饰器从最远离方法、属性或参数定义的地方开始向内执行。例如:

function decorator1(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('Decorator 1');
    return descriptor;
}

function decorator2(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log('Decorator 2');
    return descriptor;
}

class MyClass {
    @decorator1
    @decorator2
    myMethod() {}
}

在上述代码中,先输出“Decorator 2”,然后输出“Decorator 1”。理解装饰器的执行顺序对于正确设计和使用装饰器非常重要。

装饰器与TypeScript类型系统

虽然装饰器可以对类、方法、属性和参数进行操作,但在TypeScript类型系统中,装饰器本身并不会改变类型信息。例如,一个装饰器为类添加了新的方法,但在类型检查时,这个新方法不会被自动识别,除非手动声明类型。

function addMethodDecorator(target: Function) {
    target.prototype.newMethod = function() {
        console.log('New method');
    };
}

@addMethodDecorator
class MyClass {
    constructor() {}
}

const instance = new MyClass();
(instance as any).newMethod(); 

在上述代码中,我们需要使用类型断言(instance as any)来调用新添加的方法,因为TypeScript类型系统并不知道这个新方法的存在。如果希望类型系统能够识别新添加的方法,可以通过声明接口或者类型别名来扩展类的类型。

装饰器在不同运行环境中的兼容性

TypeScript装饰器是基于ES7装饰器提案的实现,但不同的运行环境(如浏览器、Node.js等)对装饰器的支持程度可能不同。在使用装饰器时,需要考虑目标运行环境的兼容性。通常可以使用转译工具(如Babel)将包含装饰器的代码转译为目标环境支持的ES5或ES6代码。

结语

自定义TypeScript装饰器为我们提供了一种强大且灵活的方式来修改类和其成员的行为。通过遵循合理的设计原则,我们可以开发出可复用、易维护的装饰器。在实际项目中,装饰器在日志记录、权限验证、依赖注入等多个方面都有着广泛的应用。同时,我们也需要注意装饰器的执行顺序、与TypeScript类型系统的交互以及运行环境的兼容性等问题。通过充分理解和运用这些知识,我们能够更好地利用TypeScript装饰器来提升代码的质量和开发效率。