javascript中装饰器(Decorator)的使用

280 阅读3分钟

最近在开发一个DDS shapes demo的日志记录功能,是一个比较简单的需求。在同事的提醒下,发现使用装饰器(Decorator)可以比较优雅的实现此功能,在此之前对于装饰器也没有太多的了解,因此特意记录一些在学习使用中了解到的知识点。

装饰器是什么

装饰器是一个在代码运行时动态添加功能的方式,它可以用来修改类或函数的行为,在其他的语言中比较常见,ECMAScript目前处于stage3 的阶段,typescript中也有对应的实现。但目前装饰器还没有完全定案,各版差异可以参看:

www.zhihu.com/question/52…

它通常用于:

  1. 扩展已有的类或函数功能;
  2. 修改类或函数的属性;
  3. 将类或函数转化为不同的形式,例如,将类转化为单例模式。

装饰器使用 @expression 的形式使用,expression 是一个函数,会在运行时被调用,被装饰的数据会作为参数传入到这个函数中。

示例

下面是一个示例:

function addLogFunction(cls) {
  cls.prototype.log = function(msg) {
    console.log(`[${new Date().toISOString()}] ${msg}`);
  };
  return cls;
}

@addLogFunction
class MyClass {
  constructor() {}
}

const myObj = new MyClass();
myObj.log('hello');

这是一个简单的日志记录功能,通过扩展类的原型增加了类的功能,可以在不侵入函数内部结构的情况扩展函数的功能。除了加到类或者函数上,他还可以加在类的属性上。

但是上面装饰器其实有一种缺点,就是除了传入要装饰的数据之外,装饰器本身的功能不能通过传参去自定义,比如我们有一个接口管理类:

export class ShapesDemoDDSManger implements DDSManager {
  public participantId: number;
  private domainId = 0;
  private shapeDataType: TopicDataType = 'Shape';
  ...
  
  // 创建Publisher
  public async createPublisher(
    data: PubSubDataModel,
    moveSpeed: number,
    rotateSpeed: number,
    fillKind: number,
  ): Promise<void> {
    data.participant = this.participantId;
    const pubId = await this.server.createPublisher(data);
    const publisher = new ShapesDemoPublisher();
    publisher.fillKind = fillKind;
    publisher.rotateSpeed = rotateSpeed;
    publisher.moveSpeed = moveSpeed;
    publisher.config = data;
    publisher.id = pubId;
    this.shapesPublishers.push(publisher);
    this.onPublisherAddEmitter.fire(publisher);
  }
  
  // 暂停所有Publisher
  pauseAllPub(): void {
    this.publishers.forEach((publisher) => {
      this.privateTogglePubState(publisher.id, false);
    });
  }
  
}

里面有很多很多的接口方法,但是每一个方法的功能并不一致,我们如果只有一个addLogFunction的装饰器方法,没法知晓这个函数究竟是干什么的。因此,想要通过传参自定义装饰器的功能,我们可以使用装饰器工厂。

export class ShapesDemoDDSManger implements DDSManager {
  public participantId: number;
  private domainId = 0;
  private shapeDataType: TopicDataType = 'Shape';
  ...
  
  // 创建Publisher
  @log("创建Publisher")
  public async createPublisher(
    data: PubSubDataModel,
    moveSpeed: number,
    rotateSpeed: number,
    fillKind: number,
  ): Promise<void> {
    data.participant = this.participantId;
    const pubId = await this.server.createPublisher(data);
    const publisher = new ShapesDemoPublisher();
    publisher.fillKind = fillKind;
    publisher.rotateSpeed = rotateSpeed;
    publisher.moveSpeed = moveSpeed;
    publisher.config = data;
    publisher.id = pubId;
    this.shapesPublishers.push(publisher);
    this.onPublisherAddEmitter.fire(publisher);
  }
  
  // 暂停所有Publisher
  @log("暂停所有Publisher")
  pauseAllPub(): void {
    this.publishers.forEach((publisher) => {
      this.privateTogglePubState(publisher.id, false);
    });
  }
  
}
export const log = (methodDesc: string, needArgs?: string[] | number[]) => {
  return (target: any, name: string, descriptor: PropertyDescriptor) => {
    const original = descriptor.value;
    if (typeof original === 'function') {
      descriptor.value = function (...args: any[]) {
        const argsRes: string[] = [];
        if (needArgs) {
          needArgs.forEach((arg) => {
            const res = findKeyValue(args, arg);
            argsRes.push(`${arg}: ${res}`);
          });
        }

        const log: logContent = {
          time: new Date().toLocaleString(),
          desc: methodDesc,
          args: argsRes,
        };

        eventBus.emit(`${LOG_EVENT}`, log);

        try {
          const result = original.apply(this, args);
          return result;
        } catch (e) {
          console.log(`Error from ${name}: ${e}`);
          throw e;
        }
      };
    }
    return descriptor;
  };
};

这样,饰器工厂通过 @log(methodDesc) 形式使用,装饰器工厂中的 log会返回一个装饰器函数methodDesc是用户想自定义传入的参数(也可以增加其他更多的参数)。

装饰器分类

类装饰器

类装饰器在类声明之前被声明(紧靠着类声明),它应用于类构造函数,可以用来监视,修改或替换类定义。

function decorator(target: any, name: string, descriptor: PropertyDescriptor) {
  console.log(target);
}

@decorator
class Animal { 
    name = 'cat';
}

其中的三个参数分别是

  1. target:被装饰的类的构造函数,在这里就是指Animal的构造函数;
  2. name:类的名称,即"Animal";
  3. descriptor:类的描述对象

方法/属性装饰器

方法装饰器修饰类的方法或属性,扩展其功能或修改其属性。

function readonly(target, name, descriptor){
  descriptor.writable = false
  return descriptor;
}

function log(target, name, descriptor){
   const originalMethod = descriptor.value;
  descriptor.value = function (...args) {
    console.log(`Function ${name} called with ${args}`);
    const result = originalMethod.apply(this, args);
    return result;
  };
  return descriptor
}

class Animal {
  @readonly
  name() { return 'PeiQi' }
  
  @log
  eat() { console.log("ice cream") }
}

其中的三个参数分别是

  1. target:对于静态成员来说是类的构造函数,对于实例成员是类的原型对象
  2. name:方法/属性的名称,即"name"和"eat";
  3. descriptor:类的描述对象

参数装饰器

参数装饰器用来修饰函数的参数,这个我们在theia的源码中经常会出现。

@injectable()
export class ShapesDemoFrontendContribution
  extends MultiAbstractViewContribution<ShapesDemoWidget>
  implements FrontendApplicationContribution, CommandContribution, MenuContribution
{
  constructor(@inject(ThemeService) protected readonly themeService: ThemeService) {
    super({
      widgetId: ShapesDemoWidget.ID,
      widgetName: ShapesDemoWidget.LABEL,
      defaultWidgetOptions: {area: 'main'},
      toggleCommandId: ShapesDemoCommand.id,
    });
  }
}

其中的三个参数分别是

  1. target:对于静态成员来说是类的构造函数,对于实例成员是类的原型对象。
  2. name:成员的名字。
  3. index:参数在函数参数列表中的索引。

参数装饰器只能用来监视一个方法的参数是否被传入,返回值会被忽略。

遇到的问题

日志记录功能是有了,但是现在又发现一个新的问题,我们的shapes demo可以同时打开多个tabs,每一个tab都是相互独立的,即每一个tab的shapes demo都生成了一个dds-manager的实例化对象,我们的日志记录当然也要对每一个shapes demo进行区分,因此,在日志记录过程中我们需要拿到ShapesDemoDDSManger实例的uid:

private readonly uid = UUID.uuid4();

根据这个uid我们展示不同的日志集,但是在用装饰器时,却犯了难,因为拿不到这个uid。

为啥呢?

上文中我们有提到,装饰器函数的第一个函数通常是类的构造函数或原型对象。是因为装饰器的本意是要“装饰”类的实例,但是在编译这个类时,实例还没生成,所以只能去装饰原型;而这个uid明显就是实例化后才生成的,没法传入装饰器函数。

最后,我又只能退回去,不使用装饰器的方式来进行日志记录,在dds-manager中增加了一个私有方法,还是侵入了函数内部来记录:

export class ShapesDemoDDSManger implements DDSManager {
  // 记录日志功能
  private logFormatter(methodDesc: string, needArgs?: Array<logArgs>) {
    const argsRes: string[] = [];
    if (needArgs) {
      needArgs.forEach((arg) => {
        argsRes.push(`${arg.key}: ${arg.value}`);
      });
    }

    const log: logContent = {
      time: new Date().toLocaleString(),
      desc: methodDesc,
      args: argsRes,
    };

    eventBus.emit(`${LOG_EVENT}-${this.id}`, log);
  }
  
  // 调整Subscriber状态
  toggleSubState(id: number, canSub: boolean): void {
    // 日志记录
    this.logFormatter('调整Subscriber状态', [
      {key: 'topicName', value: this.getSubById(id)?.topicName || ''},
      {key: 'status', value: canSub ? '恢复' : '暂停'},
    ]);

    if (canSub) {
      this.server.resumeSubscribeSubData(id);
    } else {
      this.server.stopSubscribeSubData(id);
    }
  }
  
}

资料参考:一文搞懂 ts 装饰器 - 掘金

更多的装饰器运用,可以参看:JS装饰器,看这一篇就够了 - 掘金