以下の内容はhttps://susisu.hatenablog.com/より取得しました。


Stage 3 Decorators のことを思い出す時に読む記事

デコレータの Proposal が Stage 3 になってから約 4 年, TypeScript がサポートしてから約 3 年経っているにも関わらず, 未だに普段使いしなさすぎて全く使い方を覚えられていません. ということで TypeScript での使い方を中心に覚える / 忘れたら読んで思い出すために記事を書いておきます.

なお具体的なユースケースについてはほぼ触れませんので悪しからず. それについてはまたの機会に...

仕様の本体 = ECMAScript への Proposal は以下.

TypeScript のリリースノートは以下.

Stage 3 に至るまでの変遷については以下が詳しいです.

デコレータ

デコレータを付与できる対象は,

  • クラス
  • メソッド
  • getter
  • setter
  • フィールド
  • auto-accessor

の 6 種類です.

@classDecorator
class MyClass {
  @methodDecorator
  myMethod(): void {
    // ...
  }

  @getterDecorator
  get myValue(): number {
    return 0;
  }

  @setterDecorator
  set myValue(value: number) {
    // ...
  }

  @fieldDecorator
  myField: number = 0;

  @accessorDecorator
  accessor myAccessor: number = 0;
}

private (TypeScript の private 修飾子ではなく # をつけたやつのこと) であったり static なメソッドやフィールドに対しても付与できます.

コンストラクタに対するデコレータや, クラスではない単なる関数に対するデコレータはありません. まあ前者はそもそもクラス自体と同じものですし, 後者はだいたい高階関数で十分そうですね. コンストラクタやメソッドのパラメータに対するデコレータは TypeScript の experimental decorators にはありましたが, Stage 3 では削除されています.

Auto-accessor

デコレータと同じ Proposal では, auto-accessor という新しい種類のフィールドも提案されています. これはフィールドの前に accessor 修飾子を付与することで定義できて, 例えば,

class MyClass {
  accessor myAccessor: number = 0;
}

というコードは,

class MyClass {
  #myAccessor: number = 0;

  get myAccessor(): number {
    return this.#myAccessor;
  }

  set myAccessor(value: number) {
    this.#myAccessor = value;
  }
}

と概ね同等です (実際には #myAccessor が private なフィールドとして見えることもありません).

通常のフィールドに対するデコレータでは get や set をフックすることができませんが, auto-accessor に対するデコレータではこれらをフックできます.

TypeScript での型

それぞれ最も汎用的な (と思われる) ものを示します.

function classDecorator<Class extends abstract new (...args: any) => any>(
  target: Class,
  context: ClassDecoratorContext<Class>,
): Class | void {
  // ...
}

function methodDecorator<This, Args extends any[], Return>(
  target: (this: This, ...args: Args) => Return,
  context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
): ((this: This, ...args: Args) => Return) | void {
  // ...
}

function getterDecorator<This, Value>(
  target: (this: This) => Value,
  context: ClassGetterDecoratorContext<This, Value>,
): ((this: This) => Value) | void {
  // ...
}

function setterDecorator<This, Value>(
  target: (this: This, value: Value) => void,
  context: ClassSetterDecoratorContext<This, Value>,
): ((this: This, value: Value) => void) | void {
  // ...
}

function fieldDecorator<This, Value>(
  target: undefined,
  context: ClassFieldDecoratorContext<This, Value>,
): ((this: This, initialValue: Value) => Value) | void {
  // ...
}

function accessorDecorator<This, Value>(
  target: ClassAccessorDecoratorTarget<This, Value>,
  context: ClassAccessorDecoratorContext<This, Value>,
): ClassAccessorDecoratorResult<This, Value> | void {
  // ..
}

コンテキストオブジェクト context にはデコレータを付与した対象の情報や, 各種操作を行うための関数が入っています. ClassFieldDecoratorContext を抜粋しつつ紹介するとこういう感じ.

interface ClassFieldDecoratorContext<This = unknown, Value = unknown> {
  // 対象の種類
  readonly kind: "field";
  // 対象の名前
  readonly name: string | symbol;
  // (クラス以外) 対象が static かどうか
  readonly static: boolean;
  // (クラス以外) 対象が private かどうか
  readonly private: boolean;
  // (クラス以外) 対象へアクセスするための関数群
  readonly access: {
    has(object: This): boolean;
    get(object: This): Value;
    set(object: This, value: Value): void;
  };
  // 対象が属するクラスに初期化ロジックを追加するための関数
  addInitializer(initializer: (this: This) => void): void;
  //  対象が属するクラスのメタデータオブジェクト
  readonly metadata: DecoratorMetadata;
}

Auto-accessor に対するデコレータについての ClassAccessorDecoratorTargetClassAccessorDecoratorResult の定義は以下のようになっていて, ちょうど getter / setter とフィールドに対してのものを合わせた形をしています.

interface ClassAccessorDecoratorTarget<This, Value> {
  get(this: This): Value;
  set(this: This, value: Value): void;
}

interface ClassAccessorDecoratorResult<This, Value> {
  get?(this: This): Value;
  set?(this: This, value: Value): void;
  init?(this: This, value: Value): Value;
}

できること

対象の置き換え

クラス, メソッド, getter, setter に対するデコレータでは, デコレータから戻り値を返すことで, 対象をその値で置き換えられます.

フィールドに対するデコレータでは, デコレータから関数を返すことで, フィールドの初期値を置き換えられます. 例えば以下のような感じ.

function doubled(_target: undefined): (initialValue: number) => number {
  return (initialValue) => 2 * initialValue;
}

class MyClass {
  @doubled
  myField: number = 3;
}

const obj = new MyClass();
console.log(obj.myField);
// => 6

メソッドなどの場合とは異なりデコレータから直接置き換え後の値を返すようになっていないのは, フィールドの初期値がインスタンス化のタイミングで評価されるので, デコレータ自体の評価タイミング (クラス定義時) とは異なるからですね.

ところで TypeScript では, 以下のようにフィールドをその場で初期化しなくても, コンストラクタ内でフィールドが初期化されていればコンパイルエラーになりません.

class MyClass {
  @doubled
  myField: number;

  constructor() {
    this.myField = 3;
  }
}

一方でこの場合, デコレータから返した関数の引数 initialValue には (型の上では number であるにも関わらず) undefined が入ってきます. 意図せずランタイムエラーにならないよう注意しましょう.

Auto-accessor の場合は, getter / setter の置き換えと初期値の置き換えがそれぞれ行えます.

いずれの場合も, TypeScript においては型の変更が許されていません.

対象へのアクセス

クラスを対象とするデコレータ以外については, コンテキストオブジェクトに含まれる access を使うと, 最終的な (どこかのデコレータで値を置き換えていれば置き換え後の) 対象にアクセスできます. 一見すると同じくコンテキストオブジェクトに含まれる name を使ってもアクセスできそうなものではあるんですが, 対象が private な場合はそうもいかないので, 統一的なアクセスの手段として access が提供されているというわけです.

やや人工的な例ですが, 以下のように対象へのアクセス権をどこかに渡すようなケースで使えます.

const getters = new Map<string | symbol, (obj: unknown) => unknown>();

function exposeGetter(_target: undefined, context: ClassFieldDecoratorContext): void {
  getters.set(context.name, context.access.get);
}

class MyClass {
  @exposeGetter
  publicField: number = 0;
  @exposeGetter
  #privateField: number = 1;
}

const obj = new MyClass();
console.log(getters.get("publicField")?.(obj));
// => 0
console.log(getters.get("#privateField")?.(obj));
// => 1

初期化ロジックの追加

コンテキストオブジェクトに含まれる addInitializer を使うと, クラスやインスタンスの初期化に合わせて実行されるロジックを追加できます.

Proposal ではカスタム要素を定義する例が紹介されていました. これをデコレータでやる必要があるかはさておき...

function customElement<Class extends new (...args: any) => HTMLElement>(name: string) {
  return (_target: Class, context: ClassDecoratorContext<Class>) => {
    context.addInitializer(function (this) {
      customElements.define(name, this);
    });
  };
}

@customElement("my-element")
class MyElement extends HTMLElement {
  // ...
}

メタデータの付与

コンテキストオブジェクトに含まれる metadata を使うと, 対象が属しているクラス (対象がクラスの場合はそのもの) に対してメタデータを付与できます.

例えば以下のようにすると, デコレータを付与したフィールドの名前 (キー) をクラスのメタデータに保存しておいて, あとから列挙できます. Object.keys などでもオブジェクトのキーは列挙できますが, これは意図しないものも含む可能性があるので, あらかじめ決まったキーだけ列挙したいというような場合はデコレータを使うのが便利かもしれません.

const keysKey = Symbol("keys");
function key(_target: undefined, context: ClassFieldDecoratorContext): void {
  if (context.static || context.private) {
    throw new Error("cannot register static or private keys");
  }
  const keys = (context.metadata[keysKey] as Set<string | symbol> | undefined) ?? new Set();
  context.metadata[keysKey] = keys;
  keys.add(context.name);
}

class MyClass {
  @key foo: number = 0;
  @key bar: number = 1;
  baz: number = 2;
}

console.log(MyClass[Symbol.metadata]?.[keysKey]);
// => Set(2) { 'foo', 'bar' }

なお TypeScript でトランスパイルしたコードでは, Symbol.metadata が定義されていない場合はコンテキストオブジェクトに metadata が含まれません. 大抵の環境ではまだ Symbol.metadata が未定義かと思うので, ひとまずメタデータを使う場合は以下のように polyfill しておきましょう.

// @ts-expect-error
Symbol.metadata ??= Symbol("Symbol.metadata");

ファクトリ関数

addInitializer の説明の際にしれっと登場していましたが, デコレータを作るファクトリ関数を定義することも可能です. 例えばメソッドに対するデコレータのファクトリ関数の場合は以下のような感じになります.

function factory<This, Args extends any[], Return>() {
  return (
    target: (this: This, ...args: Args) => Return,
    context: ClassMethodDecoratorContext<This, (this: This, ...args: Args) => Return>,
  ): ((this: This, ...args: Args) => Return) | void => {
    // ...
  };
}

上の例では型引数をファクトリ関数の側に書いていますが, この場合も型引数がよしなに推論されます.

class MyClass {
  // This = MyClass, Args = [], Return = void が推論される
  @factory()
  myMethod(): void {
    // ...
  }
}

参考: TypeScript の Experimental Decorators

比較用に TypeScript の experimental decorators についても簡単にまとめておきます.

ドキュメントは以下.

Experimental decorators を使う場合はコンパイラオプションで experimentalDecorators を有効にする必要があります. なお Stage 3 のデコレータとの共存はできません.

デコレータ

デコレータが付与できる対象は,

  • クラス
  • メソッド
  • getter / setter の組
  • プロパティ (フィールド)
  • コンストラクタやメソッドのパラメータ

の 5 種類です.

@classDecorator
class MyClass {
  @methodDecorator
  myMethod(): void {
    // ...
  }

  @accessorDecorator
  get myValue(): number {
    return 0;
  }
  set myValue(value: number) {
    // ...
  }

  @propertyDecorator
  myProperty: number = 0;

  constructor(@parameterDecorator myParam: number) {
    // ...
  }
}

static なメソッドやフィールドに対しても付与できますが, private (#) なメソッドやフィールドには付与できません (experimental decorators の仕様が決まった時点では存在しなかったので非対応のまま). また getter / setter に対してそれぞれに異なるデコレータを付与することはできません.

TypeScript での型

それぞれ最も汎用的な (と思われる) ものを示します.

function classDecorator<Target extends abstract new (...args: any) => any>(
  target: Target,
): Target | void {
  // ...
}

function methodDecorator<Target, Args extends any[], Return>(
  target: Target,
  key: string | symbol,
  descriptor: TypedPropertyDescriptor<(this: Target, ...args: Args) => Return>,
): TypedPropertyDescriptor<(this: Target, ...args: Args) => Return> | void {
  // ...
}

function accessorDecorator<Target, Value>(
  target: Target,
  key: string | symbol,
  descriptor: TypedPropertyDescriptor<Value>,
): TypedPropertyDescriptor<Value> | void {
  // ...
}

function propertyDecorator<Target>(target: Target, key: string | symbol): void {
  // ...
}

function parameterDecorator<Target>(
  target: Target,
  methodKey: string | symbol | undefined,
  index: number,
): void {
  // ...
}

見てわかる Stage 3 との違いとしては,

  • それぞれの第一引数 target は, 対象がクラスの場合を除いてデコレータを付与した対象そのものではない
    • static な場合はクラス自体, そうでない場合はクラスの prototype です
  • メソッドや getter / setter については, 対象の値だけではなく property descriptor 全体を扱う
  • コンテキストオブジェクトはない

あたりでしょうか.

できること

対象の置き換え

Stage 3 と同様に, デコレータから戻り値を返すことで対象を置き換えられます. ただしプロパティの初期値の置き換えには対応していないのと, パラメータについても置き換えのような機能はありません.

メタデータの付与

Experimental decorators の仕様上にはメタデータを付与するための仕組みはありませんが, reflect-metadata を使うことでクラスに対してメタデータを付与できます.

またコンパイラオプションで emitDecoratorMetadata を有効にすると, メソッドや getter / setter, プロパティに対して,

  • design:type
  • design:paramtypes
  • design:returntype

といったコンパイル時の型情報を表すメタデータが自動で付与されます.

まとめ?

特にまとめとかはないんですが, なんかデコレータの面白い || 便利な使い方があったら教えてください.

Vite でお手製 Hot Module Replacement

先日の新春 LT 大会で, PixiJS と Vite を使って VJ するという話をしました.

VJing with PixiJS and Vite
こういうやつです

実装や資料は以下のリポジトリにまとまっています. が操作説明などは一切用意していないのでご了承ください (大体キーボード操作で, keydown イベントのハンドラを見るとなんらかわかると思います).

以下は発表中で触れた Hot Module Replacement の実装についての, もう少し詳しい解説です.

Hot Reload と Hot Module Replacement

ご存知の通り Vite には元々 Hot Reload (あるいは Live Reload) の仕組みがあって, ファイルが変更されたときに自動でページを再読み込みしてくれます. 便利ですね.

一方でページの再読み込みとなると, ページ内で保持していた状態は全てリセットされてしまいます. ごくシンプルなページならこれでも十分なのですが, ページが状態を持っているような場合は, 変更の結果を確認するために毎回ページを操作して望んだ状態に戻したりする必要が出てきます. それもアプリケーションの開発中であればまだ面倒というだけで済まなくもないですが, VJ の実演中にライブコーディング的にコードを変更したくなったときに毎回映像がリセットされてしまっては困ります.

そこで登場するのが Hot Module Replacement (HMR) です. Hot Reload ではページ単位の再読み込みでしたが, HMR ではより粒度の細かいモジュール単位での再読み込みが行えます. これを使うと, 例えば状態を保持するモジュールと画面を描画するモジュールを分けることで, 状態をリセットすることなく画面の描画を差し替えるということが可能になります.

React など一般的なライブラリやフレームワークを使っている場合は, vite-plugin-react などのプラグインを使うと HMR がついてくると思います. PixiJS で何かを作る場合も pixi-react を使ったりするとこういった既存の資産を活かせそうではあるんですが, 状態まわりの挙動を完全に把握したかった1のと, 別に HMR 以外の部分で React が欲しいわけでもなかったので, 折角なので自作してみることにしました.

Vite の HMR API

Vite には HMR を実現するための API が用意されているので, これを使います.

最もシンプルには, モジュールに以下のようなコードを埋め込みます.

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    // noop
  });
}

