Reactを用いたアプリケーションアーキテクチャ:Fluxを再考する

他のフレームワークやライブラリからReactに乗り換える人たちは、「ReactはUIのレンダリングに関する問題しか解決しておらず、状態管理とアプリケーションアーキテクチャの選択は開発者に委ねられているのだから、どうやってアプリケーションの状態を管理したらいいのか?」 と疑問に思う傾向があります。FacebookはReactのレンダリングモデルに適している、Fluxと呼ばれるアーキテクチャを勧めています。

この記事では、UIレイヤとしてReactを用いてJavaScriptのアプリケーションの状態を管理する方法を探り、OmのようなClojureScriptライブラリのアイデアを用いてFacebookのFluxの抽象的なフレームワークを作り変えてみたいと思います。

Flux

Fluxの核となる考えは、データは一方通行で流れるべきというものです。これによってアプリケーションの論証が簡単になり、システムコンポーネント間の依存性は明確になり、全ての状態の変更は”真のシングルソース”由来になります。

flux-simple-f8-diagram-explained-1300w
画像訳:
Action creatorはヘルパーメソッドで、ライブラリの中に集められます。メソッドのパラメータからアクションを作り、タイプを割り当て、Dispatcherに渡します。

全てのActionはコールバックを経由して全てのStoreに送られます。このコールバックはStoreがDispatcherと共に登録したものです。

StoreがActionに応じて自らをアップデートした後、changeイベントを出します。Controller-Viewと呼ばれる特殊なViewはChangeイベントを検知し、Storeから新しいデータを取り出し、子のViewのツリー全体に新しいデータを提供します。

要約すると、ViewActionのトリガーとなります。Actionが発生させたイベントはDispatcherによってStoreに知らされます。Storeは状態を修正するactionに反応します。ViewはデータをStoreから取り入れて表示するので、Storeは必要に応じてUIの中でトリガーをアップデートし再表示します。

私が、Fluxについて気に入らない点がいくつかあります。それは、以下になります。

  • 状態のマネジメントと、状態変化の原因となるビジネスロジックを絡み合わせてしまいます。
  • 状態は、モジュラー化された様々なStoreに散りばめられていますが、最終的にこれらのStoreは互いについて知ることになります。
  • Storeはディスパッチのメカニズムと組み合わせられます。

Storeからグローバルな不変の状態へ

複数のStoreを持つ代わりに、私は「不変のデータ構造に対してグローバルで可変なリファレンスを持つ」というOmのアプローチをお勧めします。この方法により、データを逐一修正することなく、あるいは継時的に値を異なる値と再バインドすることなく、アプリケーションの状態を不変の値の子孫として作ることができます。

これはatomoのライブラリやFacebookのimmutable-jsを使えば容易に達成できます。グローバルなatomに含まれるデータ構造はマップになり、これによってキーを使ってデータを論理ドメインに分割できるようになります。

このアプローチは、分離した論理ドメインはお互いに参照し合う傾向があるという事実を隠そうとするものではありません。以下に、設計した音楽アプリケーションの状態の例を挙げてみます。

// state.js

import {fromJS} from "immutable";

let initialState = fromJS({
    user: null,
    albums: [
        {
            title: "La Leyenda del Tiempo",
            artist: "Camarón"
        },
        {
            title: "Veneno",
            artist: "Veneno"
        }
    ],
    playlists: [
        {
            name: "Flamenco",
            tracks: [
                {
                    title: "Nana del Caballo Grande",
                    artist: "Camarón",
                    album: "La Leyenda del Tiempo"
                }
            ]
        }
    ]
});

不変のデータ構造から、グローバルで可変なatomを非常に簡単に作ることができます。

// state.js

import atomo from "atomo";

export const state = atomo.atom(initialState);

atomはobserveできること、atomからある値を変更した新しいatomを作成するときも参照される不変の値は構造を共有することから、私たちはメモリの効率が良い方法で、アプリケーションの状態をシリアライズできます。

// history.js

import {List} from "immutable";
import {state} from "./state";

const history = atomo.atom(new List());

state.addWatch(function(atom, oldValue, newValue){
    history.swap((hs) => hs.push(oldValue));
});

これにより自由にタイムトラベルができるようになりますし、この状態管理戦略に加えて、Undo/Redo機能を実装するのも些細なことです。もしも私たちがActionの子孫やシステムのペイロードをシリアライズすれば、アプリケーションを使ってユーザインタラクションを再度行うことが非常に簡単になり、回帰テストも「前述のアクションをシステムに与え、ある決まった状態で終わるとassertする」といったシンプルなものなります。

Cursor

