最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 理解Typescript装饰器

    正文概述 掘金(挖掘机F)   2021-02-21   572

    装饰器模式简介

    装饰器模式是一种重要的设计模式,它能够以对客户透明的方式动态地给一个对象附加上更多的责任,为明晰它的概念,请看下面的例子:

    在这个例子中,对于使用床的人而言,“睡觉”这个动作没有发生改变,床还是那张床,只不过我们通过床垫、被絮、按摩机增加了额外的功能。在程序设计中,它们就可以分别以装饰器的形式进行设计,再通过组合使床拥有全部的装饰特征,程序中类之间的关系使用uml类图表示如下:

    理解Typescript装饰器

    前端领域同样存在装饰器,但是JavaScript的装饰器提案历经一波三折,目前仍处于Stage 2阶段,而且在语法和实现上经历了较大的改版,距离正式成为ECMA语言标准尚需时日。在TypeScript愈发流行的今天,它已推出了这个实验性功能,一些框架如angular、nestjs都已经大量使用了装饰器,下面我们一起探索一下它。

    起步

    新建一个node项目,并使用tsc工具生成tsconfig.json配置文件,笔者使用的tsc版本为4.0.3

    mkdir decorator-tour && cd decorator-tour && npm init -y && tsc --init
    

    为了使Typescript编译器支持装饰器,需要在tsconfig.json的compilerOptions选项中设置"experimentalDecorators": true

    // tsconfig.json
    {
      "compilerOptions": {
        ...
        "experimentalDecorators": true,
        ...
      }
    }
    

    新建index.ts文件,并给出最初的代码

    class Greeter {
      greeting: string;
      constructor(message: string) {
        this.greeting = message;
      }
      greet(name: string): string {
        console.log(`welcome, ${name}!`);
        return "Hello";
      }
    }
    
    const g = new Greeter('msg');
    
    g.greet('tom');
    

    执行tsc && node index.js后控制台将打印出welcome, tom!

    核心玩法

    装饰方法

    Greeter实例的greet方法表示“欢迎”之意,但是一般的“欢迎”应该配合上“微笑”,这里我们希望在调用greet之前再打印一行"smile"文本,那怎么操作呢?当然可以直接在greet方法里加入打印"smile"的代码,但这并不好,试想在Greeter类中可能将要实现其他的方法,比如guide、interpret等,它们都需要先打印"smile"然后执行功能代码,这种情况下Greeter类的很多方法都有相同的需求,就可以将打印"smile"这个功能提取成一个装饰器。

    function smile(
      target: any,
      propertyKey: string,
      descriptor: PropertyDescriptor
    ) {
      console.log('smile');
    }
    
    class Greeter {
      greeting: string;
      constructor(message: string) {
        this.greeting = message;
      }
      @smile
      greet(name: string): string {
        console.log(`welcome, ${name}!`);
        return "Hello";
      }
    }
    
    const g = new Greeter('msg');
    
    g.greet('tom');
    
    

    在上面的代码中,smile是一个装饰器,@smile语法规定了greet方法将使用smile装饰器,而装饰器smile本身其实是一个函数,它接收target(被装饰的对象),propertyKey(被装饰的属性)和descriptor(属性的描述)作为参数,本例中target表示Greeter的原型对象,即Greeter.prototype,propertyKey是"greet",而descriptor是Greeter.prototype.greet的属性描述对象,类似下面这样:

    {
      value: [Function],
      writable: true,
      enumerable: true,
      configurable: true
    }
    

    smile方法本身没有返回任何值,只是执行了简单的打印"smile"的功能,在执行过程中是怎么把装饰器和原函数串联起来的呢?我们不妨分析一下typescript经过tsc编译后的代码:

    'use strict'
    var __decorate =
      (this && this.__decorate) ||
      function (decorators, target, key, desc) {
        var c = arguments.length, // 本例中为4
          r = // 本例中为greet方法的属性描述对象
            c < 3
              ? target
              : desc === null
              ? (desc = Object.getOwnPropertyDescriptor(target, key))
              : desc,
          d
        if (typeof Reflect === 'object' && typeof Reflect.decorate === 'function')
          r = Reflect.decorate(decorators, target, key, desc)
        else
          for (var i = decorators.length - 1; i >= 0; i--)
            if ((d = decorators[i]))
              // 这一步执行了smile函数,然后由于smile函数返回undefined,故将原本的r赋值给r
              r = (c < 3 ? d(r) : c > 3 ? d(target, key, r) : d(target, key)) || r
              // 返回r
        return c > 3 && r && Object.defineProperty(target, key, r), r
      }
    function smile(target, propertyKey, descriptor) {
      console.log('smile')
    }
    var Greeter = (function () {
      function Greeter(message) {
        this.greeting = message
      }
      Greeter.prototype.greet = function (name) {
        console.log('welcome, ' + name + '!')
        return 'Hello'
      }
      // @smile装饰器被解析成这一行
      __decorate([smile], Greeter.prototype, 'greet', null)
      return Greeter
    })()
    var g = new Greeter('msg')
    g.greet('tom')
    

    在上面的代码中,我们发现@smile装饰器被解析成了__decorate([smile], Greeter.prototype, 'greet', null),__decorate方法先获取了greet方法的属性描述对象r,然后执行smile装饰器函数,最后又通过Object.defineProperty对greet方法进行了重定义,但是此处进行重定义时属性描述对象r并未发生变化,所以greet方法没有变化;如果smile函数返回一个属性描述对象,则原greet方法是可以被覆盖的,比如如下定义smile装饰器,则最终greet方法被覆盖,运行tsc && node index.js将得到hijacked method

    function smile(
      target: any,
      propertyKey: string,
      descriptor: PropertyDescriptor
    ) {
      return {
        value: function(name: string): string {
          console.log('hijacked method');
          return '';
        },
        writable: true,
        enumerable: true,
        configurable: true
      }
    }
    

    上面我们用到的装饰器语法是@smile的形式,实际上装饰器也是可以带参数的,比如现在需求发生了变化,需要根据不同场景确定"smile"的打印次数,greet方法执行的时候需要打印3次smile,打印的次数可以作为参数传递给smile装饰器灵活控制,如何实现呢?熟悉闭包的同学肯定已经想到了。

    function smile(times: number) {
      return function(
        target: any,
        propertyKey: string,
        descriptor: PropertyDescriptor
      ) {
        for (let i = 0; i < times; i++) {
          console.log('smile');
        }
      }
    }
    

    经过上面的改造,smile装饰器需接收1个参数,通过@smile(3)的形式使用装饰器即可在调用greet方法时打印3次"smile"。

    装饰参数

    不光方法可以被装饰,方法的参数同样也可以,写法如下:

    class Greeter {
      greeting: string;
      constructor(message: string) {
        this.greeting = message;
      }
      @smile(3)
      @checkParam
      greet(@startsWith('t') name: string): string {
        console.log(`welcome, ${name}!`);
        return "Hello";
      }
    }
    

    例子中的startsWith是一个参数装饰器,它限制greet方法的name参数必须以字符't'开头,需要checkParam装饰器配合工作,接下来先看下startsWith装饰器的实现:

    const startsWithKey = '__startswith';
    function startsWith(prefix: string) {
      return function(target: any, // Greeter.prototype
        propertyKey: string, // 'greet'
        paramsIndex: number // 参数的序号,本例中name参数序号为0
      ) {
        const startsWithConstraints = Reflect.getOwnMetadata(
          startsWithKey,
          target,
          propertyKey
        ) || {} as Record<number,string>;
        startsWithConstraints[paramsIndex] = prefix;
    
        Reflect.defineMetadata(
          startsWithKey,
          startsWithConstraints,
          target,
          propertyKey
        );
      }
    }
    

    上面的代码用到了Reflect-metadata,它是一个ECMA提案,可以在对象和对象的属性上定义元数据,用法类似下面这样:

    const o = { a: 1, b: 2 };
    Reflect.defineMetadata("meta_key", "meta_value", o, 'a'); // 定义元数据
    Reflect.getOwnMetadata("meta_key", o, 'a'); // "meta_value" 获得元数据
    

    目前需要借助reflect-metadata库进行polyfill。

    // 命令行安装
    // npm install reflect-metadata -S
    
    // index.ts 引入该库
    import "reflect-metadata";
    

    回过头来看startsWith的实现,它返回的装饰器函数有三个参数,分别是target(被装饰的对象),propertyKey(被装饰的属性)和paramsIndex(参数的次序),本例中target表示Greeter的原型对象,即Greeter.prototype,propertyKey是"greet",paramsIndex是name参数的次序0。该函数设置了greet方法的metadata,key为一个常数startsWithKey, value是一个[[参数次序:开头字符]]的映射。到这里我们发现startsWith装饰器只是收集了映射,但是并未进行校验,这是由于参数装饰器并不能得到运行时调用方法的实参,校验操作需要在一个额外的方法装饰器checkParam中进行。

    function checkParam(
      target: any,
      propertyKey: string,
      descriptor: PropertyDescriptor
    ) {
      const method = descriptor.value;
      // 对greet方法进行改写,先进行参数校验
      descriptor.value = function() {
        // 获得startsWith装饰器收集到的[[参数次序:开头字符]]映射
        const startsWithConstraints = Reflect.getOwnMetadata(
          startsWithKey,
          target,
          propertyKey
        ) || {};
    
        // 对定义了startsWith装饰器的参数进行校验
        Array.prototype.slice.call(arguments).forEach((arg, index) => {
          const prefix = startsWithConstraints[index];
          if (prefix && !arg.startsWith(prefix)) {
            throw new Error(`argument ${index} must start with ${prefix}`);
          }
        })
    
        return method.apply(this, arguments);
      };
    }
    

    checkParam装饰器先通过descriptor.value得到Greeter.prototype.greet方法,然后对这个方法进行改写,增加了参数校验。校验之前从greet方法的metadata中取出了startsWith装饰器收集到的[[参数次序:开头字符]]映射,然后分别对定义了startWith装饰器的参数进行校验。

    下面测试一下校验失败的场景:

    const g = new Greeter('msg');
    
    g.greet('rtom');
    

    运行程序后命令行会得到一个报错:

    throw new Error("argument " + index + " must start with " + prefix);
                    ^
                    
    Error: argument 0 must start with t
    ...
    

    装饰类的构造器

    类似方法装饰器,类的构造器也可以被装饰,写法是直接在class关键字上方添加装饰器代码,装饰器的实现比较简单,只有一个参数target,指代的是构造器方法本身。

    function activate(target: any) {
      target.active = true; // target指代Greeter函数
    }
    
    @activate
    class Greeter {
      static active = false;
      greeting: string;
      constructor(message: string) {
        this.greeting = message;
      }
      greet(name: string): string {
        console.log(`welcome, ${name}!`);
        return "Hello";
      }
    }
    
    console.log(Greeter.active); // true
    

    在上方的例子中,activate装饰器的target参数指代Greeter构造器,该装饰器修改了Greeter类的static属性active的值。值得注意的是我们并未实例化这个类,但装饰器代码已经发挥了作用。

    应用

    至此我们对Typescript的装饰器有了一定的了解,通过使用装饰器可以定制类构造器、方法,甚至方法参数的行为。下面我们举两个例子来实际看一下。

    依赖注入

    首先聊聊什么是依赖注入,vue中就有这个概念,provide/inject是解决组件之间的通信问题的利器,不受层级结构的限制。其核心思想是外层组件通过provide选项声明可同享的属性,内层组件通过inject选项指定待注入的属性,这样外层组件的属性值就可以同步到内层组件了。我们大致可以这样理解依赖注入的步骤:首先收集需要共享的数据,然后标记需要使用这些数据的对象,最后从共享数据中挑选出该对象需要的数据交给它。

    接下来我们模仿nest.js的做法实现一个简单的依赖注入。

    class FlowerService {
      strew () {
        console.log('strew flower');
      }
    }
    
    class Greeter {
      constructor(
        private readonly flower: FlowerService
      ) {
      }
      greet(name: string): string {
        console.log(`welcome, ${name}!`);
        this.flower.strew();
        return "Hello";
      }
    }
    
    // 期待的操作是 const g = create(Greeter);
    const g = new Greeter(new FlowerService());
    g.greet('tom');
    

    这是一段给"欢迎"方法添加"撒花"动作的代码,"撒花"作为一个类FlowerService独立出来。现在创建Greeter对象的时候是主动将FlowerService实例化并传入,我们期待的是将FlowerService作为依赖,通过某种方式注入到Greeter类中,然后通过const g = create(Greeter)的工厂函数创建Greeter的实例,下面是具体实现步骤:

    首先实现provide,这里直接对FlowerService类进行装饰,将它的构造器放到一个weakmap中存起来备用。

    const providerMap = new WeakMap();
    
    // ----- Provider -----
    function provider(target: any) {
      providerMap.set(target, null);
    }
    
    @provider
    class FlowerService {
      strew () {
        console.log('strew flower');
      }
    }
    

    接下来是一个关键的问题,程序怎么知道Greeter类需要FlowerService这个依赖呢?我们发现Greeter构造函数的形参flower就是FlowerService的实例,那程序有办法拿到构造函数的入参类型吗?这需要在tsconfig.json的compilerOptions配置中开启一个额外的选项:

    // tsconfig.json
    {
      "compilerOptions": {
        ...
        "emitDecoratorMetadata": true,
        ...
      }
    }
    

    然后我们给Greeter函数添加一个装饰器inject,这个装饰器可以什么也不做。

    function inject(target: any) {
      // do nothing
    }
    
    @inject
    class Greeter {
      constructor(
        private readonly flower: FlowerService
      ) {
      }
      greet(name: string): string {
        ...
      }
    }
    

    下面是tsc编译得到的js代码,Greeter被添加了两个装饰器,一个是我们自己定义的inject,另外一个调用了__metadata函数,该函数通过Reflect.metadata返回一个装饰器,该装饰器设置了Greeter的metadata,操作类似:Reflect.defineMetadata("design:paramtypes", [FlowerService], Greeter); // 定义元数据,我们发现Greeter构造器的参数类型就以metadata的形式被保存到"design:paramtypes"这个key中了。

    var __metadata = (this && this.__metadata) || function (k, v) {
        if (typeof Reflect === "object" && typeof Reflect.metadata === "function") return Reflect.metadata(k, v);
    };
    ...
    function inject(target) {
    }
    var Greeter = (function () {
        function Greeter(flower) {
            this.flower = flower;
        }
        Greeter.prototype.greet = function (name) {
            ...
        };
        Greeter = __decorate([
            inject,
            // __metadata函数返回一个装饰器,该装饰器设置了Greeter的metadata
            __metadata("design:paramtypes", [FlowerService])
        ], Greeter);
        return Greeter;
    }());
    

    最后一步是创建对象,我们定义一个函数create(target),首先通过Reflect.getOwnMetadata("design:paramtypes", target)获取目标构造器target的入参类型,然后在providerMap中查找是否存在该入参类型,如果有则说明该类型是一个provider,将该类型实例化后传给目标构造器target,最后创建target实例并返回。值得注意的是,provider可能自身也依赖其他provider,故需要处理一下依赖的递归收集。

    function create(target: any) {
      // 获取函数的入参类型
      const paramTypes = Reflect.getOwnMetadata(
        "design:paramtypes",
        target
      ) || [];
      
      const deps = paramTypes.map((type: any) => {
        const instance = providerMap.get(type);
        if (instance === null) {
          // 递归收集依赖
          providerMap.set(type, create(type));
        }
        return providerMap.get(type);
      })
      return new target(...deps);
    }
    

    这样简单的依赖注入就实现了,下面是完整代码。

    import "reflect-metadata";
    
    const providerMap = new WeakMap();
    
    // ----- Provider -----
    function provider(target: any) {
      providerMap.set(target, null);
    }
    
    @provider
    class FlowerService {
      strew () {
        console.log('strew flower');
      }
    }
    
    // ----- Inject -----
    function create(target: any) {
      // 获取函数的入参类型
      const paramTypes = Reflect.getOwnMetadata(
        "design:paramtypes",
        target
      ) || [];
      
      const deps = paramTypes.map((type: any) => {
        const instance = providerMap.get(type);
        if (instance === null) {
          // 递归收集依赖
          providerMap.set(type, create(type));
        }
        return providerMap.get(type);
      })
      return new target(...deps);
    }
    
    // 必须要inject一下,ts解析出构造器的入参类型
    function inject(target: any) {
    
    }
    
    @inject
    class Greeter {
      constructor(
        private readonly flower: FlowerService
      ) {
      }
      greet(name: string): string {
        console.log(`welcome, ${name}!`);
        this.flower.strew();
        return "Hello";
      }
    }
    
    const g = create(Greeter);
    g.greet('tom');
    
    // 命令行输出
    // welcome, tom!
    // strew flower
    

    约束类的静态方法

    要让Foo类实现Bar接口,我们通常这样写class Foo implements Bar,Bar接口里面约束了实例的属性和方法,如:

    interface Bar {
      work: () => void
    }
    class Foo implements Bar {
      work() {
        // do something
      }
    }
    

    但约束Foo的静态属性要怎么做呢?首先interface不支持添加static关键字,下面这种写法是不被允许的:

    interface Bar {
      static life: number;
      work: () => void;
    }
    

    我们知道static属性其实最终是添加在构造函数上的,改成下面这种写法才可行:

    interface Bar {
      work: () => void
    }
    interface StaticBar {
      life: number;
    }
    const Foo: StaticBar = class implements Bar {
      static life: number;
      work() {
        // do something
      }
    }
    

    但是这种方式改变了class声明的写法,感觉不是十分优雅,下面是使用装饰器的写法:

    interface Bar {
      work: () => void
    }
    
    type WithStatic<T, U> = {
      new(): T;
    } & U;
    
    type BarWithStatic = WithStatic<Bar, { life: number }>;
    
    // 通过装饰器重写了构造函数的类型
    function staticImplements<T>() {
      return <U extends T>(constructor: U) => {};
    }
    
    @staticImplements<BarWithStatic>()
    class Foo {
      static life: number;
      work() {
        // do something
      }
    }
    

    这里的装饰器staticImplements没有做任何逻辑上的操作,它只是声明了构造函数的类型,这样静态属性自然就具备了类型声明。

    参考

    1. 官方文档
    2. 如何用 Decorator 装饰你的 Typescript?
    3. How to define static property in TypeScript interface
    4. A practical guide to TypeScript decorators
    5. decorator与依赖注入

    起源地下载网 » 理解Typescript装饰器

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元