vite dev でサーバーを起動し, このモジュールを読み込んだページを表示した上で, モジュールのファイルに変更を加えると, なんとページの再読み込みが起こりません.

通常はモジュールが変更されると, その変更は上位のモジュール (そのモジュールを参照しているモジュール) にも伝わり, 最上位のページまで伝わったときに再読み込みが行われます. ところが上のように import.meta.hot.accept( が含まれるモジュールはこの変更の伝播の境界 (HMR 境界) となり, デフォルトでは上位のモジュールに変更が伝播しなくなるため, ページの再読み込みがされなくなるというわけです.

もし HMR 境界から上位のモジュールに変更を伝播させる必要がある場合は, 以下のように明示的に import.meta.hot.invalidate を呼び出す必要があります.

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    import.meta.hot.invalidate();
  });
}

import.meta.hot.accept のコールバックの引数 newModule は, 変更後のモジュールの module namespace object (モジュールから export される全ての値を持ったオブジェクト) です. 例えば以下のようにしてからモジュールのファイルを変更すると, 変更後の foo の値がログに出力されることが確認できます.

export const foo = 0;

if (import.meta.hot) {
  import.meta.hot.accept((newModule) => {
    if (!newModule) {
      // ファイルにエラーがあったとき (newModule === undefined) は無視
      return;
    }
    console.log(newModule.foo);
  });
}

