アカベコマイリ

HEAR NOTHING SEE NOTHING SAY NOTHING

material-flux を試す

React による Web アプリケーション開発で Flux に facebook/flux を採用していたけどシングルトンが扱いにくいので代替を検討した。

flummox

Flux ライブラリを採用するにあたり単純であることを重視。規模が小さく簡潔で、その気になれば自分で書き直せるようなものがよい。機能としては Store/Action だけ管理できれば十分である。

次に ES6 (これからは ES2015 と呼ぶべきだろうか?) に対応していること。ライブラリの提供する Store/Action を利用するにあたり Object.assign() などの Mix-In 機構ではなく ES6 class の継承で書きたい。

すこし前に以下の記事を読み、これらの条件を満たすものとして acdlite/flummox を採用するつもりだった。

しかし flummox は Store/Action と React コンポーネントを関連づけるため FluxComponent という React コンポーネントを使う点がイマイチ。View 部分を React 以外 (dekujs/deku とか) へ変えたくなったとき困りそうだ。

Flux はデータフロー管理に徹してほしいので View ライブラリの依存は避けたい。というわけで却下。

material-flux

そもそも単純さを重視するなら自分で実装したほうがよくないか?というわけで設計を検討してみた。

しかしシングルトンを避けて Store/Action をシンプルに管理しようとすると babelify で ES6 を試すでも触れた material-flux みたいになった。ならばいっそこれを採用したほうがよいだろう。というわけで material-flux を試す。

material-flux は小規模で設計も簡潔。付属のサンプルと作者の解説記事を読むだけですぐに利用できるだろう。

問題があるとすれば Store/Action がシングルトンであることに依存していた箇所の修正である。また EventEmmiter を直に使わず React コンポーネントのように setState で更新通知する点も注意が必要。

以下のプロジェクトを facebook/flux から material-flux へ乗り換えたときの覚書をまとめる。

提供されるクラス

material-flux の提供するクラスは以下の 3 種類。

  1. Action
  2. Store
  3. Context

Action と Store は他の Flux 実装でもお馴染みだが Context は特徴的。これは dispatcher をインスタンス化するもの。Action と Store も Context 継承クラス内で管理される。

Action

Action の実装は以下のようになる。

import { Action } from 'material-flux';

/**
 * 音声プレーヤーの操作種別を定義します。
 * @type {Object}
 */
export const Keys = {
  play:     Symbol( 'AudioPlayerAction.play' ),
  pause:    Symbol( 'AudioPlayerAction.pause' ),
  stop:     Symbol( 'AudioPlayerAction.stop' ),
  seek:     Symbol( 'AudioPlayerAction.seek' ),
  volume:   Symbol( 'AudioPlayerAction.volume' ),
  unselect: Symbol( 'AudioPlayerAction.unselect' )
};

/**
 * 音声プレーヤーを操作します。
 */
export default class AudioPlayerAction extends Action {
  /**
   * 音声を再生します。
   *
   * @param {Music} music 再生対象とする音楽情報。
   */
  play( music ) {
    this.dispatch( Keys.play, music );
  }

  // ...以下、略
}

Action はコンストラクタへ Context が渡されて内部に参照が保存される。dispatcher への Action 登録は dispatch メソッド経由でおこなう。Action 識別子の定義はサンプルどおり Symbol にした。この方法だと keyMirror に比べて単純な名前でも衝突を避けられて便利だ。

dispatch メソッドは第一引数に Action 識別子、第二引数に可変長のパラメーターを指定。これは Store 側で同じ識別子に関連付けたメソッドの引数として渡される。プロパティ名を決める必要もなくパラメータの順番だけ気をつければよい。管理しやすい設計だ。

facebook/flux から乗り換える場合は Action 識別子を定義していた 〜Constans 系ファイルが不要になる。というか facebook/flux を利用する場合でも識別子は Action 系ファイル内に定義して export するほうが分かりやすい。

Store

Store の実装は以下のようになる。

コメントにシングルトンとあるがこれは消し忘れ。実際には Context 内でインスタンス化される。サンプル プロジェクトの実装を見て混乱するかもしれないのでコメントがミスであることをここに明示しておく。

import { Store }   from 'material-flux';
import { Keys }    from '../actions/AudioPlayerAction.js';
import AudioPlayer from '../model/AudioPlayer.js';

/**
 * 音声プレーヤーを操作します。
 * このクラスはシングルトンとして実装されます。
 *
 * @type {AudioPlayerStore}
 */
export default class AudioPlayerStore extends Store {
  /**
   * インスタンスを初期化します。
   *
   * @param {Context}        context        コンテキスト。
   * @param {MusicListStore} musicListStore 音楽リスト。
   */
  constructor( context, musicListStore ) {
    super( context );

    this.register( Keys.play,     this._actionPlay     );
    this.register( Keys.pause,    this._actionPause    );
    this.register( Keys.stop,     this._actionStop     );
    this.register( Keys.seek,     this._actionSeek     );
    this.register( Keys.volume,   this._actionVolume   );
    this.register( Keys.unselect, this._actionUnselect );

    /**
     * 変更監視される値。
     * @type {Object}
     */
    this.state = {
      /**
       * 再生対象となる音楽情報。
       * @type {Music}
       */
      current: null,

      /**
       * 再生状態。
       * @type {PlayState}
       */
      playState: PlayState.Stopped
    };

    // ... 以下、略
  }

  // ... 以下、略
}

Store もコンストラクタに Context が渡される。Action との関連付けは register メソッド経由で処理。第一引数に Action 識別子、第二引数に Action と関連付ける関数やメソッドを指定する。

複数 Store 間の依存は facebook/flux の dispatcher 的に waitFor メソッドで管理可能。待機が不要ならこの例のようにコンストラクタで依存 Store の参照を受け取って保持してもよい。手抜きともいえるが。

もうひとつの特徴として state がある。これは React コンポーネントと同様に監視対象とするデータを定義して setState メソッドで更新すると変更通知が発生する。例えば playState というプロパティが監視対象だとして、

this.setState( { playState: PlayState.Playing } );

のようにすると EventEmitter による通知が発生する。プロパティを更新せずに通知だけ発生させたい場合は引数なしで実行すればよい。

this.setState();

material-flux のサンプルにはない用法だが Store クラスの実装をみると引数なしでもエラーにせず最後に this.emit('change') していることを確認できる。

通知の購読は onChange、解除は removeChangeListener または removeAllChangeListeners でおこなう。React.Component 継承クラスでは暗黙の bind(this) が廃止された。そのため onChangeremoveChangeListener へ同じインスタンスを渡すためには

// Store の館対象となる bind 済み Listener を一意にするためのフィールド
this.__onChangeAudioPlayer = this._onChangeAudioPlayer.bind( this );

のように bind(this) したものを保存して渡す必要がある。こうした管理が面倒で Store に複数のコールバックを登録することがないなら removeAllChangeListeners を利用するほうが楽。このメソッドはコールバックをすべて解除するため onChange に登録したインスタンスとの整合を考えなくて済む。

facebook/flux から乗り換える場合は state/setState 関連の修正が大量に発生するため注意が必要。かわりに EventEmitter 関連の冗長な実装が不要となる。

データ更新と通知を React コンポーネントの state 風にしたのはよい設計。これらの処理はセットで実行されることが多いため記述が簡潔になる。引数なしの呼び出しで単純な通知になるのも、この用途で別メソッドを用意するよりスマートだ。

Context

Context は Store/Action を結びつける。実装は以下のようになる。

import { Context }       from 'material-flux';
import MusicListAction   from './actions/MusicListAction.js';
import MusicListStore    from './stores/MusicListStore.js';
import AudioPlayerAction from './actions/AudioPlayerAction.js';
import AudioPlayerStore  from './stores/AudioPlayerStore.js';

/**
 * アプリケーションを表します。
 */
export default class AppContext extends Context {
  /**
   * インスタンスを初期化します。
   */
  constructor() {
    super();

    this.musicListAction = new MusicListAction( this );
    this.musicListStore  = new MusicListStore( this );

    this.audioPlayerAction = new AudioPlayerAction( this );
    this.audioPlayerStore  = new AudioPlayerStore( this, this.musicListStore );
  }
}

facebook/flux でシングルトンだったものが Context 内のインスタンスとして管理される。AudioPlayerStoreMusicListStore に依存することを考慮した順番でインスタンス化している。

Context の利用

いままで Action/Store を直に import していた箇所を Context に置き換える。まず Context のインスタンスだが、これはどこで生成してもよい。Context に含まれる Action/Store を必要とする箇所となるだろう。

管理が面倒ならアプリケーションのエントリー ポイントでインスタンス化して React コンポーネントへ props 経由で渡す。例えば以下のような感じ。

import AppContext    from './AppContext.js';
import MainViewModel from './vm/MainViewModel.js';

let context = null;

window.onload = () => {
  context = new AppContext();

  React.render(
    <MainViewModel context={ context } />,
    document.querySelector( 'body' )
  );
}

子コンポーネントに伝搬してゆくときは props で渡してゆけばよい。こうすると React コンポーネントの受け取る外部データを props へ統一しやすくなる。props にだけ依存するようにすればモックやスタブを渡しやすくなるため render テストなどにも役立つだろう。

まとめ

facebook/flux からの移行は作業量が多くて苦労したが、それに見合う結果を得られたと感じる。シングルトンがなくなり処理の順序が明確になったことと Store の state 管理が特に気に入った。

実装が小規模なため不明な点をコードで確認しやすい点もよい。大きなフレームワークに乗るより、こうした小さなライブラリを組み合わせて実装するほうが好きだ。