各Viewに、全ての状態を渡して回るのは避けたいことです。ほとんどの場合、Viewはグローバル状態のサブセットを表示するので、Viewをモジュラー化するためには、グローバルな不変の状態の内部のパスに焦点を当てる機能が必要です。

OmはCursorと呼ばれる抽象化によってこの問題を解決します。グローバルatomのパスに絞り、atomと同様のAPIを提供します。Viewでサブストラクチャをデータソースとして扱えるようにし、さらにatomと同じ操作で修正することさえ可能です。atomと不変データを用いて使うために、この概念のシンプルな実装を書きました。

atomや別のcursorからcursorを1つ取り出し、cursorをポイントするパスをリファインすることができます。

import kurtsore from "kurtsore";
import {is} from "immutable";

let cursor = kurtsore.cursor(state),
    albums = cursor.derive('albums'),
    playlists = cursor.derive('playlists');

is(
    albums.deref(),
    state.deref().get('albums')
);
//=> true

is(
    playlists.deref(),
    state.deref().get('playlists')
);
//=> true

View

cursorは、最上位コンポーネントへのパスがなくても作ることができ、それをサブコンポーネントに渡すときにリファインできます。この方法で、全体の状態を把握しなくとも、グローバル不変状態の一部を表すモジュラーViewが得られます。

cursorは作成時にそれらがポイントしている状態のスナップショットを保存し、また不変データが同等であるかのチェックは参照確認のみのため瞬く間に行われます。よって、コンポーネントが最も効率的なshouldComponentUpdateを更新、実行したかが判断できるのです。私のreact-kurtsoreライブラリやOmniscientなどのオープンソースライブラリなどの例を参考にしてください。

これはグローバル状態のサブコンポーネントを表示し、リファインしたcursorを子に渡すコンポーネントの例です。

// views.js

import React from "react";
import {CursorPropsMixin} from "react-kurtsore";


export const Album = React.createClass({
    mixins: [ CursorPropsMixin ],

    render(){
        let album = this.props.album.deref();
        return <li>{album.get('artist')} - {album.get('title')}</li>;
    }
});

export const Albums = React.createClass({
    mixins: [ CursorPropsMixin ],

    render(){
        let albums = this.props.albums.deref(),
            cursors = albums.map((a, idx) => this.props.albums.derive(idx));

        return (
            <ul>
                {cursors.map(
                    (a, idx) => <Album key={idx} album={a} />
                )}
            </ul>
        );
    }
});

Action

Fluxと同様、actionはユニークかつ一定の値で区別されます。このため、ストリングかES6シンボルを使います。

//  constants.js

export const ACTIONS = {
    LOG_IN: Symbol.for("user:log-in"),
    LOG_IN_FAILED: Symbol.for("user:log-in-failed"),
    LOG_OUT: Symbol.for("user:log-out")
};

ここではactionを不変レコードとして、typepayload属性で表します。タイプは識別子であり、ペイロードは任意の不変値です。

// actions.js

import immutable from "immutable";

export const Action = immutable.Record({type: null, payload: null});

export function action(type, payload){
    return new Action({type, payload});
};

Dispatcherの削除

Fluxは、状態遷移のトリガーのために、Storeに結合したSingletonのDispatcherを提示しています。これはStore間に従属性を与え、それぞれが互いの存在と、action実行の順序を知らせるようにします。Dispatcherとパブリッシュ・サブスクライブ方式は、各Storeがトリガーとなる全てのactionを逐一出力する点で異なります。

私は、パブリッシュ・サブスクライブ方式のactionにCSPチャネルを代わりに使うことを勧めます。js-cspライブラリで取得できます。

パブリッシュ・サブスクライブ方式は、actionをパブリッシュするチャネルを持っており、そこからパブリケーションを引き出すことができます。パブリケーションはソースチャネルと、ソースチャネルに置かれたメッセージの”topic”を抽出する関数によります。テストを簡素化するため、ソースチャネルをatomで設定できるようにします。

// pubsub.js

import atomo from "atomo";
import csp from "js-csp";

export const source = atomo.atom(csp.chan());

export function publication(topicFn){
    return csp.operations.pub.publication(source.deref(), topicFn);
};

export function publish(msg){
    csp.putAsync(source.deref(), msg);
};

パブリケーションによって、システムの他のコンポーネントはトピックをサブスクライブすることができます。そして、与えられたトピックをシェアする値をおくチャネルを生成します。actionにサブスクライブする方法は下記のとおりです。

import csp from "js-csp";
import pubsub from "./pubsub";
import {ACTIONS} from "./constants";

let userChan = csp.chan(),
    pub = pubsub.publication((v) => v.get("type"));

pub.sub(ACTIONS.LOG_IN, userChan);
pub.sub(ACTIONS.LOG_OUT, userChan);