ひとまず HMR の実装に最低限必要な API はこれだけ.

HMR に適したアプリケーションの構成

API を理解したので次は具体的に HMR を実装していきたいわけですが, その前にまずはアプリケーション全体の構成について考える必要があります.

例えば PixiJS で図形をアニメーションさせるようなコードを書くと, 最も素朴には以下のようになります2.

export function Circle(): void {
  // 状態
  let r = 0;

  // 図形 (円) を画面に配置
  const g = new Graphics().circle(0, 0, 100).fill("#ffffff");
  app.stage.addChild(g);

  // フレームごとのアニメーション
  app.ticker.add((ticker) => {
    r += ((2 * Math.PI) / 2000) * ticker.deltaMS;
    g.x = 200 * Math.cos(r);
    g.y = 200 * Math.sin(r);
  });
}

さて上で紹介した HMR API を使うと, このファイルが変更されたときに新しい Circle 関数が得られます. 画面を更新するにはこれを再実行すれば良いでしょうか?

当然このままではダメですよね. HMR API は魔法ではないので, 関数内の状態は勝手に維持されたりしませんし, 前回実行時の副作用のクリーンアップも行ってくれないので画面上に図形が増えていきます.

これらを解決するために, アプリケーション全体を,

  • 各要素を描画するための, 原則として純粋な関数. ユーザーが記述し, 実行中に変更され得る
  • 関数の呼び出し, 状態や副作用の管理を行うランタイム. あらかじめ記述され, 実行中に変更されない

の 2 つの部分に分けて構成することにします. 要するに React の真似事ですね.

最終的に, 各要素を描画するための関数 (Layer と呼んでいます. React でいうところのコンポーネント) は以下のように記述することになりました.

export const Circle: Layer<{ r: number }> = ({ app, state, container, effects }) => {
  // 状態
  // state はランタイムが管理, 関数が更新されても毎回同じオブジェクトが渡される
  state.r ??= 0;

  // 図形 (円) を画面に配置
  // container の実際の画面への配置やクリーンアップはランタイムが管理
  const g = new Graphics().circle(0, 0, 100).fill("#ffffff");
  container.addChild(g);

  // フレームごとのアニメーション
  // effects に追加した副作用の実行やクリーンアップはランタイムが管理
  effects.add(() => {
    const callback: TickerCallback<unknown> = (ticker) => {
      state.r += ((2 * Math.PI) / 2000) * ticker.deltaMS;
      g.x = 200 * Math.cos(state.r);
      g.y = 200 * Math.sin(state.r);
    };
    app.ticker.add(callback);
    return () => {
      app.ticker.remove(callback);
    };
  });

  // 子 (今回は無し)
  return [];
};

画面全体をこういった関数の組み合わせで表現し, ランタイム (Renderer と呼んでいます) には最上位の関数を渡すことで, 状態や副作用がよしなに管理されつつ画面が描画されるという仕組みになっています. ランタイムについては詳しくは実際のソースコードを見てください.

