巧用 TypeScript(二)

三毛丶發表於2018-11-04

以下問題來自於與公司小夥伴以及網友的討論,整理成章,希望提供另一種思路(避免踩坑)解決問題。

Decorator

Decorator 早已不是什麼新鮮事物。在 TypeScript 1.5 + 的版本中,我們可以利用內建型別 ClassDecoratorPropertyDecoratorMethodDecoratorParameterDecorator 更快書寫 Decorator,如 MethodDecorator

declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
複製程式碼

使用時,只需在相應地方加上型別註解,匿名函式的引數型別也就會被自動推匯出來了。

function methodDecorator (): MethodDecorator {
  return (target, key, descriptor) => {
    // ...
  };
}
複製程式碼

值得一提的是,如果你在 Decorator 給目標類的 prototype 新增屬性時,TypeScript 並不知道這些:

function testAble(): ClassDecorator {
  return target => {
    target.prototype.someValue = true
  }
}

@testAble()
class SomeClass {}

const someClass = new SomeClass()

someClass.someValue() // Error: Property 'someValue' does not exist on type 'SomeClass'.
複製程式碼

這很常見,特別是當你想用 Decorator 來擴充套件一個類時。

GitHub 上有一個關於此問題的 issues,直至目前,也沒有一個合適的方案實現它。其主要問題在於 TypeScript 並不知道目標類是否使用了 Decorator,以及 Decorator 的名稱。從這個 issues 來看,建議的解決辦法是使用 Mixin:

type Constructor<T> = new(...args: any[]) => T

// mixin 函式的宣告,需要實現
declare function mixin<T1, T2>(...MixIns: [Constructor<T1>, Constructor<T2>]): Constructor<T1 & T2>;

class MixInClass1 {
    mixinMethod1() {}
}

class MixInClass2 {
    mixinMethod2() {}
}

class Base extends mixin(MixInClass1, MixInClass2) {
    baseMethod() { }
}

const x = new Base();

x.baseMethod(); // OK
x.mixinMethod1(); // OK
x.mixinMethod2(); // OK
x.mixinMethod3(); // Error
複製程式碼

當把大量的 JavaScript Decorator 重構為 Mixin 時,這無疑是一件讓人頭大的事情。

這有一些偏方,能讓你順利從 JavaScript 遷移至 TypeScript:

  • 顯式賦值斷言修飾符,即是在類裡,明確說明某些屬性存在於類上:

    function testAble(): ClassDecorator {
      return target => {
        target.prototype.someValue = true
      }
    }
    
    @testAble()
    class SomeClass {
      public someValue!: boolean;
    }
    
    const someClass = new SomeClass();
    someClass.someValue // true
    複製程式碼
  • 採用宣告合併形式,單獨定義一個 interface,把用 Decorator 擴充套件的屬性的型別,放入 interface 中:

    interface SomeClass {
      someValue: boolean;
    }
    
    function testAble(): ClassDecorator {
      return target => {
        target.prototype.someValue = true
      }
    }
    
    @testAble()
    class SomeClass {}
    
    const someClass = new SomeClass();
    someClass.someValue // true
    複製程式碼

Reflect Metadata

Reflect Metadata 是 ES7 的一個提案,它主要用來在宣告的時候新增和讀取後設資料。TypeScript 在 1.5+ 的版本已經支援它,你只需要:

  • npm i reflect-metadata --save
  • tsconfig.json 裡配置 emitDecoratorMetadata 選項。

它具有諸多使用場景。

獲取型別資訊

譬如在 vue-property-decorator 6.1 及其以下版本中,通過使用 Reflect.getMetadata API,Prop Decorator 能獲取屬性型別傳至 Vue,簡要程式碼如下:

function Prop(): PropertyDecorator {
  return (target, key: string) => {
    const type = Reflect.getMetadata('design:type', target, key);
    console.log(`${key} type: ${type.name}`);
    // other...
  }
}

class SomeClass {
  @Prop()
  public Aprop!: string;
};
複製程式碼

執行程式碼可在控制檯看到 Aprop type: string。除能獲取屬性型別外,通過 Reflect.getMetadata("design:paramtypes", target, key)Reflect.getMetadata("design:returntype", target, key) 可以分別獲取函式引數型別和返回值型別。

自定義 metadataKey

除能獲取型別資訊外,常用於自定義 metadataKey,並在合適的時機獲取它的值,示例如下:

function classDecorator(): ClassDecorator {
  return target => {
    // 在類上定義後設資料,key 為 `classMetaData`,value 為 `a`
    Reflect.defineMetadata('classMetaData', 'a', target);
  }
}

function methodDecorator(): MethodDecorator {
  return (target, key, descriptor) => {
    // 在類的原型屬性 'someMethod' 上定義後設資料,key 為 `methodMetaData`,value 為 `b`
    Reflect.defineMetadata('methodMetaData', 'b', target, key);
  }
}

@classDecorator()
class SomeClass {

  @methodDecorator()
  someMethod() {}
};

Reflect.getMetadata('classMetaData', SomeClass);                         // 'a'
Reflect.getMetadata('methodMetaData', new SomeClass(), 'someMethod');    // 'b'
複製程式碼

用例

控制反轉和依賴注入

在 Angular 2+ 的版本中,控制反轉與依賴注入便是基於此實現,現在,我們來實現一個簡單版:

type Constructor<T = any> = new (...args: any[]) => T;

const Injectable = (): ClassDecorator => target => {}

class OtherService {
  a = 1
}

@Injectable()
class TestService {
  constructor(public readonly otherService: OtherService) {}

  testMethod() {
    console.log(this.otherService.a);
  }
}

const Factory = <T>(target: Constructor<T>): T  => {
  // 獲取所有注入的服務
  const providers = Reflect.getMetadata('design:paramtypes', target); // [OtherService]
  const args = providers.map((provider: Constructor) => new provider());
  return new target(...args);
}

Factory(TestService).testMethod()   // 1
複製程式碼

Controller 與 Get 的實現

如果你在使用 TypeScript 開發 Node 應用,相信你對 ControllerGetPOST 這些 Decorator,並不陌生:

@Controller('/test')
class SomeClass {

  @Get('/a')
  someGetMethod() {
    return 'hello world';
  }

  @Post('/b')
  somePostMethod() {}
};

複製程式碼

它們也是基於 Reflect Metadata 實現,不同的是,這次我們將 metadataKey 定義在 descriptorvalue 上(稍後解釋),簡單實現如下:

const METHOD_METADATA = 'method'const PATH_METADATA = 'path'const Controller = (path: string): ClassDecorator => {
  return target => {
    Reflect.defineMetadata(PATH_METADATA, path, target);
  }
}

const createMappingDecorator = (method: string) => (path: string): MethodDecorator => {
  return (target, key, descriptor) => {
    Reflect.defineMetadata(PATH_METADATA, path, descriptor.value);
    Reflect.defineMetadata(METHOD_METADATA, method, descriptor.value);
  }
}

const Get = createMappingDecorator('GET');
const Post = createMappingDecorator('POST');
複製程式碼

接著,建立一個函式,對映出 route

function mapRoute(instance: Object) {
  const prototype = Object.getPrototypeOf(instance);
  
  // 篩選出類的 methodName
  const methodsNames = Object.getOwnPropertyNames(prototype)
                              .filter(item => !isConstructor(item) && isFunction(prototype[item]));
  return methodsNames.map(methodName => {
    const fn = prototype[methodName];

    // 取出定義的 metadata
    const route = Reflect.getMetadata(PATH_METADATA, fn);
    const method = Reflect.getMetadata(METHOD_METADATA, fn);
    return {
      route,
      method,
      fn,
      methodName
    }
  })
};
複製程式碼

我們可以得到一些有用的資訊:

Reflect.getMetadata(PATH_METADATA, SomeClass);  // '/test'

mapRoute(new SomeClass())

/**
 * [{
 *    route: '/a',
 *    method: 'GET',
 *    fn: someGetMethod() { ... },
 *    methodName: 'someGetMethod'
 *  },{
 *    route: '/b',
 *    method: 'POST',
 *    fn: somePostMethod() { ... },
 *    methodName: 'somePostMethod'
 * }]
 * 
 */
複製程式碼

最後,只需把 route 相關資訊綁在 express 或者 koa 上就 ok 了。

至於為什麼要定義在 descriptorvalue 上,我們希望 mapRoute 函式的引數是一個例項,而非 class 本身(控制反轉)。

更多

相關文章