csp.go(function*(){
    let action = yield userChan;

    while (action !== csp.CLOSED) {
        let {type} = action;

        if (type === ACTIONS.LOG_IN) {
            let user = action.get("payload");
            console.log(user, " just logged in.")
        } else {
            console.log("The user just logged out.");
        }

        action = yield userChan;
    }
});

上記のuserChanは、システム上に出力されたログインとログアウトのactionを受け取るチャネルです。goに渡されたジェネレータは無期限に実行され、userChanのaction受信を待ち受け、これらのイベントをコンソールに蓄積していきます。goに渡されたジェネレータはuserChanが利用可能な値を持っているときだけ動くため、内部でwhile文を使うのが安全だということを覚えておいてください。

actionのパブリッシュ

いくつかのactionは、必要なデータを取得するのに非同期処理を必要とします。また、Viewをactionとディスパッチメカニズムから分離するため、Viewが消費し得る、ハイレベルAPI内のactionをカプセル化します。FluxはこのハイレベルAPIをaction creatorと呼びます。

View自体からactionをパブリッシュするよりも、関数内の全てのactionパブリケーションをカプセル化することをお勧めします。なぜなら、そうすることで、Viewをシステム内のコミュニケーションシステムから分離できるからです。また、Viewを隔離してテストすれば、Viewと対話し、ハイレベルAPIを想定どおりに消費していることを確認できるという利点もあります。

// authentication.js

import {ACTIONS} from "./constants";
import {action} from "./actions";
import pubsub from "./pubsub";
import http from "./http";
import {fromJS} from "immutable";


export function tryLogIn(username, password){
    http.post("/login", {username, password})
        .then((user) => pubsub.publish(action(ACTIONS.LOG_IN, fromJS(user))))
        .catch((errors) => pubsub.publish(action(ACTIONS.LOG_IN_FAILED, fromJS(errors))))
};

export function logout(username, password){
    pubsub.publish(action(ACTIONS.LOG_OUT));
};

actionの解釈

適切ななパブリッシュ・サブスクライブメカニズムを持つと、状態遷移を小さな部品にカプセル化できます。これらはパブリッシュ・サブスクライブのaction(またはactionの組み合わせ)を待ち受け、適宜に状態に影響を与え、ビジネスロジックをモジュラーかつテスト可能なユニットに分離します。これを私はeffectsと呼んでいます。

// effects.js

import csp from "js-csp";
import {ACTIONS} from "./constants";


export function logIn(publication, state){
    let loginChan = csp.chan();

    publication.sub(ACTIONS.LOG_IN, loginChan);

    csp.go(function*(){
        let action = yield loginChan;

        while (action !== csp.CLOSED) {
            let user = action.get("payload");
            state.swap((st) => st.set('user', user))
            action = yield loginChan;
        }
    });

    return loginChan;
};

export function logOut(publication, state){
    let logoutChan = csp.chan();

    publication.sub(ACTIONS.LOG_OUT, logoutChan);

    csp.go(function*(){
        let action = yield logoutChan;

        while (action !== csp.CLOSED) {
            state.swap((st) => st.remove('user'))
            action = yield logoutChan;
        }
    });

    return logoutChan;
};

パブリケーションとatomを挟むことで、logInlogOutのeffectを別でテストすることができます。logInlogOutのeffectは呼び出し時に開始し、待ち受けるチャネルを返すので、それによりチャネルを遮断できるようになることに留意してください。effectのセットをグループ化すると、思いどおりに開始したり停止したりできるステートフルオブジェクトを作ることもできます。

// effects.js

class Effects {
    start(publication, state){
        this.chans = [
            logIn(publication, state),
            logOut(publication, state)
        ];
    },
    stop(){
        this.chans.map((ch) => ch.close());
    }
}

export default new Effects();

まとめ

上記全てを統合した、アプリケーションのエントリポイントの例は以下のようになります。

// main.js

import React from "react";
import {CursorPropsMixin} from "react-kurtsore";

import {state} from "./state";
import {Albums, Playlists} from "./views";
import pubsub from "./pubsub";
import effects from "./effects";

const App = React.createClass({
    mixins: [ CursorPropsMixin ],

    render(){
        let state = this.props.state;

        return (
            <div>
                <Albums state={state.derive('albums')} />
                <Playlists state={state.derive('playlists')} />
            </div>
        );
    }
});

function render(state){
    React.render(<App state={state} />, document.querySelector("body"));
};

(function bootstrap(){
    // View
    render(kurtsore.cursor(state));
    state.addWatch(() => render(kurtsore.cursor(state)));

    // Pub-sub
    let publication = pubsub.publication((ac) => ac.get("type"));

    // Effects
    effects.start(publication, state);
})();

関連記事