TS - 裝飾器與註解

ihengshuai發表於2024-12-01

Typescript裝飾器模式,可以有效的提高開發效率,就像Java中使用註解一樣,裝飾器讓TypeScript的世界更友好。 我們使用的許多庫都基於這一強大特性構建, 例如AngularNestjs。 在這文章中我將介紹裝飾器和它的許多細節。 我希望在讀完這篇文章後,你可以掌握何時和如何使用這一強大的特性

你可能在前端專案中很少見過註解形式,這是有一定原因的,一方面可能就是見得少,另一個讀完文章你就會明白

文章首發與公眾號,掃碼前往獲取最新諮詢

概念

裝飾器本身就是<u>一種特殊的函式,被用於類的各個屬性(類本身、類屬性、類方法、類訪問器、類方法的引數)</u>,裝飾器就像高階函式一樣,對目標做了一層中間操作,可以很簡潔的無痛修改一些功能和做一些有趣的功能

一個小例子:

// 日誌列印(只做程式碼演示,執行時機有出入)
function logger(key: string): any {
  return function () {
    console.log("call: ", key);
  };
}

class HTTP {
  @logger("get")
  static get(url?: string) {
    return url;
  }
}
HTTP.get();  // 列印 call: get

上面簡單的演示了呼叫get方法時列印logger的功能,只需要在指定的屬性前方加上@logger即可,對原有的業務功能0侵入,這就是裝飾器的強大,如果以傳統的方式必然會在get內部寫一些邏輯

💡與Java註解的異同點:

  • 共同點:

    • 都是作為AOP程式設計正規化的橫切點
    • 都是給目標的注入一些額外的後設資料,方便擴充套件其他功能
    • 都可以透過容器進行後設資料自由存取
  • 不同點:

    • 執行時機:Typescript的裝飾器函式只在執行時執行,而Java的註解在編譯時會生成對應的後設資料資訊,執行時階段透過反射獲取對應目標
    • 型別:Typescript的裝飾器在編譯後型別資訊會被抹去,執行時無法獲取到對應的型別資訊,除非使用reflect-matadata(本質也是存放資料而已);而Java的型別會存放在位元組碼中,因此Java的註解是強型別的、靜態性強

環境配置

ts中的裝飾器是ES提案的一種實驗性實現,使用它需要進行一些配置,在tsconfig.json中修改配置:

{
  "compilerOptions": {
    "emitDecoratorMetadata": true,
    "experimentalDecorators": true
  }
}

裝飾器類別

ts的裝飾器只能用於類中,所以就有類、類方法、類屬性、類訪問器屬性、類方法引數裝飾器,接下來我們就一個個介紹

在介紹裝飾器前,先介紹個工具庫,在裝飾器中反射往往發揮著很大作用,reflect-metadata庫通常在裝飾器中都會使用,接下來介紹下其基本作用

Reflect-Metadata

嚴格地說,後設資料和裝飾器是EcmaScript中兩個獨立的部分。 然而,如果你想實現像是反射)這樣的能力,你總是同時需要它們。有了reflect-metadata的幫助, 我們可以獲取編譯期的型別。
藉助reflect-metadata,執行時預設可以拿到的型別有三種:

  • design:type:屬性型別
  • design:paramtypes:方法的引數型別
  • design:returntypes:方法返回值的型別
function GetPropertyMetaType() {
  return function(target: object, key: string) {
    return Reflect.getMetadata("design:type", target, key);
  }
}

function GetReturnMetaType() {
  return function(target: object, key: string, descriptor: PropertyDescriptor) {
    return Reflect.getMetadata("design:returntypes`", target, key);
  }
}

function GetParamMetaType() {
  return function(target: object, key: string, paramIdx: number) {
    return Reflect.getMetadata("design:paramtypes`", target, key);
  }
}

class TestMeta {
  @GetPropertyMetaType()
  name: string;

  @GetReturnMetaType()
  getUser(@GetParamMetaType() name: string): number {
    return 0;
  }
}

這三種方式拿到的結果都是建構函式(例如String和Number)。規則是:

  • number -> Number
  • string -> String
  • boolean -> Boolean
  • void/null/never -> undefined
  • Array/Tuple -> Array
  • Class -> Construtor
  • Enum -> 如果是純數字列舉為Number,否則為Object
  • Function -> function
  • 其餘都是Object

除此之外還可以自定義一些其他的附加資訊:

@Reflect.metadata(metadataKey, metadataValue) // 宣告式定義後設資料
class TestMeta {
  @Reflect.metadata(metadataKey, metadataValue)
  name: string;

  getUser(@Reflect.metadata(metadataKey, metadataValue) name: string): number {
    return 0;
  }
}

// 命令式定義
Reflect.defineMetadata(metadataKey, metadataValue, TestMeta.prototype, "method");

// 獲取後設資料
let metadataValue = Reflect.getMetadata(metadataKey, ins, "method");

類裝飾器

型別:

type ClassDecorator = <Func extends Function>(target: Func) => Func | void;

引數:

  • @target:類的構造器
  • @return:如果有值將會替代原有的類構造器的宣告;或不返回值也可以修改原有的類

用途:

類裝飾器可以繼承現有類新增或修改一些屬性或方法

// 擴充套件一個toString方法
type Consturctor = { new (...args: any[]): any };

function toString<T extends Consturctor>(target: T): T {
  return class extends target {
    public toString() {
      return JSON.stringify(this);
    }
  };
}

@toString
class Car {
  constructor(public prize: number, public name: string) {}
}

// ts不會智慧的推匯出toString方法
console.log(new Car(1000, "BMW").toString()); // {"prize":1000,"name":"BMW"}

屬性裝飾器

型別:

type PropertyDecorator = (target: Record<string|symbol, any>, prop: string | symbol) => any | void

引數:

  • @target:對於例項屬性成員是類的原型鏈,對於靜態屬性是類的構造器
  • @prop:當前屬性名稱
  • @return:返回的值將被忽略

用途:

屬性裝飾器可以收集資訊,反射賦值,給類新增方法等等,下面介紹一個完整的例子

import "reflect-metadata"; // 這裡需要藉助一個反射庫

type Constructor = { new (...args: any): any }

// 用來管理所有可注入的service
const services: Map<Constructor, Constructor> = new Map();

// 注入裝飾器
function Inject<T extends Constructor>(target: T) {
  services.set(target, target);
}
// 獲取注入的service裝飾器
function Service(target: Record<string|symbol, any>, key: string | symbol) {
  const service = services?.get(Reflect.getMetadata("design:type", target, key));
  service && (target[key] = new service());
}

// 常量裝飾器
function constant(value: any) {
  return function (target: object, key: string) {
    Object.defineProperty(target, key, {
      enumerable: false,
      configurable: false,
      get() {
        return value;
      },
      set() {
        return value;
      },
    });
  };
}

// 使用者相關Service
// 讓當前service變成可注入
@Inject
class UserService {
        // 模擬獲取使用者資訊
  public getUser(...args: any) {
    return new Promise((resolve, reject) => {
      setTimeout(() => {
        resolve("user")
      }, 1000)
    })
  }
}

// 使用者頁面
class UserPage extends React.Component{
  @Service  // 注入UserService
  public service: UserService;

  // 讓BASE_URL變成常量
  @constant(process.env.BASE_URL || 'https://www.baidu.com')
  private _BASE_URL: string;

  public getUser() {
    console.log(this.service)
    return this.service?.getUser({ BASE_URL: this._BASE_URL })
  }

  render() {
    return <>
      <button onClick={this.getUser}>獲取使用者資訊</button>
    </>
  }
}

上面點選獲取使用者資訊按鈕就會去請求使用者的資訊,列印結果如下

https://s3.bmp.ovh/imgs/2022/06/30/a4db1835bc490571.png

方法裝飾器

型別:

Type MethodDecorator = (target: Record<string|symbol, any>, 
prop: string | symbol, descriptor: PropertyDescriptor) => PropertyDescriptor |  void

引數:

  • @target:對於靜態成員是類構造器,對例項成員是原型鏈
  • @prop:屬性名稱
  • @descriptor:屬性描述器
  • @return:屬性描述器或不返回

用途:

方法裝飾器可以高階目標方法,做一些引數轉換,資料收集等等,如日誌收集

// 接著上面的介紹,做一個日誌收集logger

// 日誌收集
function logger(target: object, key: string, descriptor: PropertyDescriptor) {
  const origin = descriptor.value;
  descriptor.value = async function (...args: any[]) {
    try {
      const start = +new Date();
      const rs = await origin.call(this, ...args);
      const end = +new Date();

      // 列印請求耗時
      console.log(`@logger: ${key} api request spend`, `${end - start}ms.`);

      // 這裡可以做一些相關收集...
      return rs;
    } catch (err) {
      console.log(err);
    }
  };
}

