如何用 Decorator 裝飾你的 Typescript

Neal_yang發表於2019-12-26

前言

原創文章彙總:github/Nealyang

正在著手寫 THE LAST TIME 系列的 Typescript 篇,而Decorator 一直是我個人看來一個非常不錯的切面方案。所謂的切面方案就是我們常說的切面程式設計 AOP。一種程式設計思想,簡單直白的解釋就是,一種在執行時,動態的將程式碼切入到類的指定方法、指定位置上的程式設計思想就是 AOPAOP 和我們熟悉的 OOP 一樣**,只是一個程式設計正規化**,AOP 沒有說什麼規定要使用什麼程式碼協議,必須要用什麼方式去實現,這只是一個正規化。而 Decorator 也就是AOP 的一種形式。

而本文重點不在於討論程式設計正規化,主要介紹 Typescript+Decorator 下圖的一些知識講解,其中包括最近筆者在寫專案的一些應用。

如何用 Decorator 裝飾你的 Typescript

介紹

什麼是 Decorator

貌似在去年的時候在公眾號:【全棧前端精選】中,有分享過關於 Decorator 的基本介紹:Decorator 從原理到實戰,裡面有對Decorator 非常詳細的介紹。

本質上,它也就是個函式的語法糖。

DecoratorES7 新增的性特性,當然,在 Typescript 很早就有了。早在此之前,就有提出與 Decorator 思想非常相近的設計模式:裝飾者模式

如何用 Decorator 裝飾你的 Typescript

上圖的WeaponAccessory就是一個Decorator,他們新增額外的功能到基類上。讓其能夠滿足你的需求。

如何用 Decorator 裝飾你的 Typescript

簡單的理解 Decorator,可以認為它是一種包裝,對 物件,方法,屬性的包裝。就像 Decorator 俠,一身盔甲,只是裝飾,以滿足需求,未改變是人類的本質。

為什麼要使用 Decorator

為什麼要使用 Decorator,其實就是介紹到 AOP 正規化的最大特點了:非侵入式增強。

比如筆者正在寫的一個頁面容器,交 PageContainer.tsx,基本功能包括滾動、autoCell、事件注入與解綁、placeHolder Container 的新增等基本功能。

class PageContainer extends Components{
 xxx
}
複製程式碼

這時候我正使用這個容器,想接入微信分享功能。或者錯誤兜底功能。但是使用這個容器的人非常多。分享不一定都是微信分享、錯誤兜底不一定都是張著我想要的樣子。所以我必定要對容器進行改造和增強

從功能點劃分,這些的確屬於容器的能力。所以在無侵入式的增強方案中,裝飾者模式是一個非常好的選擇。也就是話落到我們所說的 Decorator。(對於 React 或者 RaxHOC 也是一種很好的方案,當然,其思想是一致的。)

+ @withError
+ @withWxShare
class PageContainer extends Components{
 xxx
}
複製程式碼

我們新增 Decorator,這樣的做法,對原有程式碼毫無入侵性,這就是AOP的好處了,把和主業務無關的事情,放到程式碼外面去做

關於 Typescript

如何用 Decorator 裝飾你的 Typescript

JavaScript 毋庸置疑是一門非常好的語言,但是其也有很多的弊端,其中不乏是作者設計之處留下的一些 “bug”。當然,瑕不掩瑜~

話說回來,JavaScript 畢竟是一門弱型別語言,與強型別語言相比,其最大的程式設計陋習就是可能會造成我們型別思維的缺失(高階詞彙,我從極客時間學到的)。而思維方式決定了程式設計習慣,程式設計習慣奠定了程式設計質量,工程質量劃定了能力邊界,而學習 Typescript,最重要的就是我們型別思維的重塑

那麼其實,Typescript 在我個人理解,並不能算是一個程式語言,它只是 JavaScript 的一層殼。當然,我們完全可以將它作為一門語言去學習。網上有很多推薦 or 不推薦 Typescript 之類的文章這裡我們不做任何討論,學與不學,用於不用,利與弊。各自拿捏~

再說說 typescript,其實對於 ts 相比大家已經不陌生了。更多關於 ts 入門文章和文件也是已經爛大街了。此文不去翻譯或者搬運各種 api或者教程章節。只是總結羅列和解惑,筆者在學習 ts 過程中曾疑惑的地方。道不到的地方,歡迎大家評論區積極討論。

首先推薦下各自 ts 的編譯環境:typescriptlang.org

再推薦筆者收藏的兩個網站:

Typescript 中的 Decorator 簽名

interface TypedPropertyDescriptor<T> {
    enumerable?: boolean;
    configurable?: boolean;
    writable?: boolean;
    value?: T;
    get?: () => T;
    set?: (value: T) => void;
}

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;
declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;
declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;
declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;
複製程式碼

如上是 ClassDecoratorPropertyDecorator以及 MethodDecorator 的三個型別簽名。

基本配置

由於 DecoratorTypescript 中還是一項實驗性的給予支援,所以在 ts 的配置配置檔案中,我們指明編譯器對 Decorator 的支援。

在命令列或tsconfig.json裡啟用experimentalDecorators編譯器選項:

  • 命令列:
tsc --target ES5 --experimentalDecorators
複製程式碼
  • tsconfig.json
{
    "compilerOptions": {
        "target": "ES5",
        "experimentalDecorators": true
    }
}
複製程式碼

型別

Typescript 中,Decorator 可以修飾五種語句:類、屬性、方法、訪問器方法引數

class definitions

類裝飾器應用於建構函式之上,會在執行時當作函式被呼叫,類的建構函式作為其唯一的引數。

注意,在 Typescript 中的class 關鍵字只是 JavaScript 建構函式的一個語法糖。由於類裝飾器的引數是一個建構函式,其也應該返回一個建構函式。

我們先看一下官網的例子:

    function classDecorator<T extends { new (...args: any[]): {} }>(
      constructor: T
    ) {
      return class extends constructor {
        newProperty = "new property";
        hello = "override";
      };
    }

    @classDecorator
    class Greeter {
      property = "property";
      hello: string;
      constructor(m: string) {
        this.hello = m;
      }
    }
    const greeter: Greeter = new Greeter("world");
    console.log({ greeter }, greeter.hello);
複製程式碼

如何用 Decorator 裝飾你的 Typescript

{ new (...args: any[]): {} }表示一個建構函式,為了看起來清晰一些,我們也可以將其宣告到外面:

/**
 *建構函式型別
 *
 * @export
 * @interface Constructable
 */
export interface IConstructable {
    new (...args:any[]):any
}
複製程式碼

properties

屬性裝飾器有兩個引數:

  • 對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件。
  • 成員的key。

descriptor不會做為引數傳入屬性裝飾器,這與TypeScript是如何初始化屬性裝飾器的有關。 因為目前沒有辦法在定義一個原型物件的成員時描述一個例項屬性,並且沒辦法監視或修改一個屬性的初始化方法。返回值也會被忽略。因此,屬性描述符只能用來監視類中是否宣告瞭某個名字的屬性。

    function setDefaultValue(target: Object, propertyName: string) {
      target[propertyName] = "Nealayng";
    }

    class Person {
      @setDefaultValue
      name: string;
    }

    console.log(new Person().name); // 輸出: Nealayng
複製程式碼

將上面的程式碼修改一下,我們給靜態成員新增一個 Decorator

    function setDefaultValue(target: Object, propertyName: string) {
      console.log(target === Person);

      target[propertyName] = "Nealayng";
    }

    class Person {
      @setDefaultValue
      static displayName = 'PersonClass'

      name: string;

      constructor(name:string){
        this.name = name;
      }
    }

    console.log(Person.prototype);
    console.log(new Person('全棧前端精選').name); // 輸出: 全棧前端精選
    console.log(Person.displayName); // 輸出: Nealayng
複製程式碼

如何用 Decorator 裝飾你的 Typescript

