Help us understand the problem. What is going on with this article?

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

はじめに

個人的に TC39 meeting をウォッチしてまとめている @printf_moriken です。
https://scrapbox.io/petamoriken/

ESNext の Decorators の提案は何度も改定しています。その割にあまり知れ渡っていません。
この記事ではその変遷と2020年4月現在における最新の 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 に対応しきれません。

Descriptor-based Decorators の提案(2016年〜2018年頃)

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

Static 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

【2019/9/23 追記】

Babel への実装が始まりました。
https://github.com/babel/babel/pull/10388

【2020/4/1 追記】

フィードバックを得た結果、問題点が共有されました。

問題点

記述するには十分複雑だし、V8 チームによるとそこまで静的ではなく、モジュールの読み込み分のコストが大きくなってしまうとのこと。十分速さに寄与しませんでした。

Read/Write Trapping Decorators の提案(2019年12月〜)

Static Decorators でも十分な速さを得ることが出来なかったため、的を絞って getter/setter のみトラップすることが出来るようにしたのがこの提案です。Proxy を使った場合と似ているため速く実行できると見積もられています。今までの Decorators の提案みたいに enumerable を変えるようなことが出来ないため、表現力はありませんが 95% のユースケースはカバーできるだろうと考えられています。

function logged() {
  return {
    get(target, instance, property, value) {
        console.log(`GET`, target, instance, property, value);
        return value;
    },
    set(target, instance, property, value) {
        console.log(`SET`, target, instance, property, value);
        return value;
    },
  };
}

class C {
  @logged x = 3;
}

Class Decorators についてはまた別のタイプの函数を用意するようです。

function defineElement(name, options) {
  return (klass) => {
    customElements.define(name, klass, options);
    return klass;
  }
}

@defineElement('my-class')
class MyClass extends HTMLElement { }

実装

まだありません。

おわりに

Decorators の仕様はどんどん移り変わっています。2019年3月の TC39 meeting の資料、そして2020年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

printf_moriken
フロントエンド、アプリなど。
https://moriken.dev
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした