彻底搞懂装饰器

713 阅读7分钟

前言

每次加班到深夜都会倍感疲劳,但是屋漏偏逢连夜雨,测试环境没测出来的问题在线上被测试发现了,当然这个主人公不是我,是我的同事,本着互相帮助的原则我去看了看他的问题,哦,原来是忘记做防抖节流了;但是问题的关键在于很多地方都要加上这个防抖节流,还不知道会不会影响到其他的逻辑;于是我建议他使用装饰器,可是他连怎么用都不知道,他没有采用我的建议,我只能长叹一口气;

为什么防抖节流最适合用装饰器呢?因为它可以与原函数完全解耦;接下来就由我来介绍一下装饰器,下次再不允许你们不会用装饰器了哦!

开启装饰器

  1. 第一步:安装typescriptnpm i -g typescript
  2. 第二步:初始化tsc --init,得到tsconfig.json文件
  3. 第三步:开启装饰器, "experimentalDecorators": true,
  4. 第四步:开始愉快地使用装饰器......

装饰器的分类

装饰器分为以下四类:

  1. 类的装饰器
  2. 方法装饰器
  3. 属性装饰器
  4. 参数装饰器
  5. 访问器装饰器 下面来详细分析一下这四类装饰器:

类的装饰器

先定义一个类WebApp,然后decoratorClass这个方法去装饰器这个类,代码如下:

@decoratorClass
class WebApp{

}

function decoratorClass(){

}

类的装饰器只能接收一个参数,就是这个类的构造函数,通过构造函数可以修改原型上的属性和方法:

function decoratorClass(target: Function) {
  target.prototype.name = "装饰器模式";
  target.prototype.getName = function () {
    return this.name;
  };
}

console.log(new WebApp()); // 存在name属性和getName方法

但是装饰器上修改的原型可以被类中定义所覆盖,因为装饰器函数是在类实例化之前就执行,即使不实例化它也会执行,比方说:

@decoratorClass
class WebApp{
    name = "我被覆盖了";
}

类的装饰器还有什么特异功能呢?这就需要我们回忆一下日常的开发过程中是不是有用过类的装饰器,比如说React的类组件、Vue的类组件,我们来举例回忆一下:

@withRouter
class HelloWorld extends Component{
    render(){
        return null;
    }
}

是不是满满的回忆,现在可能已经见不到这种React类组件了,但是这种写法可以回忆一下,其实就是代替了withRouter(HelloWorld)

让我们看一下上一个案例通过babel编译后的结果,有助于我们看清装饰器的本质:

var _class;

let WebApp = decoratorClass(_class = class WebApp {}) || _class;

function decoratorClass() {}

看到这个我们就懂了,总结一下类装饰器:如果装饰器函数有返回值,那么最终结果就是这个返回值,否则返回原来的类

方法装饰器

方法装饰器和Object.defineProperty的参数一模一样:target,key,descriptor;target指向实例对象,key表示方法名,descriptor表示属性描述符,这三个参数里面descriptor的作用最大;

首先通过descriptor.value能够获取到该方法的函数体,其次可以对函数值进行修改,比方说做一个节流防抖;给你一个WebApp类以及onChange方法,现在需要对onChange方法进行防抖节流:

class WebApp{
    onChange(){
        console.log("节流防抖!")
    }
}
// 测试用例
const webApp = new WebApp();
setInterval(()=>{
    webApp.onChange()
},10)

这是普通的写法:

  onChange(){
    if(this.timer){
      return;
    }

    this.timer = setTimeout(()=>{
      console.log("节流防抖");
      this.timer = null;
    },10000)
  }

但是这样每次都需要整个函数的逻辑都要修改掉,如果说直接把节流函数提取出来作为公共方法,这个时候经常会有同学搞不清楚是应该调用还是不调用,应该放在什么地方,导致最终节流函数没有体现出节流的作用,却留下了一个bug,为日后埋下一个雷;所以更好的方法是把节流函数与业务函数解耦,使用方法装饰器就能够办到这一点:

class WebApp {
  timer: any = null;

  @throttle(10000)
  onChange() {
    console.log("节流防抖");
  }
}

function throttle(interval: number) {
  return (target: any, propertyKey: PropertyKey, descriptor: PropertyDescriptor) => {
    const originMethod = descriptor.value;

    descriptor.value = function (...args: any[]) {
      if (!target.timer) {
        target.timer = setTimeout(() => {
          originMethod.apply(this, args);
          target.timer = null;
        }, interval);
      }
    };
  };
}

同学们,学废了了吗?

