LoginSignup
199
198

More than 1 year has passed since last update.

【TypeScript】デコレータを深く学んでみる

Last updated at Posted at 2022-01-22

はじめに

今回はデコレータについて深く学んでみます。

@(アットマーク)から始まるあれです。

Angularなどを使用されたことがあれば、見慣れていらっしゃるかもしれません。

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html',
  styleUrls: ['./app.component.css']
})

現在は非推奨ですが、Nuxtの nuxt-property-decorator などでも使われていたりします。

デコレータとは?

まず公式のリンクです。

デコレータとはメタプログラミングに役に立つ機能です。

メタプログラミングとは
ユーザが直接触ったり見たりする機能には使われませんが、
開発者が使いやすい道具を提供することに向いています。

例えば、クラスやクラスのメソッドが正しく使われることを保証したり、
表向きには見えない変換処理を行なったりします。

JavaのSpringなどを触ったご経験がある方は、
アノテーションというとイメージしやすいと思います。

また、デコレータは充てる場所によって受け取れる引数などが変わります。

- デコレータはメタプログラミングに役立つ機能
- メタプログラミングとは
    - ユーザが直接触ったり見たりする機能には使われず、開発者が使いやすい道具を提供することに向いている
    - クラスやクラスのメソッドが正しく使われることを保証
    - 表向きには見えない変換処理を行う
- デコレータはあてる場所によって受け取れる引数などが変わる

デコレータを追加できる場所

では、デコレータはどんな所に追加できるかといいますと、 Class内のほぼ全てに追加できる のです。

以下がデコレータを追加できる場所です。

  • class
  • property
  • accessor(getter/setter)
  • method
  • parameter

先ほども申しましたが、これら追加する場所によって引数で受け取れる値が変わります。

詳しくは後述いたします。

クラス・デコレータ: Class Decorators

まずは、クラス・デコレータから見てみましょう。

コードにすると以下のようになります。

// クラス全体へのデコレータは引数にコンストラクター関数を受ける
// @Loggerの処理を記述している
function Logger(constructor: Function) {
    console.log('ログ出力中...');
    console.log(constructor);
}

// @マークから始める
// functionに引数を書いた時に()は不要
@Logger
class Person {
    name = 'Tom';

    constructor() {
        console.log('Personオブジェクトを作成中...');
    }
}

const pers = new Person();
console.log(pers);

解説

1つ1つコードを追っていきます。

デコレータ関数を作る

上にLogger関数を作っていますが、今回これがデコレータ関数となります。

こちらの部分です。

// クラス全体へのデコレータは引数にコンストラクター関数を受ける
// @Loggerの処理を記述している
function Logger(constructor: Function) {
    console.log('ログ出力中...');
    console.log(constructor);
}

この関数を直接使うのではなく、代わりにクラスに対してデコレータとして追加します。

今回の例で関数を大文字から始めていますが、
必須ではなく小文字から始めても問題ないです。

ただし、多くのライブラリのデコレータは慣例で大文字から始まっているようです。

デコレータは最終的にはただの関数です。
その関数を特定の方法で例えば今回はクラスに適応するということです。

デコレータ関数の使用

デコレータ関数を定義した後は使用方法についてですが、
デコレータとして使用するには対象のクラスの前に@をつけます。
@はデコレータを認識するための特別な識別子で、この@の後ろに関数名を指定します。

こちらの部分で定義されたLogger関数を使用しています。

// @マークから始める
// functionに引数を書いた時に()は不要
@Logger
class Person {
    name = 'Tom';

    constructor() {
        console.log('Personオブジェクトを作成中...');
    }
}

デコレータは充てる場所によって受け取れる引数など変わると申しましたが、
今回のようにクラスへ充てるDecoratorの場合、受け取れる引数はコンストラクタ関数です。

コンストラクタ関数については後述いたします。

デコレータが実行される順番について

またデコレータが実行される順番ですが、上記のコードをLogに吐き出すと以下のようになります。

Screen Shot 2022-01-22 at 19.48.38.png

console.logの横に順番を振っている通り、
このコードでは上から順番になります。

注目するポイントは、1番目の「ログ出力中」と
2番目の「コンストラクタ関数」の出力が
Person class内のconstructorより前に表示されている点です。

これは何を意味しているかというと、
デコレータはJavaScriptがクラスの定義を見つけた時に実行されます。

インスタンス化のタイミングではありません。
例えばインスタンス化しているnewの行を
削除してもLoggerデコレータの1と2のコンソールログは出力されます。

コンストラクタ関数とは

では、先程登場したコンストラクタ関数について解説いたします。

そもそもコンストラクターとは?
ですが、よくclassに書かれているあれになります。

これです。

// 完全コンストラクタパターン
export class FormData {
  // ここがコンストラクター
  constructor(private _personal: PersonalData, private _survey: SurveyData) {}

  get personal(): PersonalData {
    return this._personal;
  }

  get survey(): SurveyData {
    return this._survey;
  }

  hasPersonalData(): boolean {
    return !isEmpty(this._personal);
  }

  hasSurveyData(): boolean {
    return !isEmpty(this._survey);
  }
}

コンストラクターとはインスタンス(実体)を作成する関数のことで、
初期化も行います。

JavaScriptにはClassが存在しない

ここで1つ疑問が浮かびます。
「でも、JSってClassないよね?」
という点です。

疑問に思われた方はその通りで、
JSでは「class hoge」と記述はできますが、
プロトタイプオブジェクト指向言語のため、
内部的にはfunctionであくまでプロトタイプ構文を覆い包む
シンタックスシュガーでしかないのです。

プロトタイプオブジェクト指向については、
以下が分かりやすかったのでご参考ください。

ES6以前の記述方式は、
以下のようにfunctionで記述されており、
擬似的なクラスでしかありません。

ES6以前
function Person(name, age) {
  this.name = name;
  this.age = age;
}

上記のthisを不思議に思われるかもしれませんが、
JSは変数や関数だけでなく、
数値や文字列などに至るまで全てがオブジェクトになっており、
JSではnewの式を実行すると暗黙的に新規のオブジェクトが作られ、
thisへ代入されます。

そして暗黙的にthisがreturnされます。

このようにコンストラクタ関数は技術的には通常の関数であり、
つまりは、new式を使用して新規オブジェクトを作成する関数であるといえます。

デコレータファクトリーとは

続いては、デコレータファクトリーについてです。

先程はクラスデコレータを作成することができましたが、
この他にもデコレータファクトリというものを定義することができます。

デコレータを何かに割り当てる時に、
デコレータをカスタマイズできるようにするものです。

では、先程のクラスデコレータをデコレータファクトリに変更してみます。

デコレータファクトリー:基礎編

まず先程のLogger関数に匿名関数を返すようにします。
そして、匿名関数の引数にコンストラクタ関数の引数を追加します。

デコレータ関数
const Logger = (logString: string) => {
  // 匿名関数の中には、このデコレータで実行したい処理を書く
  return function (constructor: Function) {
    console.log(logString);
    console.log(constructor);
  };
};

// 呼び出し時に関数として実行する必要がある
// これは外側のLogger関数を実行している
@Logger('Loggerの引数: ログ出力中')
// Logger関数から返される新しい匿名関数をPersonクラスのデコレータとして適応している
export class Person {
  name = 'Tom';

  constructor() {
    console.log('Personオブジェクト作成中');
  }
}

この匿名関数の中には、
このデコレータで実行したい処理を書きます。

これで新しい関数を返す関数ができました。

従ってこれをデコレータとして適応する場合には
呼び出し時に関数として実行する必要があります。

@Loggerの後に()を付けますが、
これはもちろん内側の匿名関数ではなく外側のLogger関数を実行しています。

そして、Logger関数から返される新しい匿名関数を
Personクラスのデコレータとして適応しています。

では、なぜこのデコレータファクトリをするのかというと、
例えば、任意の引数を必要な数だけ
1行目のLoggerの引数として指定することができます。

そして、呼び出し時に引数を渡せれるので、
引数として渡した値をLogger関数内でいかようにも使うことができます。

このようにデコレータファクトリを使うことで
デコレータの内部で行うことをカスタマイズすることができます。

ログを出力した結果は以下の通りです。

