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

深入理解TypeScript装饰器的工作原理

2022-08-064.1k 阅读

什么是TypeScript装饰器

在TypeScript中,装饰器(Decorators)是一种特殊类型的声明,它能够在不改变原有代码逻辑的基础上,为类、方法、属性或参数添加额外的行为或元数据。装饰器本质上是一个函数,它以目标对象、属性名或参数列表作为参数,并返回一个新的对象或值,这个新的对象或值会替换原来的目标。

装饰器的语法

装饰器使用@符号作为前缀,紧跟在要装饰的目标之前。例如,要为一个类添加装饰器,可以这样写:

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

这里@myClassDecorator就是装饰器,它会在类定义时被应用。

装饰器的类型

TypeScript支持多种类型的装饰器,包括类装饰器、方法装饰器、属性装饰器和参数装饰器。每种装饰器都有其特定的应用场景和作用方式。

类装饰器

类装饰器的定义

类装饰器应用于类的定义,它接收一个参数,即被装饰类的构造函数。类装饰器可以用来修改类的定义,比如添加新的属性或方法,或者修改类的原型。

下面是一个简单的类装饰器示例,它为类添加了一个新的静态属性:

function addStaticProperty(target: Function) {
  target['newStaticProperty'] = 'This is a new static property';
}

@addStaticProperty
class MyClass {
  constructor() {}
}

console.log(MyClass.newStaticProperty); // 输出: This is a new static property

在这个例子中,addStaticProperty是一个类装饰器,它接收MyClass的构造函数作为参数,并为这个构造函数添加了一个新的静态属性newStaticProperty

类装饰器的返回值

类装饰器可以返回一个新的构造函数,这个新的构造函数会替代原来的类构造函数。这使得我们可以在不改变原有类定义的情况下,对类的实例化过程进行拦截和修改。

以下是一个类装饰器返回新构造函数的示例:

function logClass(target: Function) {
  return class extends target {
    constructor(...args: any[]) {
      console.log('Class instantiated with arguments:', args);
      super(...args);
    }
  };
}

@logClass
class MyClass {
  constructor(public value: number) {}
}

const myInstance = new MyClass(42); 
// 输出: Class instantiated with arguments: [42]

在这个例子中,logClass装饰器返回了一个新的类,这个新类继承自原来的MyClass。新类的构造函数在调用原构造函数之前,会先打印出实例化时传入的参数。

方法装饰器

方法装饰器的定义

方法装饰器应用于类的方法,它接收三个参数:

  1. target:对于静态方法,它是类的构造函数;对于实例方法,它是类的原型对象。
  2. propertyKey:被装饰方法的名称。
  3. descriptor:一个包含方法属性描述符的对象,如value(方法的实际实现)、writableenumerableconfigurable

下面是一个方法装饰器的示例,它用于记录方法的调用次数:

function logCallCount(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const originalMethod = descriptor.value;
  let callCount = 0;

  descriptor.value = function(...args: any[]) {
    callCount++;
    console.log(`${propertyKey} has been called ${callCount} times`);
    return originalMethod.apply(this, args);
  };

  return descriptor;
}

class MyClass {
  @logCallCount
  myMethod() {
    console.log('This is my method');
  }
}

const myObj = new MyClass();
myObj.myMethod(); 
// 输出: myMethod has been called 1 times
// 输出: This is my method
myObj.myMethod(); 
// 输出: myMethod has been called 2 times
// 输出: This is my method

在这个例子中,logCallCount装饰器保存了原方法的引用,并创建了一个新的函数来替换原方法。新函数在调用原方法之前,会增加调用次数并打印日志。

方法装饰器的返回值

方法装饰器必须返回一个PropertyDescriptor对象,或者undefined。如果返回undefined,则原有的属性描述符不会被修改。返回的PropertyDescriptor对象会用于定义被装饰方法的新特性。

例如,我们可以通过方法装饰器将一个可写的方法变为只读:

function makeReadOnly(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  descriptor.writable = false;
  return descriptor;
}

class MyClass {
  @makeReadOnly
  myMethod() {
    console.log('This is my method');
  }
}

const myObj = new MyClass();
myObj.myMethod = function() {
  console.log('New method implementation');
}; 
// 报错: 无法分配到'myMethod' ,因为它是只读属性。

在这个例子中,makeReadOnly装饰器将myMethodwritable属性设置为false,从而使该方法变为只读。

属性装饰器

属性装饰器的定义

属性装饰器应用于类的属性,它接收两个参数:

  1. target:对于静态属性,它是类的构造函数;对于实例属性,它是类的原型对象。
  2. propertyKey:被装饰属性的名称。

属性装饰器通常用于添加元数据到类的属性上,而不是直接修改属性的行为。

以下是一个属性装饰器的示例,它用于标记一个属性为必填:

function required(target: any, propertyKey: string) {
  let privateKey = `_${propertyKey}`;

  Object.defineProperty(target, propertyKey, {
    get() {
      return this[privateKey];
    },
    set(value: any) {
      if (value === undefined || value === null) {
        throw new Error(`${propertyKey} is required`);
      }
      this[privateKey] = value;
    },
    enumerable: true,
    configurable: true
  });
}

class MyClass {
  @required
  myProperty: string;

  constructor(value: string) {
    this.myProperty = value;
  }
}

const myObj = new MyClass('test'); 
// 正常
const badObj = new MyClass(null); 
// 报错: myProperty is required

在这个例子中,required装饰器使用Object.defineProperty来定义属性的存取器。在设置属性值时,它会检查值是否为undefinednull,如果是,则抛出错误。

属性装饰器的返回值

属性装饰器可以返回undefined,或者一个PropertyDescriptor对象。如果返回undefined,则属性的定义不会被修改。返回的PropertyDescriptor对象可以用于重新定义属性的特性,如valuewritableenumerableconfigurable

参数装饰器

参数装饰器的定义

参数装饰器应用于类方法的参数,它接收三个参数:

  1. target:对于静态方法,它是类的构造函数;对于实例方法,它是类的原型对象。
  2. propertyKey:被装饰方法的名称。
  3. parameterIndex:参数在方法参数列表中的索引位置。

参数装饰器通常用于收集关于方法参数的元数据,例如用于依赖注入或验证参数类型。

以下是一个参数装饰器的示例,它用于标记一个参数为服务依赖:

function injectService(target: any, propertyKey: string, parameterIndex: number) {
  if (!Reflect.hasMetadata('injectables', target)) {
    Reflect.defineMetadata('injectables', [], target);
  }
  const injectables = Reflect.getMetadata('injectables', target);
  injectables.push({ propertyKey, parameterIndex });
}

class MyService {
  // 服务的实现
}

class MyClass {
  myMethod(@injectService service: MyService) {
    console.log('Using service:', service);
  }
}

在这个例子中,injectService装饰器使用Reflect API来存储关于被装饰参数的元数据。Reflect.defineMetadata用于定义元数据,Reflect.getMetadata用于获取元数据。这些元数据可以在后续用于依赖注入。

参数装饰器的返回值

参数装饰器必须返回undefined,因为它不能直接修改参数的行为或值。它主要用于添加元数据,以便在运行时进行进一步的处理。

装饰器工厂

什么是装饰器工厂

装饰器工厂是一个函数,它返回一个装饰器。这种模式允许我们在创建装饰器时传递参数,从而实现更灵活的装饰器逻辑。

装饰器工厂的示例

下面是一个装饰器工厂的示例,它用于为方法添加日志前缀:

function logWithPrefix(prefix: string) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
      console.log(`${prefix} ${propertyKey} called with arguments:`, args);
      return originalMethod.apply(this, args);
    };
    return descriptor;
  };
}

class MyClass {
  @logWithPrefix('DEBUG')
  myMethod() {
    console.log('This is my method');
  }
}

const myObj = new MyClass();
myObj.myMethod(); 
// 输出: DEBUG myMethod called with arguments: []
// 输出: This is my method

在这个例子中,logWithPrefix是一个装饰器工厂,它接收一个prefix参数,并返回一个方法装饰器。这个返回的装饰器会在方法调用时打印带有前缀的日志。

装饰器执行顺序

类装饰器的执行顺序

当一个类有多个装饰器时,它们的执行顺序是从最靠近类定义的装饰器开始,向外依次执行。例如:

function decorator1(target: Function) {
  console.log('Decorator 1');
  return target;
}

function decorator2(target: Function) {
  console.log('Decorator 2');
  return target;
}

@decorator1
@decorator2
class MyClass {
  constructor() {}
}
// 输出: Decorator 2
// 输出: Decorator 1

在这个例子中,@decorator2先执行,然后是@decorator1

方法、属性和参数装饰器的执行顺序

对于方法、属性和参数装饰器,它们的执行顺序如下:

  1. 属性装饰器:在类定义时,按照属性声明的顺序执行。
  2. 方法装饰器:在类定义时,按照方法声明的顺序执行。
  3. 参数装饰器:在方法装饰器之前执行,按照参数在方法参数列表中的顺序从左到右执行。

例如:

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

function methodDecorator(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  console.log(`Method decorator for ${propertyKey}`);
  return descriptor;
}

function parameterDecorator(target: any, propertyKey: string, parameterIndex: number) {
  console.log(`Parameter decorator for ${propertyKey} at index ${parameterIndex}`);
}

class MyClass {
  @propertyDecorator
  myProperty: string;

  @methodDecorator
  myMethod(@parameterDecorator param: string) {
    console.log('Method executed');
  }
}
// 输出: Property decorator for myProperty
// 输出: Parameter decorator for myMethod at index 0
// 输出: Method decorator for myMethod

在这个例子中,首先执行属性装饰器,然后是参数装饰器,最后是方法装饰器。

装饰器与元数据

元数据的概念

元数据是关于数据的数据,在TypeScript装饰器中,元数据通常用于存储关于类、方法、属性或参数的额外信息。这些信息可以在运行时通过Reflect API进行读取和使用。

使用装饰器添加元数据

通过装饰器,我们可以方便地为目标添加元数据。例如,我们可以为一个类添加一个description元数据:

function addDescription(description: string) {
  return function(target: Function) {
    Reflect.defineMetadata('description', description, target);
  };
}

@addDescription('This is a sample class')
class MyClass {
  constructor() {}
}

const description = Reflect.getMetadata('description', MyClass);
console.log(description); 
// 输出: This is a sample class

在这个例子中,addDescription装饰器工厂使用Reflect.defineMetadataMyClass添加了一个description元数据。然后我们使用Reflect.getMetadata来获取这个元数据。

元数据在实际应用中的作用

元数据在很多场景下都非常有用,比如在依赖注入框架中,元数据可以用于标记哪些类是服务,哪些参数需要注入服务。在验证框架中,元数据可以用于标记属性的验证规则。通过装饰器添加元数据,使得代码的可维护性和扩展性大大提高。

装饰器的实际应用场景

日志记录

如前面的例子所示,装饰器可以方便地为方法添加日志记录功能。通过方法装饰器,我们可以在方法调用前后记录日志,包括方法名、参数和返回值等信息,这对于调试和监控应用程序非常有帮助。

权限控制

在Web应用中,我们可以使用装饰器来实现权限控制。例如,通过方法装饰器检查当前用户是否具有访问某个方法的权限,如果没有权限,则抛出异常或返回错误信息。

function requirePermission(permission: string) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    const originalMethod = descriptor.value;
    descriptor.value = function(...args: any[]) {
      // 模拟权限检查
      const hasPermission = checkPermission(permission);
      if (!hasPermission) {
        throw new Error('Permission denied');
      }
      return originalMethod.apply(this, args);
    };
    return descriptor;
  };
}

function checkPermission(permission: string): boolean {
  // 实际的权限检查逻辑
  return true;
}

class MyClass {
  @requirePermission('admin:access')
  adminMethod() {
    console.log('This is an admin method');
  }
}

const myObj = new MyClass();
myObj.adminMethod(); 
// 如果权限检查通过,输出: This is an admin method
// 如果权限检查不通过,抛出错误: Permission denied

在这个例子中,requirePermission装饰器工厂用于检查用户是否具有指定的权限。如果用户没有权限,调用被装饰的方法时会抛出错误。

依赖注入

装饰器在依赖注入框架中也有广泛应用。通过参数装饰器,我们可以标记哪些参数需要注入依赖。例如:

function inject(target: any, propertyKey: string, parameterIndex: number) {
  if (!Reflect.hasMetadata('injectables', target)) {
    Reflect.defineMetadata('injectables', [], target);
  }
  const injectables = Reflect.getMetadata('injectables', target);
  injectables.push({ propertyKey, parameterIndex });
}

class MyService {
  // 服务的实现
}

class MyClass {
  myMethod(@inject service: MyService) {
    console.log('Using service:', service);
  }
}

在这个例子中,inject装饰器用于标记myMethodservice参数需要注入依赖。在运行时,依赖注入框架可以根据这些元数据来解析和注入所需的服务。

装饰器的兼容性与限制

兼容性

TypeScript装饰器目前处于实验性阶段,不同的JavaScript运行环境对其支持程度有所不同。在使用装饰器时,需要确保目标运行环境支持相关的特性,或者通过转译工具(如Babel)将使用了装饰器的代码转换为目标环境支持的代码。

限制

  1. 只能用于类及其成员:装饰器只能应用于类、类的方法、属性和参数,不能应用于函数、变量等其他JavaScript元素。
  2. 执行顺序可能导致问题:如果装饰器之间存在依赖关系,不正确的执行顺序可能会导致错误。例如,如果一个装饰器依赖于另一个装饰器添加的元数据,而元数据添加的装饰器后执行,就会出现问题。
  3. 复杂逻辑可能导致代码可读性下降:虽然装饰器可以为代码添加强大的功能,但如果过度使用或在装饰器中编写复杂的逻辑,可能会导致代码可读性和可维护性下降。

通过深入理解TypeScript装饰器的工作原理,我们可以在开发中更加灵活地运用这一特性,为代码添加各种有用的功能,同时也需要注意其兼容性和限制,以确保代码的质量和可维护性。