Poznajemy bliżej dekoratory w TypeScript. Skupimy się na dekoratorach klas. Zaczyna robić się naprawdę ciekawie. Do dzieła.

Ok, zobaczmy co przyjmuje dekorator klasy:

function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
  }

function stupidLog(constructor: Function){
    console.log(`decorator called for ${constructor.name}`);
}

Cóż, są to funkcje konstruktora. To ciekawy przykład, bo idę o zakład, że żaden z tych dekoratorów nie zadziała tak, jak się tego spodziewamy. Najpierw jednak dopiszmy sobie klasę i poznajmy składnię:

@stupidLog
@sealed
class SealMe {
  
name: string;
 
  constructor(name: string) {
    this.name = name;
  }
}


let seal1 = new SealMe("John");
let seal2 = new SealMe("Jane");

//decorator called for SealMe - 1 raz
console.log(Object.isSealed(seal1)); //false
console.log(Object.isSealed(seal2)); //false

Ok, czyli po pierwsze, stupidLog wywołał się jeden raz. Po drugie, żaden z obiektów nie jest sealed. Wytłumaczenie:

  • StupidLog wywołałby się nawet, gdyby klasa była pusta. Bo ten dekorator chodzi wtedy, gdy jest klasa definiowana, a nie obiekt tworzony. Inaczej dekorator musiałby zwrócić nową klasę dziedziczącą z tej poprzedniej i dopisującą coś do konstruktora – po użyciu super
  • sealed, cóż, musimy pamiętać, że klasa to funkcja-konstruktor i to ona – obiekt w JS jak wszystko inne – jest sealed. SealMe.prototype – tam nic nie dopiszemy. Do obiektów tak.

Mam też nadzieję, że ogarniamy, że wiele rzeczy TSa (interfejsy, prywatne/publiczne properties) istnieje tylko w TS. W JS nie ma tego.

Ok, bez zbędnego przedłużania, pokazujemy teraz przykład dekoratora, który może zwrócić klasę z jakimiś zmianiami:

function reportableClassDecorator<T extends { new (...args: any[]): {} }>(constructor: T) {
    return class extends constructor {
      reportingURL = "http://www...";
    };
  }
   
  @reportableClassDecorator
  class BugReport {
    type = "report";
    title: string;
   
    constructor(t: string) {
      this.title = t;
    }
  }
   
  const bug = new BugReport("Needs dark mode");
  console.log(bug.title); // Prints "Needs dark mode"
  console.log(bug.type); // Prints "report"
//   console.log(bug.reportingURL);

I tutaj ważna uwaga, konstruktora nie ruszyliśmy, to raz. Dwa, reportingURL będzie całkowicie niewidoczny dla TS, próba wypisania go wywoła błąd. Ale w JS (np. w konsoli w devtoolsach) już możemy się tym bawić.

Tym niemniej poznaliśmy patent, jak robić dekorator. Wystarczy trochę pokombinować i wpadniemy na coś takiego:

function sealed(constructor: Function) {
    Object.seal(constructor);
    Object.seal(constructor.prototype);
  }

function stupidLog<T extends { new (...args: any[]): {} }>(originalConstructor: T){
    return class extends originalConstructor {
        constructor(...args: any[]){
            super(args);
            console.log("New object created");
        }
    }
    
}

@stupidLog
@sealed
class SealMe {
  
name: string;
 
  constructor(name: string) {
    this.name = name;
  }
}


let seal1 = new SealMe("John");
let seal2 = new SealMe("Jane");

//New object created x2
console.log(Object.isSealed(seal1)); //false
console.log(Object.isSealed(seal2)); //false

Już chyba widzimy, co się tu dzieje. Ten console log odpalany jest teraz za każdym razem, gdy nowy obiekt jest tworzony. A jak sobie poradzić z sealowaniem nowych obiektów?

Do tego to nawet dekorator nie jest potrzebny:

@stupidLog
@sealed
class SealMe {
  
name: string;
 
  constructor(name: string) {
    this.name = name;
    Object.seal(this);
  }
}


let seal1 = new SealMe("John");
let seal2 = new SealMe("Jane");

//New object created x2
console.log(Object.isSealed(seal1)); //true
console.log(Object.isSealed(seal2)); //true

Żeby jednak te dekoratory miały jakikolwiek sens, powinny one przyjmować jakieś argumenty i coś konkretnego robić. Ok, napisałem taki przykład, postarajmy się jakoś to ogarnąć:

function WithRender(hookId: string) {
    return function<T extends { new (...args: any[])}>(
      originalConstructor: T
    ) {
      return class extends originalConstructor {
        constructor(...args: any[]) {
          super(...args);
          const hookEl = document.getElementById(hookId);
          if (hookEl) {
            let li = document.createElement("li");
            li.textContent = `Name: ${this.name} Age: ${this.age}`;
            hookEl.appendChild(li);
          }
        }
      };
    };
  }

Tu jest kolejna warstwa abstrakcji, sam kod po ostatnim returnie raczej nie powinien przerażać, ale tak, ten hookId to w dekoratorze będzie argument. Zwracamy funkcję dekorator, która zwraca nową klasę, której konstruktor został wyposażony o nowe możliwości.

Dalej wygląda to tak:


@WithRender('ul-app')
class PersonItem {
    public name: string;
    public age: number;

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

let john = new PersonItem("John", 30);
let jane = new PersonItem("Jane", 20);

Czyli prosto. Ok, dekoratory trochę przypominają callback hell więc możemy choć trochę sobie to uprościć jeszcze:

function WithRender(hookId: string) {
    return function<T extends { new (...args: any[])}>(
      originalConstructor: T
    ) {
      return class extends originalConstructor {
        constructor(...args: any[]) {
          super(...args);
          this.mount();
          
        }
        mount(){
          const hookEl = document.getElementById(hookId);
          if (hookEl) {
            let li = document.createElement("li");
            li.textContent = `Name: ${this.name} Age: ${this.age}`;
            hookEl.appendChild(li);
          }
          console.log("Mounted");
        }
      };
    };
  }

Podzieliliśmy to logicznie, bo de facto nie chcemy nic w konstruktorze zmieniać, poza odpaleniem mount i tyle. I jasne, można to napisać w klasie, bez dekoratora, natomiast całe piękno tego rozwiązania polega na tym, że my ten dekorator możemy podpinać do każdej klasy.

Więcej TSa niedługo!