@Inject
class UserService {
  @logger
  // 登入Request
  async postLogin(username: string) {
    const time = Math.floor(Math.random() * 10 * 1000);
    const rs = await new Promise((resolve, reject) => {
      setTimeout(() => {
        if (Math.floor(Math.random() * 26) % 10) {
          resolve(username + ' logined success...');
        } else {
          reject('error');
        }
      }, time);
    });
    return rs;
  }
}

// 使用者頁面
class UserPage extends React.Component{
  @Service  // 注入UserService
  public service: UserService;

  public async toLogin(username: string) {
    return await this.service?.postLogin(username);
  }

  render() {
    return <>
      <button onClick={() => this.toLogin('Mr Ming')}>登入</button>
    </>
  }
}

上面點選登入後會記錄請求耗時,控制檯列印的結果如下:

https://ihengshuai-demo1.oss-cn-beijing.aliyuncs.com/005HV6Avgy1h3tgd7rxirj30r60a640p.jpg

訪問器裝飾器

  • 訪問器裝飾器和方法裝飾器差不多,唯一不同的就是key不同
  • 方法裝飾器:value、wriable、enumerable、configurable
  • 訪問器裝飾器:get、set、enumerate、configurable

用途:

// 不變裝飾器
function immutable(
  target: any,
  propertyKey: string,
  descriptor: PropertyDescriptor,
) {
  descriptor.set = function (value: any) {
    return value;
  };
}

// 私有屬性裝飾器
function toPrivate(target: object, key: string) {
  let _val: any = undefined;
  Object.defineProperty(target, key, {
    enumerable: false,
    configurable: false,
    get() {
      return _val;
    },
    set(val) {
      _val = val;
    },
  });
}

// 使用者頁面
class UserPage extends React.Component{
  @Service  // 注入UserService
  public service: UserService;

  @toPrivate
  private _PORT: number = 3306;
  get PORT() {
    return this._PORT;
  }
  @immutable
  set PORT(port: number) {
    this._PORT = port;
  } 

  render() {
    return <>
      <button onClick={() => (this.PORT = 2)}>改變PORT</button>
    </>
  }
}

現在給PORT賦值,將不會改變_PORT的值,如下圖:

https://ihengshuai-demo1.oss-cn-beijing.aliyuncs.com/005HV6Avgy1h3ti440ni7j312w0sgqbp.jpg

引數裝飾器

型別:

type ParamerDecorator = (target: Record<string|symbol, any>, prop: string | symbol, paramIdx: number) => void

引數:

  • @target:對於例項屬性是類的原型鏈,對於靜態屬性是類的構造器
  • prop:屬性名
  • paramIdx:引數位置

用途:

引數裝飾器單獨使用很有限,一般結合其他裝飾器一起使用;下面介紹一個驗證器案例,使用者可以自定義驗證器也可以使用原始型別來驗證引數是否正確:

// 引數裝飾器
type ParamerDecorator = (
  target: Record<string | symbol, any>,
  prop: string | symbol,
  paramIdx: number,
) => void;
// 自定義驗證器
type Validater = (...args: any) => boolean;
const validatorStorage: Record<string | symbol, Validater[]> = {};
const typeDecoratorFactory =
  (validator: Validater): ParamerDecorator =>
  (target, prop, idx) => {
    const targetValidators = validatorStorage[prop] ?? [];
    targetValidators[idx] = validator;
    validatorStorage[prop] = targetValidators;
  };
const isString = typeDecoratorFactory((str: string) => typeof str === 'string');
// 源驗證器或自定義引數驗證器
function validator(
  target: object,
  prop: string,
  descriptor: PropertyDescriptor,
) {
  // 反射獲取引數源型別
  const typeMetaDatas: Function[] = Reflect.getMetadata(
    'design:paramtypes',
    target,
    prop,
  );
  const origin = descriptor.value;
  descriptor.value = function (...args: any[]) {
    // 取到自定義驗證器
    const validators = validatorStorage[prop];

    // 驗證引數
    if (args) {
      args.forEach((arg, idx) => {
        // 自定義驗證器
        const validate = validators?.[idx];
        // 源型別驗證器
        const metaValidate = typeMetaDatas?.[idx];
        const errorMsg = `Failed for parameter: ${prop} at method of ${
          JSON.stringify(target.constructor?.toString())?.match(
            /function (\w+)/i,
          )?.[1]
        }, expect 【${metaValidate.name?.toLowerCase()}】but 【${typeof arg}】`;
        if (validate && !validate(arg)) {
          throw new TypeError(errorMsg);
        }
        // 沒有自定義驗證器執行預設驗證器
        else if (!validate) {
          if (metaValidate !== arg?.constructor) {
            throw new TypeError(errorMsg);
          }
        }
      });
    }

    // 執行源函式
    return origin.call(this, ...args);
  };
}

