ESNext Stage 2 Decorators の変遷と最新仕様


はじめに

個人的に TC39 meeting をウォッチしてまとめている @printf_moriken です。

https://scrapbox.io/petamoriken/

ESNext の Decorators の提案は何度も改定しています。その割にあまり知れ渡っていません。

この記事ではその変遷と2019年6月現在における最新の Decorators について簡単にまとめようと思います。



https://github.com/tc39/proposal-decorators/issues/284#issuecomment-499783929


最初の提案(2014年~2015年頃)

最初の Decorators の提案はこのような形式をしていました。

class Person {

@nonenumerable
get kidCount() { return this.children.length; }
}

function nonenumerable(target, name, description) {
descriptor.enumerable = false;
return descriptor;
}

Decorators 自体はただの函数で、引数にクラスとプロパティ名そしてプロパティディスクリプタを受け取り、そのプロパティディスクリプタを加工して返すようになっています。こうすることでクラスの prototypeObject.defineProperty される前に割り込むことが出来ます。


実装

TypeScript で experimentalDecorators フラグを付けた場合や、Babel の @babel/plugin-proposal-decoratorslegacy フラグを付けた場合はこの仕様をもとにトランスパイルされます。


問題点

この提案ではクラス自体やクラスのメソッドに Decorators を適用することしか考慮されていません。ESNext の他の提案として進んでいる Public/Private Class Field Declarations や Static Class Features に対応しきれません。


ディスクリプタベースの提案(2016年頃〜2019年3月)

最初の提案は Decorators に渡す引数が複数あったのが問題だったので、ただ一つのオブジェクトを渡すようにしたのがこの提案です。

class Counter extends React.Component {

state = {
count: 0
};

@bound
handleClick() {
this.setState({ count: this.state.count + 1 });
}

render() {
// 本来なら () => this.handleClick() と記述するが @bound の効果で不要になる
return <div onClick={this.handleClick}>{this.state.count}</div>;
}
}

// https://github.com/mbrowne/bound-decorator
// あらかじめ bind されたメソッドをインスタンスに直接生やす Decorator
function bound(elementDescriptor) {
const { kind, key, method, enumerable, configurable, writable } = elementDescriptor;
if (kind !== "method") {
throw new Error("Unexpected kind");
}

const initialize =
// private メソッドに対しての場合は key がオブジェクトになる
typeof key == "object"
// private メソッドの場合はメソッドが変更されることがないためそのまま bind する
? function() { return method.bind(this) }
// public メソッドの場合は prototype が変更される可能性があるので実行時にメソッドを取得する
: function() { return this[key].bind(this) };

// 副作用を起こさないように prototype にはメソッドをそのまま残し extras でインスタンスに bound された函数を追加する
elementDescriptor.extras = [
{ kind: "field", key, placement: "own", enumerable, configurable, writable, initialize }
];
return elementDescriptor;
}

この例ではクラスのメソッドに Decorators を適用していますが Decorators 函数にやってくるオブジェクトの kind プロパティによって、メソッドなのかプロパティなのかはたまたクラス自身に適用されたのかを識別する事ができます。

それぞれのインターフェースは以下のようになってます。

interface MethodDescriptor {

kind: "method";
key: string | symbol | Object;
placement: "prototype" | "static";

method: Function;

// property descriptor
writable: boolean;
configurable: boolean;
enumerable: boolean;
}

interface AccessorDescriptor {
kind: "accessor";
key: string | symbol | Object;
placement: "prototype" | "static";

get: () => any;
set: (val: any) => void;

// property descriptor
configurable: boolean;
enumerable: boolean;
}

interface FieldDescriptor {
kind: "field";
key: string | symbol | Object;
placement: "own" | "static";

initialize: () => void | undefined;

// property descriptor
writable: boolean;
configurable: boolean;
enumerable: boolean;
}

interface ClassDescriptor {
kind: "class";
elements: Array<MethodDescriptor | ClassDescriptor | FieldDescriptor>;
}


実装

現時点での Babel(v7.1+) の @babel/plugin-proposal-decorators ではこの仕様をもとにトランスパイルされます。


問題点

Decorators のすべての場合を対応するために仕様がとても複雑になってしまいました。上手く扱うためにはこの複雑な仕様を完全に理解する必要がありますし、特に致命的な問題として実行時の速度がとても遅くなってしまいました


ビルトインベースの提案(2019年3月〜)

今までをふまえてすべての場合に複雑でない形で対応し、なおかつ実行速度を遅くしないような Decorators の仕様考える必要がありました。この提案ではビルトインな Decorators として @wrap, @register, @initialize, @expose の4つを基本とし、カスタム Decorators を作る場合はそれを組み合わせて作れるようにします。

例えば @wrap をクラスのメソッドに付けると以下のようになります。

class C {

@wrap(f)
method() { }
}

// ↓ と等価

class C {
method() { }
}
C.prototype.method = f(C.prototype.method);

この @wrap を使って、メソッドが呼ばれる度に console.log にログを残すカスタム Decorator の @logged を作るとこうなります。

// decorator @foo { @bar @baz @bing } のような形式でカスタム Decorator を作る

decorator @logged {
@wrap(method => {
const name = method.name;
function wrapped(...args) {
console.log(`starting ${name} with arguments ${args.join(", ")}`);
method.call(this, ...args);
console.log(`ending ${name}`);
}
wrapped.name = name;
return wrapped;
})
}

class C {
@logged
method(arg) {
this.#x = arg;
}

@logged
set #x(value) { }
}

new C().method(1);
// starting method with arguments 1
// starting set #x with arguments 1
// ending set #x
// ending method

これらの基本の4つのビルトイン Decorators ではそれぞれが一つの函数を受け取る形になっています。そしてその Decorators に渡した函数が受け取る引数と返り値をそれぞれの場合で独立させて意味を定義することで仕様を単純化しています。

またこれらの Decorators は静的解析のみで既存の ECMAScript のコードに変換できるようになっています。ディスクリプタベースのときのように Decorators 函数の返り値によってクラスのメソッドの定義を削除したり変更したり別のものを追加したり……みたいなことが起きず、単に函数を通すだけです。これによって実行時に遅くなることを防いでいます。

現在のこの仕様について詳しくは TC39 公式の提案リポジトリを御覧ください。

https://github.com/tc39/proposal-decorators/


実装

まだありません。Babel にこの最新仕様をいれるための道筋は立てられているみたいです。

https://hackmd.io/44ErLPn8Qi6FshyoTcrXcA


おわりに

最新の Decorators の仕様では今までと全然違う形で提供されるようになっています。2019年3月の TC39 meeting の資料を見たときは本当に驚きました。

Angular や NestJS といった TypeScript のフレームワークでは一番古い Decorators がそのまま使われています。ESNext の Decorators の仕様が変わるとフレームワークやライブラリの恩恵を受けている人たち……というよりかはフレームワークやライブラリを制作している人たちの対応が迫られますが、たとえ利用者であっでも今 Decorators を採用する場合、新しい Decorators の実装が広まっていったときにちゃんと追っていける覚悟が必要になるのかなと思います。

ESNext の特に Stage 1, 2 についてはこのように仕様が大きく変更されることがあります。みなさんも2ヶ月ごとに開催される TC39 meeting をチェックしてみてはいかがでしょうか。

最後に宣伝となりますが個人的に Scrapbox で ESNext の動向をまとめています。また pixivFANBOX にて今月開催された TC39 meeting について要点をまとめていますので、よければこちらも見ていただけると嬉しいです。

https://scrapbox.io/petamoriken/

https://www.pixiv.net/fanbox/creator/2656022/post/428539