Edited at

JavaScript の デコレータ の使い方

2019/03/17 現在はまだ Stage 2 (Draft) の段階ですが、面白そうなのでメモ。

自分なりに分かりやすくまとめてみようと思います。

参考「Decorators proposal

参考「GitHub - tc39/proposal-decorators: Decorators for ES6 classes


1. デコレータとは何か

クラスやメソッドに機能を追加するもの。

(Java でいうアノテーション)


2. 基本的な使い方

デコレータは関数で定義します。

(ここではアロー関数を使っていますが、普通の function でも大丈夫です)


2.1. クラス


定義

const F = target => {

// target: クラス
// return target; // なくても良い
};


適用

@F // ★

class Foo {

}



やっていること

class Foo {

}

Foo = F(Foo) || Foo; // ★



2.2. メソッド


定義

const F = (target, name, descriptor) => {

// target: クラスの prototype (※ this の代わりに使うとハマることがある)
// name: メソッド名
// descriptor: メソッドのディスクリプタ
// descriptor.value: メソッドそのもの
// descriptor.writable: false なら const
// return descriptor; // なくても良い
};


適用

class Foo {

@F // ★
bar() {}
}


やっていること

class Foo {

bar() {}
}

// ★
(() => {
let bar = Object.getOwnPropertyDescriptor(Foo.prototype, 'bar');
bar = F(Foo.prototype, 'bar', bar) || bar;
if (bar) Object.defineProperty(Foo.prototype, 'bar', bar);
})();


Object.getOwnPropertyDescriptor(), Object.defineProperty() はディスクリプタを読み書きする関数。

※ディスクリプタの enumerable, configurable は普段使わないと思うので省略


2.3. アクセサ

ほぼメソッドと同じ (ディスクリプタの内容が異なる) 。


定義

const F = (target, name, descriptor) => {

// target: this
// name: メソッド名
// descriptor: メソッドのディスクリプタ
// descriptor.get: getter そのもの
// descriptor.set: setter そのもの
// return descriptor; // なくても良い
};


適用

class Foo {

@F // ★
get bar() {}
set bar(value) {}
}


やっていること

class Foo {

get bar() {}
set bar(value) {}
}

// ★ (メソッドと同じ)
(() => {
let bar = Object.getOwnPropertyDescriptor(Foo.prototype, 'bar');
bar = F(Foo.prototype, 'bar', bar) || bar;
if (bar) Object.defineProperty(Foo.prototype, 'bar', bar);
})();


Object.getOwnPropertyDescriptor(), Object.defineProperty() はディスクリプタを読み書きする関数。

※ディスクリプタの enumerable, configurable は普段使わないと思うので省略


2.4. オブジェクトのメソッド・アクセサ

オブジェクト定義の中ののメソッド・アクセサでも同様に使用することができます。


適用

const Foo = {

@F // ★
bar() {},

@F // ★
get baz() {},
set baz(value) {}

};



3. もう少し進んだ使い方


3.1. デコレータに引数を持たせる

デコレータを返す関数を定義する。


定義

const F = param => (target, name, descriptor) => {

// ...
};


適用

class Foo {

@F('Param') // ★ F('Param') == (target, name, descriptor) => {/* ... */}
bar() {}
}


3.2. 複数のデコレータを同時に適用する

適用したいクラス・メソッド・アクセサの宣言に近いものから順に適用されます。


適用

class Foo {

// ★ F → G → H
@H
@G
@F
bar() {}

}



4. 具体的な使用例


4.1. @readonly

どの場面でも使えそうだったので具体例として挙げてみました。

// 定義

const readonly = (target, name, descriptor) => {
descriptor.writable = false;
};

// 適用
class Foo {

bar() {}

@readonly // ★
baz() {}

}

//
const foo = new Foo();

foo.bar = () => {};
foo.baz = () => {}; // ★エラーが発生する


4.2. メソッドの前後に何かする

※これはハマりポイントがいくつかあったので注意が必要です。


4.2.1. 正しく動作する例

// 定義

const F = (target, name, descriptor) => {
if ( 'value' in descriptor ) {
const old = descriptor.value;
descriptor.value = function(...args) {
console.log(name + '(): begin'); // 何か
const result = old.apply(this, args);
console.log(name + '(): end'); // 何か
return result;
};
}
};

// 適用
class Foo {

constructor() {
this.baz = 'Baz';
}

@F // ★
bar() {
console.log(this.baz);
}

}

//
const foo = new Foo();

foo.bar();


コンソール

bar(): begin

Baz
bar(): end


4.2.2. descriptor.value をそのまま使えない問題

// 定義

const F = (target, name, descriptor) => {
if ( 'value' in descriptor ) {
const old = descriptor.value; // 取り出す
descriptor.value = (...args) => {
return old(...args); // typeof this === 'undefined'
};
}
};

取り出した関数内の thisthis として使えなくなるため。

参考「束縛された関数を生成する - Function.prototype.bind() - JavaScript | MDN


4.2.3. bind(target) しても正しく動作しない問題

target == this ではありません。

// 定義

const F = (target, name, descriptor) => {
if ( 'value' in descriptor ) {
const old = descriptor.value.bind(target); // bind(target)
descriptor.value = (...args) => {
const result = old(...args); // this === target === Foo.prototype
return result;
};
}
};

// 適用
class Foo {

constructor() {
this.baz = 'Baz';
}

@F // ★
bar() {
console.log(this.baz); // typeof this.baz === 'undefined'
}

}


prototype と this の違い

class Foo {

constructor() {
this.baz = 'Baz';
}
bar() {}
}

//
const foo = new Foo();

console.log(foo); // this == {baz: "Baz", __proto__: Foo.prototype}
console.log(Foo.prototype); // prototype == {constructor: f, bar: f, __proto__: Object.prototype}


prototype の仕組みに関しては「プロトタイプチェーン」で調べてみてください。


4.2.4. アロー関数では呼び出し元の this が分からない問題

多くの場合は、this を束縛しないアロー関数の方が function より便利ですが、今回ばかりは function を使わざるを得ません。

デコレータが実行される段階ではクラスはインスタンス化されていないので、this を知るすべがありません。

Object のプロパティやメソッドに this を知るための機能もないので、デコレータ内で定義する関数内で this を調べることになりますが、それはアロー関数ではなく function でないとできません。


「正しく動作する例」再掲 (引用)

// 定義

const F = (target, name, descriptor) => {
if ( 'value' in descriptor ) {
const old = descriptor.value; // prototype からメソッド取得
descriptor.value = function(...args) { // アロー関数ではなく function
const result = old.apply(this, args); // new 後に呼ばれたときに this が決定される
return result;
};
}
};