最后把这一段代码放到babel中编译之后,发现了一个真相,代码太长就不拷贝了大家有兴趣可以自行去编译一下试试:方法装饰器其实就是Object.defineProperty,target是构造函数的原型,key是装饰的方法名,descriptor就是装饰器的返回值

属性装饰器

属性装饰器接收两个参数,一个是实例对象,一个是key名,如下:

class WebApp {
    @decoratorProperty
    abc = "abc";
}

function decoratorProperty(target: WebApp, key: any) {
    console.log(target, key);
}

属性装饰器可以让一个属性对应两个值,比如说下面这个案例摘自TypeScript官网:

import "reflect-metadata";

const formatMetadataKey = Symbol("format");
function format(formatString: string) {
  return Reflect.metadata(formatMetadataKey, formatString);
}

function getFormat(target: any, propertyKey: string) {
  return Reflect.getMetadata(formatMetadataKey, target, propertyKey);
}
class WebApp{
  @format("yyyy-MM-dd hh:mm:ss")
  day!:number;
  constructor(day:number){
    this.day = day;
  }

  getDay(){
    const day = getFormat(this, "day");
    console.log(day)
  }
}
const webApp = new WebApp(1);
console.log(webApp.day);
webApp.getDay();

day这个key对应着实例上的day属性,也对应元数据中的一个值,即一个key对应着两个值,reflect-metadata这个库在框架源码中经常会被引用到,这个库是用来操作元数据的,通常用来做类型校验,比如:

  1. 通过Reflect.getMetadata('design:type', target, key);获取属性的类型
  2. design:paramtypes获取函数参数类型,design:returntype获取函数返回值类型
  3. 实现控制反转和依赖注入

最后再来编译一把看看庐山真面目,一段代码总结一下,与方法装饰器不同的点就是descriptor是内置的:

Object.defineProperty(_class.prototype,key,{
      configurable: true,
      enumerable: true,
      writable: true,
      initializer: function () {
        return "abc";
      }
})

参数装饰器

参数装饰器和其他装饰器略有不同,最后一个参数是参数所在位置的索引:

class WebApp{
  constructor(@decoratorParams name){
    console.log("name:",name)
  }
}

function decoratorParams(target: WebApp, key: any, index: number) {
  console.log(target, key, index);
}

参数装饰器更多的是辅助其他装饰器,比如预先针对某个参数名设置了一些元数据,那么这个时候就可以在参数装饰器中通过target和key参数来获取到;

最后通过编译之后的结果来看一看参数装饰器的本质是什么?由于参数装饰器在babel平台上并不会被编译,我们换到typescript playground中编译,得到的代码如下:

截屏2023-08-16 22.31.08.png

最终还是落到Object.defineProperty上面去了,只不过这个参数位置是由编译器编译出来的,这个过程也比较简单,直接转化成ast,然后读取函数参数就可以拿到参数的位置,感兴趣的可以尝试着用ast解析出参数位置。

访问器装饰器

访问器装饰器与方法装饰器类似,参数与Object.defineProperty参数一致,这里就不过多介绍了

元数据

元数据就是上文提到的metadata,要使用这个特性需要引入一个库:reflect-metadata

它的主要功能就是无侵入地在类上面设置值,因为在类上直接访问是访问不到的,必须要通过Reflect.getMetadata才能获取到值

控制反转和依赖注入

利用装饰器可以实现控制反转和依赖注入,那么问题来了,什么是控制反转和依赖注入呢?控制反转和依赖注入是SOLID中的一个设计原则,它的原理就是增加了一个容器层,将类与类之间解耦,类似于发布订阅模式,比如说B类需要用到A类的name属性,一般会这么写:

class A{
    name:string;
    constructor(){
        this.name = 'XiaoMing';
    }
}
class B{
    name:string;
    constructor(){
        this.name = new A().name;
    }
}

当A类需要传入name参数,来初始化name属性,此时B类就会报错,也必须要修改,这样的话A类与B类是强耦合:

class App{
    name:string;
    constructor(name){
        this.name = name;
    }
}
class Bpp{
    name:string;
    constructor(name){
        this.name = new App(name).name;
    }
}

程序设计的一个基本原则就是松耦合,这就要借助一个容器,来存放所有实例化的对象,然后需要使用的时候就从容器中取就可以了;假如用一个Map去存储实例化对象,举个简单的例子:

class App{
    constructor(){
        this.name = "Li";
    }
}
const container = new Map();
container.set("App",new App())

class Bpp{
    app:App;
    constructor(){
        this.app = container.get("App");
    }
}

当然中间实例化的过程也可以抽象成一个类的装饰器,这里就不详细讲了,有兴趣的可以看一看inversify或者nest的具体实现

后记

以上就是关于装饰器的全部内容,不当之处大家可以指出来一起探讨探讨;