Screen Shot 2022-01-22 at 20.06.32.png

複数のデコレータ

続いては、デコレータの基本的な概念についてです。
クラスには複数のデコレータが追加できます。

@Logger('Loggerの引数: ログ出力中')
@WithTemplate('<h1>Personオブジェクト</h1>', 'app')
export class Person {
  name = 'Tom'

  constructor() {
    sonsole.log('Personオブジェクト作成中')
  }
}

このように、あらかじめ定義されたデコレータを複数追加できます。

では、このように複数のデコレータをあてた場合実行順はどのようになるのでしょうか?

複数デコレータの実行順番

例えば以下のようなコードがあったとします。

最初にデコレータファクトリーを定義して、
その下でクラスデコレータを充てているケースだとします。

const Logger = (logString: string) => {
  return function (constructor: Function {
    console.log(logString);
    console.log(constructor);
  }
};

const WithTemplate = (template: string, hookId: string) => {
  return function (constructor: any) {
    const p = new constructor();
    const hookEl = document.getElementById(hookId);
    if (!hookEl) {
      return;
    }
    hookEl.innerHTML = template;
    hookEl.querySelector('h1')!.textContent = p.name;
  };
};

@Logger('Loggerの引数: ログ出力中')
@WithTemplate('<h1>Personオブジェクト<h1/>', 'app')
export class Person {
  name ='Tom';

  constructor() {
    console.log('Personオブジェクト作成中');
  }
}

上記の実行の順番ですが、
これは、classに近い方から
WithTemplate > Loggerのように「下から上に実行」されます。

ただし、これは内側の匿名関数が実行される順番なので、
外側のデコレータ関数はそれよりも前に実行されるため
通常のJavaScriptのルール通り記述された順番通りになります。

なぜなら@Logger()としているため、
内部のデコレータ関数ではなくLogger関数自体を実行しているからです。

つまり、

Loggerの外側 >
WithTemplateの外側 > 
WithTemplateの匿名関数 >
Loggerの匿名関数

となります。

プロパティ・デコレータ: Property Decorators

続いては、プロパティ・デコレータについてです。

/**
 * デコレータファクトリーではなく、デコレータ関数として定義
 * @param {instanceならこのclassのprototype、staticならconstructor} target
 * @param {string | Symbol} propertyName
 */
const Log = (target: any, propertyName: string | Symbol) => {
  console.log('Property Decorator');
  console.log('target', target);
  console.log('propertyName', propertyName);
};

export class Product {
  // プロパティにデコレータを追加している
  @Log
  title: string;
  private _price: number;

  set price(val: number) {
    if (val < 1) {
      throw new Error('不正な価格です');
    }
    this._price = val;
  }

  constructor(title: string, price: number) {
    this.title = title;
    this._price = price;
  }

  getPriceWithTax(tax: number): number {
    return this._price * (1 + tax);
  }
}

2つの引数を受け取る

プロパティ・デコレータの場合、2つの引数を受け取ります。

1つ目はtargetです。
targetに何が渡されるかは、
インスタンスプロパティかstaticプロパティかで変わります。

インスタンスプロパティへデコレータを設定した場合、
このクラスのプロトタイプが渡されます。
staticプロパティの場合はコンストラクタ関数が渡されます。

2つ目の引数は、単純にpropertyNameが渡されます。
これはシンプルにstringかもしれませんしSymbolである可能性があります。
この時点ではどちらかわかりません。

実際に、このログを出力すると右下のようになります。

Screen Shot 2022-01-22 at 20.30.51.png

まず最初のTextのみのログが出力されて、
targetはプロトタイプが渡されています。

そして、propertyNameは「title」が表示されています。

これらlogはインスタンス化していなくても出力されます。
なぜなら冒頭お伝えしたように、
デコレータはJavaScriptでクラス定義が登録された時に実行されるからです。

つまり、このプロパティを持っているコンストラクタ関数が
JavaScriptで作られた時です。

アクセサ・デコレータ: Accessor Decorators

続いてアクセサデコレータです。

/**
 * @param {instanceならprototype、staticならconstructor} target
 */
const Log = (
  target: any,
  accessorName: string,
  descriptor: PropertyDescriptor,
) => {
  console.log('Accessor デコレータ');
  console.log('target', target);
  console.log('accessorName', accessorName);
  console.log('descriptor', descriptor);
};

export class Product {
  title: string;
  private _price: number;

  @Log
  set price(val: number) {
    if (val < 1) {
      throw new Error('不正な価格です');
    }
    this._price = val;
  }

  constructor(title: string, price: number) {
    this.title = title;
    this._price = price;
  }

  getPriceWithTax(tax: number): number {
    return this._price * (1 + tax);
  }
}

引数

3つの引数を受け取ります。

1つ目は、targetでプロパティデコレータと一緒です。
2つ目は、アクセサのnameを取ります。
3つ目は、プロパティディスクリプタ(PropertyDescriptor)です。
これはTypeScriptに組み込まれている型です。

出力結果は以下のようになります。

Screen Shot 2022-01-22 at 20.32.46.png

PropertyDescriptor

ちなみにTypeScriptで定義されているPropertyDescriptorへ飛ぶと以下のようになっています。

interface PropertyDescriptor {
    configurable?: boolean; // 直訳:設定可能 trueの時、ディスクリプタ変更や、オブジェクトからプロパティ削除が可能
    enumerable?: boolean; // 直訳:列挙可能 trueの時、そのプロパティはkeysやfor...in...によるプロパティ列挙に現れる
    value?: any;  // プロパティの値。データディスクリプタのみ
    writable?: boolean;  // 直訳:書き込み可能 trueの時、値を変更可能。データディスクリプタのみ
    get?(): any; // Getter関数、アクセサディスクリプタのみ
    set?(v: any): void;  // Setter関数、アクセサディスクリプタのみ
}

メソッド・デコレータ: Method Decoratos

続いてメソッドデコレータです。

/**
 * @param {instanceならprototype、staticならconstructor} target
 */
const Log = (
  target: any,
  methodName: string | Symbol,
  descriptor: PropertyDescriptor,
) => {
  console.log('Method デコレータ');
  console.log('target', target);
  console.log('methodName', methodName);
  console.log('descriptor', descriptor);
};

export class Product {
  title: string;
  private _price: number;

  set price(val: number) {
    if (val < 1) {
      throw new Error('不正な価格です');
    }
    this._price = val;
  }

  constructor(title: string, price: number) {
    this.title = title;
    this._price = price;
  }

  @Log
  getPriceWithTax(tax: number): number {
    return this._price * (1 + tax);
  }
}

引数

受け取れる3つの引数はアクセサデコレータとほぼ一緒ですので、
解説を省略いたします。

パラメータ・デコレータ: Paramerter Decorators

続いてパラメータデコレータです。

/**
 * @param {instanceならprototype、staticならconstructor} target
 */
const Log = (
  target: any,
  methodName: string | Symbol,
  position: number
) => {
  console.log('Parameter デコレータ');
  console.log('target', target);
  console.log('methodName', methodName);
  console.log('position', position);
};

export class Product {
  title: string;
  private _price: number;

  set price(val: number) {
    if (val < 1) {
      throw new Error('不正な価格です');
    }
    this._price = val;
  }

  constructor(title: string, price: number) {
    this.title = title;
    this._price = price;
  }

  getPriceWithTax(@Log tax: number): number {
    return this._price * (1 + tax);
  }
}

パラメータデコレータはパラメータの前に付けます。

パラメータのデコレータは1つの引数に対してではなく、
全てのパラメータに対して追加でき、それぞれのパラメータに独立して別々にデコレータを設定できます。

引数

受け取れる引数は3つです。

1つ目はtargetです。
これも今までと同じです。

2つ目はMethodNameです。
パラメータの名前ではないため注意が必要です。

3つ目はパラメータの位置です。
ゼロ始まりです。

以下が実際のlogです。

Screen Shot 2022-01-22 at 20.41.32.png

以上でデコレータを追加できる箇所全てを学びました。

そのほかにもクラスデコレータによるクラスの変更が可能だったり、
メソッドデコレータによるPropertyDescriptorの変更だったりあるのですが今回は以上となります。

お読み頂き有難うございました。

199
198
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
199
198