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

TypeScript装饰器详解:从类到方法的全面覆盖

2024-10-103.4k 阅读

一、TypeScript 装饰器基础

在深入探讨 TypeScript 装饰器从类到方法的各种应用之前,我们先来了解一下装饰器的基本概念和语法。

装饰器是一种特殊类型的声明,它能够被附加到类声明、方法、属性或参数上。它们使用 @expression 的形式,其中 expression 是一个在装饰器应用时会被求值的表达式。这个表达式的返回值必须是一个函数,该函数会在运行时被调用,并且被赋予装饰器应用的相关元数据。

1.1 装饰器工厂函数

要创建一个装饰器,我们通常会定义一个装饰器工厂函数。这个函数接受一些可选的参数,并返回一个实际的装饰器函数。例如:

// 装饰器工厂函数
function Logger(logString: string) {
    return function (target: any) {
        console.log(logString);
        console.log(target);
    };
}

// 使用装饰器
@Logger('Logging Class...')
class MyClass { }

在上述代码中,Logger 是一个装饰器工厂函数,它接受一个字符串参数 logString。返回的内部函数就是实际的装饰器,当装饰器应用到 MyClass 类上时,会打印出 logString 和类的构造函数。

1.2 类装饰器

类装饰器应用于类的定义。它的参数是类的构造函数。类装饰器可以用来修改类的定义,例如添加新的属性或方法,或者修改类的行为。

function WithTemplate(template: string, hookId: string) {
    return function <T extends { new (...args: any[]): { name: string } }>(originalConstructor: T) {
        return class extends originalConstructor {
            constructor(..._: any[]) {
                super();
                const hookEl = document.getElementById(hookId);
                if (hookEl) {
                    hookEl.innerHTML = template;
                    hookEl.querySelector('h1')!.textContent = this.name;
                }
            }
        };
    };
}

@WithTemplate('<h1>My Object</h1>', 'app')
class Person {
    name = 'Max';
    constructor() {
        console.log('Creating person object...');
    }
}

const pers = new Person();

这里的 WithTemplate 装饰器接受一个模板字符串和一个钩子元素的 ID。它返回一个装饰器函数,这个函数会创建一个新的类,该类继承自原始类,并在构造函数中使用模板字符串更新指定钩子元素的内容,并将原始类实例的 name 属性值设置到模板中的 h1 标签中。

二、方法装饰器

方法装饰器应用于类的方法声明。它接受三个参数:

  1. 目标对象:对于静态成员来说是类的构造函数,对于实例成员来说是类的原型对象。
  2. 属性名:被装饰方法的名称。
  3. 属性描述符:描述该方法的属性描述符对象。

2.1 简单的方法装饰器示例

function Log(target: any, propertyName: string, descriptor: PropertyDescriptor) {
    console.log('LOGGING METHOD');
    console.log(target);
    console.log(propertyName);
    console.log(descriptor);
    return descriptor;
}

class Product {
    constructor(public title: string, private price: number) { }

    @Log
    getPriceWithTax(tax: number) {
        return this.price * (1 + tax);
    }
}

在上述代码中,Log 装饰器简单地打印出目标对象、方法名和属性描述符。属性描述符包含了方法的一些特性,如 value(方法的实际函数)、writable(是否可写)、enumerable(是否可枚举)和 configurable(是否可配置)。

2.2 修改方法行为的装饰器

我们可以通过修改属性描述符来改变方法的行为。例如,我们可以创建一个装饰器来缓存方法的返回值,避免重复计算。

function Cached(target: any, propertyName: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    let cache;
    descriptor.value = function (...args: any[]) {
        if (!cache) {
            cache = originalMethod.apply(this, args);
        }
        return cache;
    };
    return descriptor;
}

class ExpensiveCalculation {
    constructor(private value: number) { }

    @Cached
    calculate() {
        console.log('Performing expensive calculation...');
        return this.value * this.value * this.value;
    }
}

const calc = new ExpensiveCalculation(5);
console.log(calc.calculate());
console.log(calc.calculate());

在这个例子中,Cached 装饰器保存了 calculate 方法的原始实现,并创建了一个新的函数。新函数在第一次调用时执行原始方法并缓存结果,后续调用直接返回缓存的值,从而避免了重复的昂贵计算。

2.3 异步方法装饰器

对于异步方法,我们同样可以使用装饰器来处理。例如,我们可以创建一个装饰器来捕获异步方法中的错误,并进行统一的错误处理。

function CatchError(target: any, propertyName: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = async function (...args: any[]) {
        try {
            return await originalMethod.apply(this, args);
        } catch (error) {
            console.error('Error in async method:', error);
        }
    };
    return descriptor;
}

class AsyncTask {
    @CatchError
    async performTask() {
        throw new Error('Task failed');
    }
}

const task = new AsyncTask();
task.performTask();

在这个例子中,CatchError 装饰器将异步方法包装在 try - catch 块中。如果异步方法抛出错误,装饰器会捕获并在控制台打印错误信息,从而提供了一种统一的异步错误处理机制。

三、属性装饰器

属性装饰器应用于类的属性声明。它接受两个参数:

  1. 目标对象:对于静态成员来说是类的构造函数,对于实例成员来说是类的原型对象。
  2. 属性名:被装饰属性的名称。

3.1 简单的属性装饰器示例