以此可以驗證,上面我們說的: Decorator 的第一個引數,對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件

methods

方法裝飾器表示式會在執行時當作函式被呼叫,傳入下列3個引數:

  • 對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件。
  • 成員的名字。
  • 成員的屬性描述符 descriptor

注意: 如果程式碼輸出目標版本小於ES5,descriptor將會是undefined。

    function log(
      target: Object,
      propertyName: string,
      descriptor: TypedPropertyDescriptor<(...args: any[]) => any>
    ) {
      const method = descriptor.value;
      descriptor.value = function(...args: any[]) {
        // 將引數轉為字串
        const params: string = args.map(a => JSON.stringify(a)).join();

        const result = method!.apply(this, args);

        // 將結果轉為字串
        const resultString: string = JSON.stringify(result);

        console.log(`Call:${propertyName}(${params}) => ${resultString}`);

        return result;
      };
    }

    class Author {
      constructor(private firstName: string, private lastName: string) {}

      @log
      say(message: string): string {
        return `${message} by: ${this.lastName}${this.firstName}`;
      }
    }

    const author:Author = new Author('Yang','Neal');
    author.say('《全站前端精選》');//Call:say("全站前端精選") => "全站前端精選 by: NealYang"
複製程式碼

上述的程式碼比較簡單,也就不做過多解釋了。其中需要注意的是屬性描述符 descriptor 的型別和許多文章寫的型別有些不同:propertyDescriptor: PropertyDescriptor

如何用 Decorator 裝飾你的 Typescript

從官方的宣告檔案可以看出,descriptor 設定為TypedPropertyDescriptor加上泛型約束感覺更加的嚴謹一些。

如何用 Decorator 裝飾你的 Typescript

當然,官網也是直接宣告為型別PropertyDescriptor的。這個,仁者見仁。

accessors

訪問器,不過是類宣告中屬性的讀取訪問器和寫入訪問器。訪問器裝飾器表示式會在執行時當作函式被呼叫,傳入下列3個引數:

  • 對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件。
  • 成員的名字。
  • 成員的屬性描述符。

如果程式碼輸出目標版本小於ES5,Property Descriptor將會是undefined。同時 TypeScript 不允許同時裝飾一個成員的get和set訪問器

    function Enumerable(
      target: any,
      propertyKey: string,
      descriptor: PropertyDescriptor
    ) {
      //make the method enumerable
      descriptor.enumerable = true;
    }

    class Person {
      _name: string;

      constructor(name: string) {
        this._name = name;
      }

      @Enumerable
      get name() {
        return this._name;
      }
    }

    console.log("-- creating instance --");
    let person = new Person("Diana");
    console.log("-- looping --");
    for (let key in person) {
      console.log(key + " = " + person[key]);
    }
複製程式碼

如何用 Decorator 裝飾你的 Typescript

如果上面 get 不新增Enumerable的話,那麼 for in 只能出來_name _name = Diana

parameters

引數裝飾器表示式會在執行時當作函式被呼叫,傳入下列3個引數:

  • 對於靜態成員來說是類的建構函式,對於例項成員是類的原型物件。
  • 成員的名字。
  • 引數在函式引數列表中的索引。

引數裝飾器只能用來監視一個方法的引數是否被傳入。

在下面的示例中,我們將使用引數裝飾器@notNull來註冊目標引數以進行非空驗證,但是由於僅在載入期間呼叫此裝飾器(而不是在呼叫方法時),因此我們還需要方法裝飾器@validate,它將攔截方法呼叫並執行所需的驗證。

function notNull(target: any, propertyKey: string, parameterIndex: number) {
    console.log("param decorator notNull function invoked ");
    Validator.registerNotNull(target, propertyKey, parameterIndex);
}

function validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    console.log("method decorator validate function invoked ");
    let originalMethod = descriptor.value;
    //wrapping the original method
    descriptor.value = function (...args: any[]) {//wrapper function
        if (!Validator.performValidation(target, propertyKey, args)) {
            console.log("validation failed, method call aborted: " + propertyKey);
            return;
        }
        let result = originalMethod.apply(this, args);
        return result;
    }
}