const renderer = new Renderer({ app });
// 最上位の関数 Root を描画
renderer.render(Root);
app.stage.addChild(renderer.container);

このようなアプリケーションの構成を前提とすることで, とうとう HMR が実装できる状態になりました.

具体的な HMR の実装

HMR の実装でも React と vite-plugin-react を大いに参考にしました. とはいえコード変換や再描画の実行のあたりはかなり手を抜いています.

まずは関数やランタイムを登録するグローバルなレジストリを用意しておきます. これらはそれぞれランタイムが最新の関数を取得したり, 再描画処理を呼び出す際にランタイムを取得したりするのに使います.

const layerRegistry = window.layerRegistry ?? new Map();
window.layerRegistry = layerRegistry;

const rendererRegistry = window.rendererRegistry ?? new Set();
window.rendererRegistry = rendererRegistry;

各モジュールには Vite プラグインを使って以下のようなコードを埋め込んでいまsす. liv:refresh の実装については実際のソースコードを見てください.

import { importModule, registerLayers, performRefresh } from "liv:refresh";

if (import.meta.hot) {
  // 現在の module namespace object も欲しいので dynamic import で取得
  importModule(import.meta.url).then((module) => {
    // 関数を登録
    registerLayers("/path/to/file", module);
    import.meta.hot.accept((newModule) => {
      if (!newModule) {
        return;
      }
      // 新しい関数を登録 = 更新
      registerLayers("/path/to/file", newModule);
      // 再描画を実行, または上位モジュールに伝播が必要ならそうする
      if (!performRefresh(module, newModule)) {
        import.meta.hot.invalidate();
      }
    });
  });
}

