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

TypeScript属性装饰器:深入理解类属性的装饰机制

2022-09-206.7k 阅读

什么是 TypeScript 属性装饰器

在 TypeScript 中,属性装饰器是一种特殊类型的装饰器,它应用于类的属性声明。属性装饰器可以在运行时对类的属性进行元编程,即在不修改属性的定义逻辑的基础上,为属性添加额外的行为或元数据。属性装饰器表达式会在运行时当作函数调用,传入两个参数:

  1. 对于静态成员,是类的构造函数;对于实例成员,是类的原型对象:这个参数提供了关于属性所属类的信息,根据是静态还是实例成员而有所不同。
  2. 属性的名称:明确指出装饰器所应用的具体属性。

属性装饰器的基本语法

属性装饰器的语法非常直观,它紧跟在属性声明之前,使用 @ 符号开头。以下是一个简单的示例:

function propertyDecorator(target: any, propertyKey: string) {
    console.log(`Target: ${target}`);
    console.log(`Property Key: ${propertyKey}`);
}

class MyClass {
    @propertyDecorator
    myProperty: string;
}

在上述代码中,propertyDecorator 是一个属性装饰器函数。当 MyClass 类被定义时,propertyDecorator 函数会被调用,传入 MyClass.prototype(因为 myProperty 是实例属性)和属性名称 'myProperty' 作为参数。在实际应用中,装饰器函数可以执行更复杂的逻辑,比如修改属性的特性(如可枚举性、可写性等),或者添加元数据。

通过属性装饰器修改属性特性

属性装饰器一个常见的用途是修改属性的特性。JavaScript 的对象属性具有诸如 writable(可写)、enumerable(可枚举)和 configurable(可配置)等特性。我们可以使用属性装饰器来改变这些特性。以下是一个示例:

function readonly(target: any, propertyKey: string) {
    const descriptor: PropertyDescriptor = {
        writable: false
    };
    Object.defineProperty(target, propertyKey, descriptor);
}

class MyClass {
    @readonly
    myProperty: string = "Hello";
}

const myObj = new MyClass();
// 下面这行代码会在严格模式下抛出错误,因为 myProperty 现在是只读的
// myObj.myProperty = "World"; 

在上述代码中,readonly 装饰器通过 Object.defineProperty 方法将属性的 writable 特性设置为 false,从而使属性变为只读。当尝试修改 myProperty 时,如果在严格模式下就会抛出错误。

在属性装饰器中添加元数据

另一个重要的用途是为属性添加元数据。元数据是关于数据的数据,在编程中可以用来描述代码元素的额外信息。在 TypeScript 中,我们可以借助 Reflect 元数据 API 来添加和读取元数据。以下是一个示例:

import 'reflect-metadata';

const metadataKey = Symbol("description");

function addDescription(description: string) {
    return function (target: any, propertyKey: string) {
        Reflect.defineMetadata(metadataKey, description, target, propertyKey);
    };
}

class MyClass {
    @addDescription("This is a sample property")
    myProperty: string;
}

const myObj = new MyClass();
const description = Reflect.getMetadata(metadataKey, myObj, "myProperty");
console.log(description); 

在上述代码中,addDescription 是一个高阶函数,它返回一个属性装饰器。这个装饰器使用 Reflect.defineMetadata 方法为属性添加了一个描述元数据。之后,通过 Reflect.getMetadata 方法可以获取这个元数据。

静态属性上的属性装饰器

属性装饰器同样可以应用于静态属性。当装饰器应用于静态属性时,target 参数将是类的构造函数。以下是一个示例:

function staticPropertyDecorator(target: any, propertyKey: string) {
    console.log(`Target (constructor): ${target}`);
    console.log(`Property Key: ${propertyKey}`);
}

class MyClass {
    @staticPropertyDecorator
    static myStaticProperty: string;
}

在上述代码中,staticPropertyDecorator 装饰器应用于 MyClass 的静态属性 myStaticProperty。当 MyClass 被定义时,装饰器函数被调用,传入 MyClass 构造函数和属性名称 'myStaticProperty'

属性装饰器与类继承

在类继承的场景下,属性装饰器的行为值得深入探讨。当一个类继承自另一个带有属性装饰器的类时,装饰器会在子类中再次应用。以下是一个示例:

function logProperty(target: any, propertyKey: string) {
    let value = target[propertyKey];

    const getter = function () {
        console.log(`Getting ${propertyKey}: ${value}`);
        return value;
    };

    const setter = function (newValue) {
        console.log(`Setting ${propertyKey} to ${newValue}`);
        value = newValue;
    };

    if (delete target[propertyKey]) {
        Object.defineProperty(target, propertyKey, {
            get: getter,
            set: setter,
            enumerable: true,
            configurable: true
        });
    }
}

class BaseClass {
    @logProperty
    baseProperty: string = "Base Value";
}

class SubClass extends BaseClass {
    @logProperty
    subProperty: string = "Sub Value";
}

const subObj = new SubClass();
subObj.baseProperty; 
subObj.subProperty; 
subObj.baseProperty = "New Base Value"; 
subObj.subProperty = "New Sub Value"; 

在上述代码中,logProperty 装饰器为属性添加了日志记录功能。当 SubClass 继承自 BaseClass 时,baseProperty 的装饰器逻辑依然生效,并且 subProperty 也应用了相同的装饰器逻辑。

多个属性装饰器的应用

一个属性可以应用多个装饰器。装饰器的应用顺序是从最接近属性声明的装饰器开始,向外依次应用。以下是一个示例:

function decorator1(target: any, propertyKey: string) {
    console.log("Decorator 1 applied");
}

function decorator2(target: any, propertyKey: string) {
    console.log("Decorator 2 applied");
}

class MyClass {
    @decorator1
    @decorator2
    myProperty: string;
}

在上述代码中,decorator2 会先被应用,然后是 decorator1。输出结果会是:

Decorator 2 applied
Decorator 1 applied

实现复杂的属性装饰器逻辑

属性装饰器可以实现非常复杂的逻辑。例如,我们可以实现一个缓存装饰器,它会缓存属性的计算结果,避免重复计算。以下是一个示例:

function cache(target: any, propertyKey: string) {
    let cachedValue;
    const originalGetter = Object.getOwnPropertyDescriptor(target, propertyKey)?.get;

    if (typeof originalGetter === 'function') {
        Object.defineProperty(target, propertyKey, {
            get: function () {
                if (cachedValue === undefined) {
                    cachedValue = originalGetter.call(this);
                }
                return cachedValue;
            },
            enumerable: true,
            configurable: true
        });
    }
}

class ExpensiveCalculation {
    private data: number[] = Array.from({ length: 1000000 }, (_, i) => i + 1);

    @cache
    get sum(): number {
        return this.data.reduce((acc, val) => acc + val, 0);
    }
}

const calculation = new ExpensiveCalculation();
console.time("First calculation");
console.log(calculation.sum); 
console.timeEnd("First calculation");

console.time("Second calculation");
console.log(calculation.sum); 
console.timeEnd("Second calculation");

在上述代码中,cache 装饰器为 sum 属性添加了缓存功能。第一次计算 sum 时,会执行数组的累加操作。之后再次访问 sum 时,直接返回缓存的值,大大提高了性能。

与其他装饰器类型的协同工作

属性装饰器可以与类装饰器、方法装饰器和参数装饰器协同工作,构建更强大的元编程能力。例如,我们可以通过类装饰器为整个类设置一个命名空间,然后属性装饰器可以基于这个命名空间来添加特定的元数据。以下是一个示例:

function namespace(namespaceName: string) {
    return function (target: any) {
        Reflect.defineMetadata('namespace', namespaceName, target);
    };
}

function addMetadataToProperty(metadata: any) {
    return function (target: any, propertyKey: string) {
        const ns = Reflect.getMetadata('namespace', target.constructor);
        const fullMetadata = { namespace: ns, ...metadata };
        Reflect.defineMetadata('propertyMetadata', fullMetadata, target, propertyKey);
    };
}

@namespace('myNamespace')
class MyClass {
    @addMetadataToProperty({ role: 'dataProperty' })
    myProperty: string;
}

const myObj = new MyClass();
const propertyMetadata = Reflect.getMetadata('propertyMetadata', myObj, "myProperty");
console.log(propertyMetadata); 

在上述代码中,namespace 类装饰器为 MyClass 设置了命名空间。addMetadataToProperty 属性装饰器基于这个命名空间为 myProperty 属性添加了额外的元数据。

在实际项目中的应用场景

  1. 数据验证:在企业级应用开发中,数据的合法性至关重要。属性装饰器可以用于在属性赋值时进行数据验证。例如,在一个用户信息管理系统中,用户的年龄属性可以通过属性装饰器确保其为正整数。
function validatePositiveNumber(target: any, propertyKey: string) {
    let value;
    const descriptor: PropertyDescriptor = {
        get() {
            return value;
        },
        set(newValue) {
            if (typeof newValue === 'number' && newValue > 0) {
                value = newValue;
            } else {
                throw new Error('Value must be a positive number');
            }
        }
    };
    Object.defineProperty(target, propertyKey, descriptor);
}

class User {
    @validatePositiveNumber
    age: number;
}

const user = new User();
user.age = 25; 
// user.age = -5; // 会抛出错误
  1. 国际化与本地化:在多语言应用中,属性装饰器可以用于标记需要进行本地化处理的属性。通过这种方式,可以在运行时根据用户的语言设置自动获取相应的本地化字符串。
import 'reflect-metadata';

const i18nMetadataKey = Symbol("i18n");

function i18n(key: string) {
    return function (target: any, propertyKey: string) {
        Reflect.defineMetadata(i18nMetadataKey, key, target, propertyKey);
    };
}

class Message {
    @i18n('welcome_message')
    welcome: string;

    @i18n('goodbye_message')
    goodbye: string;
}

// 在运行时,根据用户语言设置获取相应的本地化字符串
function getLocalizedValue(instance: any, propertyKey: string, language: string) {
    const key = Reflect.getMetadata(i18nMetadataKey, instance, propertyKey);
    // 这里假设存在一个函数 getTranslation 用于获取本地化字符串
    return getTranslation(key, language);
}
  1. 访问控制:在权限管理系统中,属性装饰器可以用于控制对类属性的访问。例如,只有具有特定权限的用户才能读取或修改某些属性。
function requirePermission(permission: string) {
    return function (target: any, propertyKey: string) {
        let value;
        const descriptor: PropertyDescriptor = {
            get() {
                // 这里假设存在一个函数 checkPermission 用于检查权限
                if (checkPermission(permission)) {
                    return value;
                } else {
                    throw new Error('Permission denied');
                }
            },
            set(newValue) {
                if (checkPermission(permission)) {
                    value = newValue;
                } else {
                    throw new Error('Permission denied');
                }
            }
        };
        Object.defineProperty(target, propertyKey, descriptor);
    };
}

class SensitiveData {
    @requirePermission('admin')
    secretKey: string;
}

const data = new SensitiveData();
// 如果当前用户没有 'admin' 权限,以下操作会抛出错误
// data.secretKey = "123456"; 
// console.log(data.secretKey); 

注意事项与限制

  1. 兼容性:虽然属性装饰器在现代 JavaScript 环境(如支持 ES6 及以上的环境)中得到了广泛支持,但在一些旧的环境中可能无法正常工作。在使用属性装饰器时,需要考虑项目的目标运行环境,并可能需要进行适当的编译或 polyfill 处理。
  2. 性能影响:复杂的属性装饰器逻辑可能会对性能产生一定的影响。例如,频繁地读取和修改属性描述符,或者进行大量的元数据操作,都可能增加运行时的开销。在实现属性装饰器时,需要权衡功能与性能,尽量优化逻辑。
  3. 调试困难:由于属性装饰器的逻辑是在运行时执行,并且通常涉及到元编程,调试可能会变得比较困难。在开发过程中,合理地使用日志输出和调试工具(如 Chrome DevTools)可以帮助定位问题。
  4. 装饰器顺序:多个属性装饰器的应用顺序非常重要,因为它们可能会相互影响。在编写代码时,需要仔细考虑装饰器的顺序,确保逻辑的正确性。

总结

属性装饰器是 TypeScript 强大的元编程工具之一,它为类属性提供了丰富的扩展能力。通过属性装饰器,我们可以在不改变属性核心逻辑的前提下,实现诸如属性特性修改、元数据添加、缓存、数据验证等功能。在实际项目中,属性装饰器可以大大提高代码的可维护性和可扩展性,特别是在处理复杂业务逻辑和架构设计时。然而,使用属性装饰器也需要注意兼容性、性能、调试等方面的问题。通过合理地运用属性装饰器,并遵循最佳实践,我们可以构建出更加健壮和高效的前端应用程序。