class Validator {
    private static notNullValidatorMap: Map<any, Map<string, number[]>> = new Map();

    //todo add more validator maps
    static registerNotNull(target: any, methodName: string, paramIndex: number): void {
        let paramMap: Map<string, number[]> = this.notNullValidatorMap.get(target);
        if (!paramMap) {
            paramMap = new Map();
            this.notNullValidatorMap.set(target, paramMap);
        }
        let paramIndexes: number[] = paramMap.get(methodName);
        if (!paramIndexes) {
            paramIndexes = [];
            paramMap.set(methodName, paramIndexes);
        }
        paramIndexes.push(paramIndex);
    }

    static performValidation(target: any, methodName: string, paramValues: any[]): boolean {
        let notNullMethodMap: Map<string, number[]> = this.notNullValidatorMap.get(target);
        if (!notNullMethodMap) {
            return true;
        }
        let paramIndexes: number[] = notNullMethodMap.get(methodName);
        if (!paramIndexes) {
            return true;
        }
        let hasErrors: boolean = false;
        for (const [index, paramValue] of paramValues.entries()) {
            if (paramIndexes.indexOf(index) != -1) {
                if (!paramValue) {
                    console.error("method param at index " + index + " cannot be null");
                    hasErrors = true;
                }
            }
        }
        return !hasErrors;
    }
}

class Task {
    @validate
    run(@notNull name: string): void {
        console.log("running task, name: " + name);
    }
}

console.log("-- creating instance --");
let task: Task = new Task();
console.log("-- calling Task#run(null) --");
task.run(null);
console.log("----------------");
console.log("-- calling Task#run('test') --");
task.run("test");

複製程式碼

對應的輸出位:

param decorator notNull function invoked
method decorator validate function invoked
-- creating instance --
-- calling Task#run(null) --
method param at index 0 cannot be null
validation failed, method call aborted: run
----------------
-- calling Task#run('test') --
running task, name: test
複製程式碼

@validate裝飾器把run方法包裹在一個函式裡在呼叫原先的函式前驗證函式引數.

裝飾器工廠

裝飾器工廠真的也就是一個噱頭(造名詞)而已,其實也是工廠的概念哈,畢竟官方也是這麼號稱的。在實際專案開發中,我們使用的也還是挺多的

**裝飾器工廠就是一個簡單的函式,它返回一個表示式,以供裝飾器在執行時呼叫。**其實說白了,就是一個函式 return 一個 Decorator。非常像 JavaScript 函式柯里化,個人稱之為“函式式Decorator”~

如何用 Decorator 裝飾你的 Typescript

import { logClass } from './class-decorator';
import { logMethod } from './method-decorator';
import { logProperty } from './property-decorator';
import { logParameter } from './parameter-decorator';

// 裝飾器工廠,根據傳入的引數呼叫相應的裝飾器
export function log(...args) {
    switch (args.length) {
        case 3: // 可能是方法裝飾器或引數裝飾器
            // 如果第三個引數是數字,那麼它是索引,所以這是引數裝飾器
            if typeof args[2] === "number") {
                return logParameter.apply(this, args);
            }
            return logMethod.apply(this, args);
        case 2: // 屬性裝飾器 
            return logProperty.apply(this, args);
        case 1: // 類裝飾器
            return logClass.apply(this, args);
        default: // 引數數目不合法
            throw new Error('Not a valid decorator');
    }
}

@log
class Employee {
    @log
    private name: string;

    constructor(name: string) {
        this.name = name;
    }

    @log
    greet(@log message: string): string {
        return `${this.name} says: ${message}`;
    }
}
複製程式碼

載入順序

一個類中,不同位置申明的裝飾器,按照以下規定的順序應用:

  • 有多個引數裝飾器(parameterDecorator)時,從最後一個引數依次向前執行
  • 方法(methodDecorator)和方法引數裝飾器(parameterDecorator)中,引數裝飾器先執行
  • 類裝飾器(classDecorator)總是最後執行。
  • 方法(methodDecorator)和屬性裝飾器(propertyDecorator),誰在前面誰先執行。因為引數屬於方法一部分,所以引數會一直緊緊挨著方法執行。
function ClassDecorator() {
    return function (target) {
        console.log("I am class decorator");
    }
}
function MethodDecorator() {
    return function (target, methodName: string, descriptor: PropertyDescriptor) {
        console.log("I am method decorator");
    }
}
function Param1Decorator() {
    return function (target, methodName: string, paramIndex: number) {
        console.log("I am parameter1 decorator");
    }
}
function Param2Decorator() {
    return function (target, methodName: string, paramIndex: number) {
        console.log("I am parameter2 decorator");
    }
}
function PropertyDecorator() {
    return function (target, propertyName: string) {
        console.log("I am property decorator");
    }
}

@ClassDecorator()
class Hello {
    @PropertyDecorator()
    greeting: string;


    @MethodDecorator()
    greet( @Param1Decorator() p1: string, @Param2Decorator() p2: string) { }
}
複製程式碼

輸出為:

I am parameter2 decorator
I am parameter1 decorator
I am method decorator
I am property decorator
I am class decorator
複製程式碼

實戰

由於是業務程式碼,與技術無關瑣碎,只擷取部分程式碼示意,非 Decorator 程式碼,以截圖形式

這應該也是整理這篇文章最開始的原因了。直接說說專案(rax1.0+Decorator)吧。

需求很簡單,就是是編寫一個頁面的容器。

如何用 Decorator 裝飾你的 Typescript

部分專案結構:

pm-detail
├─ constants
│    └─ index.ts  //常量
├─ index.css
├─ index.tsx  // 入口檔案
└─ modules  // 模組
       └─ page-container  // 容器元件
              ├─ base   //容器基礎元件
              ├─ decorator  // 裝飾器
              ├─ index.tsx
              ├─ lib  // 工具
              └─ style.ts
複製程式碼

重點看下如下幾個檔案

如何用 Decorator 裝飾你的 Typescript

  • base.tsx

如何用 Decorator 裝飾你的 Typescript

其實是基礎功能的封裝

在此基礎上,我們需要個能滾動的容器

  • scrollbase.tsx

如何用 Decorator 裝飾你的 Typescript

也是基於 Base.tsx 基礎上,封裝一些滾動容器具有的功能

  • style decorator
import is from './util/is';
import map from './util/map';

const isObject = is(Object);
const isFunction = is(Function);

class Style {
  static factory = (...args) => new Style(...args);

  analyze(styles, props, state) {
    return map(v => {
      if (isFunction(v)) {
        const r = v.call(this.component, props, state);
        return isObject(r) ? this.analyze(r, props, state) : r;
      }
      if (isObject(v)) return this.analyze(v, props, state);
      return v;
    })(styles);
  }

  generateStyles(props, state) {
    const { styles: customStyles } = props;
    const mergedStyles = this.analyze(this.defaultStyles, props, state);
    if (customStyles) {
      Object.keys(customStyles).forEach(key => {
        if (mergedStyles[key]) {
          if (isObject(mergedStyles[key])) {
            Object.assign(mergedStyles[key], customStyles[key]);
          } else {
            mergedStyles[key] = customStyles[key];
          }
        } else {
          mergedStyles[key] = customStyles[key];
        }
      });
    }
    return {
      styles: mergedStyles,
    };
  }

  constructor(defaultStyles = {}, { vary = true } = {}) {
    const manager = this;

    this.defaultStyles = defaultStyles;

    return BaseComponent => {
      const componentWillMount = BaseComponent.prototype.componentWillMount;
      const componentWillUpdate = BaseComponent.prototype.componentWillUpdate;

      BaseComponent.prototype.componentWillMount = function() {
        manager.component = this;
        Object.assign(this, manager.generateStyles(this.props, this.state));
        return componentWillMount && componentWillMount.apply(this, arguments);
      };

      if (vary) {
        BaseComponent.prototype.componentWillUpdate = function(nextProps, nextState) {
          Object.assign(this, manager.generateStyles(nextProps, nextState));
          return componentWillUpdate && componentWillUpdate.apply(this, arguments);
        };
      }

      return BaseComponent;
    };
  }
}

export default Style.factory;
複製程式碼

然後我們需要一個錯誤的兜底功能,但是這個本身應該不屬於容器的功能。所以我們封裝一個 errorDecorator

  • withError.txs
function withError<T extends IConstructable>(Wrapped: T) {
  const willReceiveProps = Wrapped.prototype.componentWillReceiveProps;
  const didMount = Wrapped.prototype.componentDidMount;
  const willUnmount = Wrapped.prototype.componentWillUnmount;

  return class extends Wrapped {
    static displayName: string = `WithError${getDisplayName(Wrapped)}·`;

    static defaultProps: IProps = {
      isOffline: false,
      isError: false,
      errorRefresh: () => {
        window.location.reload(true);
      }
    };

    private state: StateType;
    private eventNamespace: string = "";

    constructor(...args: any[]) {
      super(...args);
      const { isOffline, isError, errorRefresh, tabPanelIndex } = this.props;
      this.state = {
        isOffline,
        isError,
        errorRefresh
      };
      if (tabPanelIndex > -1) {
        this.eventNamespace = `.${tabPanelIndex}`;
      }
    }

    triggerErrorHandler = e => {...};

    componentWillReceiveProps(...args) {
      if (willReceiveProps) {
        willReceiveProps.apply(this, args);
      }
      const [nextProps] = args;
      const { isOffline, isError, errorRefresh } = nextProps;
      this.setState({
        isOffline,
        isError,
        errorRefresh
      });
    }

    componentDidMount(...args) {
      if (didMount) {
        didMount.apply(this, args);
      }
      const { eventNamespace } = this;
      emitter.on(
        EVENTS.TRIGGER_ERROR + eventNamespace,
        this.triggerErrorHandler
      );
    }

    componentWillUnmount(...args) {
      if (willUnmount) {
        willUnmount.apply(this, args);
      }
      const { eventNamespace } = this;
      emitter.off(
        EVENTS.TRIGGER_ERROR + eventNamespace,
        this.triggerErrorHandler
      );
    }

    render() {
      const { isOffline, isError, errorRefresh } = this.state;

      if (isOffline || isError) {
        let errorType = "system";
        if (isOffline) {
          errorType = "offline";
        }
        return <Error errorType={errorType} refresh={errorRefresh} />;
      }

      return super.render();
    }
  };
}
複製程式碼

然後我們進行整合匯出

import { createElement, PureComponent, RaxNode } from 'rax';
import ScrollBase from "./base/scrollBase";
import withError from "./decorator/withError";

interface IScrollContainerProps {
  spmA:string;
  spmB:string;
  renderHeader?:()=>RaxNode;
  renderFooter?:()=>RaxNode;
  [key:string]:any;
}
@withError
class ScrollContainer extends PureComponent<IScrollContainerProps,{}> {

  render() {
    return <ScrollBase {...this.props} />;
  }
}

export default ScrollContainer;
複製程式碼

使用如下:

如何用 Decorator 裝飾你的 Typescript

如何用 Decorator 裝飾你的 Typescript

學習交流

最後附一張,本文思維導圖。

公眾號回覆:【xmind1】 獲取思維導圖原始檔

如何用 Decorator 裝飾你的 Typescript

  • 關注公眾號【全棧前端精選】,每日獲取好文推薦
  • 新增微訊號:is_Nealyang(備註來源) ,入群交流
公眾號【全棧前端精選】 個人微信【is_Nealyang】
如何用 Decorator 裝飾你的 Typescript
如何用 Decorator 裝飾你的 Typescript

參考文獻

相關文章