class LoginPage {
  // 省略前面程式碼...

  @validator
  public async toLogin(@isString username: string) { // 期望username為string
    return await this.service?.postLogin(username);
  }

  render() {
    return <>
      <button onClick={() => (this.toLogin(xxx)}>登入</button>
    </>
  }
}

現在當點選登入時,呼叫toLogin方法,就會驗證username的引數是否合法(當然前提是引數不能為空的,ts也是無法知道引數是否必填,只是來驗證引數的型別罷了),列印如下:

https://ihengshuai-demo1.oss-cn-beijing.aliyuncs.com/005HV6Avgy1h3tt2o96xrj315k11ob29.jpg

當將isString程式碼改為 typeDecoratorFactory((str: object) => str?.name === ‘Mr Ming’);

https://ihengshuai-demo1.oss-cn-beijing.aliyuncs.com/005HV6Avgy1h3ttid8rcnj31360e4alm.jpg

當刪除自定義驗證器,再次執行會驗證預設的型別驗證器:

https://ihengshuai-demo1.oss-cn-beijing.aliyuncs.com/005HV6Avgy1h3ttkha08bj31460lqh5j.jpg

執行順序

上面我們針對每個型別的裝飾器分別做了介紹並作了相關的例子實驗,現在我們可以同時使用不同的裝飾器,測試一下不同型別裝飾器的執行順序是如何的:

// 記錄
function trace(key: string): any {
  console.log('evaluate: \t', key);
  return function () {
    console.log('call: \t\t', key);
  };
}

// 類裝飾器
@trace('Class Decorator')
class People {
  protected _name: string;

  @trace('Static Property _instance')
  public static _instance?: People;

  @trace('Instance Property grade')
  public grade: number;

  constructor(@trace('Constructor Parameter') name: string) {
    this._name = name;
    this.grade = 0;
    this.age = 0;
  }

  @trace('屬性訪問器')
  public get name() {
    return this._name;
  }

  @trace('Instance Method')
  add(
    @trace('Instance Method Param x') x: number,
    @trace('Instance Method Param y') y: number,
  ): number {
    return x + y;
  }

  @trace('Instance Property age')
  public age: number;

  @trace('Static Method getInstance')
  public static getInstance(
    @trace('Static Method Param name') name: string,
  ): People {
    if (!this._instance) {
      this._instance = new People(name);
    }
    return this._instance;
  }
}

上面的列印結果如下:

https://ihengshuai-demo1.oss-cn-beijing.aliyuncs.com/005HV6Avgy1h3tubgm2udj31680ymx4h.jpg

從上面的結果可以得出結論:
裝飾器訪問順序