ランタイムでは状態を関数ごとに管理しているのですが, HMR の前後では関数自体が置き換わるため, 素朴には関数の同一性を判定できず, ランタイムがどの状態をどの関数に渡したら良いのかわからなくなってしまいます3. そのため, 関数にはファイル名と関数名を元にした ID を付与した上でグローバルなレジストリに登録し, ID を使って関数の同一性を判定できるようにしています.

モジュールから export している値のうち, 描画に関する関数のみが変更された場合は, 上位モジュールには変更を伝播させず, その場で再描画を実行します. それ以外の値が変更されたり, export の数に増減があった場合は, 上位のモジュールも更新の必要があるため, import.meta.hot.invalidate を呼び出して変更を伝播させています.

なお再描画は更新された関数に対してのみ行えると効率が良さそうなのですが, 実装が面倒になったので毎回全体を再描画しています.

まとめ

車輪の再発明楽しい.


  1. 例えば React の場合は Hooks のインターフェースの都合上, コンポーネント内の Hooks の数が変わったときに状態がリセットされることがあるが, こういった挙動を把握していなくて / 把握していても誤って状態がリセットされてしまうと困る
  2. PixiJS の代わりに, jQuery 全盛期のような DOM を直接操作するようなコードを想像してもらってもよいです
  3. あるいは重複する可能性が高い name に頼るか, React でいう key のようなものを毎回書くことになるか



以上の内容はhttps://susisu.hatenablog.com/より取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14