function LogProperty(target: any, propertyName: string) {
    let value;
    const getter = function () {
        console.log(`Getting property ${propertyName}`);
        return value;
    };
    const setter = function (newValue: any) {
        console.log(`Setting property ${propertyName} to ${newValue}`);
        value = newValue;
    };
    if (delete target[propertyName]) {
        Object.defineProperty(target, propertyName, {
            get: getter,
            set: setter,
            enumerable: true,
            configurable: true
        });
    }
}

class User {
    @LogProperty
    username: string;

    constructor(username: string) {
        this.username = username;
    }
}

const user = new User('John');
console.log(user.username);
user.username = 'Jane';

在上述代码中,LogProperty 装饰器创建了一个自定义的 getter 和 setter 函数。当获取或设置属性值时,会打印相应的日志信息。通过 Object.defineProperty 方法,我们将这些自定义的 getter 和 setter 应用到目标属性上,从而实现了对属性访问的拦截和日志记录。

3.2 验证属性值的装饰器

我们可以创建一个属性装饰器来验证属性值的合法性。例如,我们要求一个属性必须是正数。

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

class Point {
    @PositiveNumber
    x: number;

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

const point = new Point(5);
console.log(point.x);
try {
    point.x = -1;
} catch (error) {
    console.error(error.message);
}

在这个例子中,PositiveNumber 装饰器在设置属性值时会检查值是否为正数。如果不是正数,会抛出一个错误,从而保证了属性值的合法性。

四、参数装饰器

参数装饰器应用于类方法的参数声明。它接受三个参数:

  1. 目标对象:对于静态成员来说是类的构造函数,对于实例成员来说是类的原型对象。
  2. 方法名:被装饰方法的名称。
  3. 参数在函数参数列表中的索引:参数在方法参数列表中的位置索引。

4.1 记录参数值的装饰器

function LogParam(target: any, methodName: string, paramIndex: number) {
    console.log(`Logging parameter for method ${methodName} at index ${paramIndex}`);
}

class Calculator {
    add(@LogParam a: number, @LogParam b: number) {
        return a + b;
    }
}

const calculator = new Calculator();
calculator.add(2, 3);

在上述代码中,LogParam 装饰器简单地打印出被装饰参数所属的方法名和参数在参数列表中的索引。这在调试和记录方法调用时的参数信息非常有用。

4.2 参数验证装饰器

我们可以创建一个参数装饰器来验证方法参数的合法性。例如,我们要求一个方法的参数必须是字符串类型。

function StringParam(target: any, methodName: string, paramIndex: number) {
    return function (target: any, ...args: any[]) {
        if (typeof args[paramIndex]!=='string') {
            throw new Error(`Parameter at index ${paramIndex} for method ${methodName} must be a string`);
        }
        return target.apply(this, args);
    };
}

class Greeting {
    greet(@StringParam message: string) {
        console.log(`Hello, ${message}!`);
    }
}

const greeting = new Greeting();
greeting.greet('World');
try {
    greeting.greet(123);
} catch (error) {
    console.error(error.message);
}

在这个例子中,StringParam 装饰器返回一个新的函数,该函数在调用原始方法之前会检查指定索引位置的参数是否为字符串类型。如果不是字符串类型,会抛出一个错误,从而保证了方法参数的合法性。

五、装饰器组合与应用场景

5.1 装饰器组合

在实际应用中,我们常常需要将多个装饰器应用到同一个目标上。装饰器的应用顺序是从下到上,从左到右。例如:

function First() {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log('First decorator applied');
        return descriptor;
    };
}

function Second() {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        console.log('Second decorator applied');
        return descriptor;
    };
}

class MyClass {
    @First()
    @Second()
    myMethod() { }
}

在上述代码中,Second 装饰器会先被应用,然后是 First 装饰器。因此,控制台会先打印 Second decorator applied,然后是 First decorator applied

5.2 应用场景

  1. 日志记录:通过类、方法、属性和参数装饰器,我们可以在不同层面记录日志信息,帮助调试和监控应用程序的运行状态。例如,在方法装饰器中记录方法的调用时间、参数和返回值。
  2. 权限控制:可以在方法装饰器中检查用户的权限,决定是否允许执行该方法。例如,只有管理员权限的用户才能调用某些特定的方法。
  3. 数据验证:属性和参数装饰器可以用于验证数据的合法性。确保属性值和方法参数符合特定的规则,提高应用程序的稳定性和安全性。
  4. 缓存机制:通过方法装饰器实现缓存,避免重复执行昂贵的计算,提高应用程序的性能。

六、装饰器在实际项目中的注意事项

  1. 兼容性:虽然 TypeScript 支持装饰器,但它们目前仍处于实验性阶段,不同的运行环境(如浏览器、Node.js)可能对装饰器的支持有所差异。在实际项目中,需要考虑使用转译工具(如 Babel)来确保装饰器在目标环境中能够正常工作。
  2. 性能影响:装饰器本质上是函数调用,过多的装饰器或复杂的装饰器逻辑可能会对性能产生一定的影响。在使用装饰器时,需要权衡功能需求和性能开销。
  3. 代码可读性:虽然装饰器可以使代码更加简洁和优雅,但过多或复杂的装饰器组合可能会降低代码的可读性。在编写装饰器时,应该遵循清晰的命名规范和注释,以便其他开发人员能够理解装饰器的功能和应用场景。

在实际项目中,合理地使用 TypeScript 装饰器可以极大地提高代码的可维护性、复用性和功能性。通过深入理解从类到方法、属性和参数的各种装饰器应用,开发人员可以更好地利用这一强大的特性来构建高质量的前端应用程序。