  1. 例項屬性:按定義從上往下 => 屬性/方法(引數 -> 方法名)/屬性訪問器
  2. 靜態屬性:按定義從上往下 => 屬性/方法(引數 -> 方法名)
  3. 構造器引數
  4. 類裝飾器

當改變例項或者靜態屬性的定義順序,響應的執行順序也會按著從上往下定義的順序執行,有興趣的小夥伴可以動手試試

至此,有關typescript的裝飾器的基本使用就介紹完了,相信看到這裡的小夥伴也會對其有相關的瞭解,希望讀完後自己動手實踐

進階與感悟

你是不是已經透過以上講解對裝飾器的各種想法已經蠢蠢欲動了,確實透過註解形式可以很方便無痛做一些通用的功能,其基本的核心原理還是在於後設資料的存取、然後透過攔截的形式修改或者附加一些額外功能

因此,使用裝飾器我們需要準備一個容器來存放後設資料;如果在一個大型專案中使用,後設資料過多就會導致容器過大,資源載入的時間也就會變長,尤其是在前端頁面這種載入時間是衡量使用者體驗的一種很關鍵指標。我們知道前端工程打包通常都會進行treeshaking來最佳化掉沒有使用的程式碼來減小資源體積,而使用裝飾器則必須將資源存放在容器中,這就導致treeshaking的指標降低,很明顯二者是相悖的,因此解決這個矛盾就成了最關鍵的問題

那麼如何解決這個矛盾呢?一方面我想用,另一方面我還想要效能,這世上哪有這麼好的事情,當然要進行取捨

大家知道前端資源是動態載入的,尤其效能最佳化都會對首屏或者當前用不到的資源懶載入、並進行分包處理,但通用的邏輯還是會在第一時間載入。按照這種思路是不是容器也可以這樣處理?或者說有個全域性容器區域性容器

將必要的通用的邏輯提取到全域性的容器,而其他頁面非全域性通用的區域性邏輯提取到區域性容器,這樣只有在區域性容器的服務頁面載入時再載入便會提高全域性的速度;當然,載入邏輯程式設計往往需要深思熟慮才行,這裡不過多介紹,大家自行發揮

實踐

說了這麼多,我們來個真實案例。簡單概述就類似於Java中的實體類,透過註解、反射實現ORM(型別校驗、欄位轉換等等)。

假設有一個簡單的表單頁面,提交相關欄位給後端,我們都會這麼做:

function FormPage() {
  const [formData, setFormData] = useState({
    name: '',
    email: '',
  });

  function onSubmit() {
    console.log(formData);

    fetch(formData);
  }

  return () => <Form>
    <input type="text" value={formData.name}/>
    <input type="text" value={formData.email}/>

    <Button onClick={onSubmit}>提交</Button>
  </Form>
}

以上是一個很簡單的例子,當他的欄位變得非常複雜且介面的欄位名可能不一致或因為什麼歷史原因導致的,那麼對於資料提交或者或者回顯也會變得非常麻煩。這時候我們使用裝飾器模式可以很方便的解決這些問題:

  1. 假設還是以上頁面

    function FormPage() {
      // 參考下方
      const [formData, setFormData] = useState(createEntity(User));
    
      function onSubmit() {
     console.log(formData);
    
     fetch(resolveEntity(formData)); // 轉換後表達資料
      }
    
      return () => <Form>
     <input type="text" value={formData.name}/>
     <input type="text" value={formData.email}/>
     <input type="text" value={formData.age}/>
    
     <Button onClick={onSubmit}>提交</Button>
      </Form>
    }
  2. 定義使用者表單的實體類:

    @Entity() // 標識實體類
    class UserEntity {
      @Transform({ from: 'u_name' })
      name: string;
    
      @ToField('u_email')
      email: string;
    
      @ToJson<UserEntity, 'age'>((_, v) => v >> 0)
      age: number;
    
      @FieldType(Gender)
      gender: Gender;
    }
  3. 專案中使用裝飾器的基建:

    /**
     * 建立實體
     * @param target 目標實體類
     * @injectTarget Class
     * @returns 包裝後的實體
     */
    export function Entity() {
      return function (target: Constructor): InstanceType<typeof target> {
     return class extends target {
       constructor(args: any = {}) {
         super(args);
    
         this.initFields(args);
       }
    
       private get isEntity() {
         return true;
       }
    
       protected initFields(args = {}) {
         const isInitialize = args.__initialize__;
    
         if (isInitialize) this.copyDataToEntity(args);
         else this.transformDataToEntity(args);
       }
    
       private copyDataToEntity(args: IDict = {}) { /** 省略 **/ }
    
       private transformDataToEntity(args: IDict = {}) { /** 省略 **/ }
    
       protected toJSON() { /** 省略 **/ }
    
       // 省略...
     };
      };
    }
    
    
    /**
     * 建立實體
     * @param Entity 目標實體類
     * @param initValue? 初始化值
     * @param __initialize__? 是否初始化(初始化不執行實體內部邏輯,直接賦值)
     * @returns 實體
     */
    export function createEntity<T extends Constructor>(
      Entity: T,
      initValue: Partial<InstanceType<T>> = {},
      __initialize__ = true
    ): InstanceType<T> {
      return new Entity({ ...initValue, __initialize__ });
    }

由於程式碼篇幅過長,以上只簡單的列舉了一小部分使用,感興趣的話可以 線上體驗前端註解使用

文章首發與公眾號,掃碼前往獲取最新諮詢

參考文件

  • typescriptlang - decorators
  • microsoft - devblogs
  • reflect-metadata

相關文章