FacebookのFluxアーキテクチャの始め方 Part 1

私のように、Reactを使ってより進んだことがしたいと考えたなら、おそらくFluxに注目した経験があると思います。ざっと目を通してタブを閉じ、JavaScript開発者としての自分の人生を見直したことでしょう。

もしReactになじみがなければ、私の記事「React入門」を読んでみてください。

Fluxとは?

Fluxは、遠目には始めるために複雑な手順を踏まなければいけないように映ります。しかし、GitHubにあるexampleを見てみれば、これがどのように機能するのかが実に明確になってきます。

簡単に言うと、Fluxは美化された出版-購読型モデルのアーキテクチャです。データはシステム内を一方向に流れ、そこから様々な利用者が必要に応じてデータを取得します。これについて考えるには、私たちの体に例えてみると簡単です。

イベント – 血液

血液は私たちの体内を一方向に流れています。必要とする全ての要素を臓器に与え、循環する間にさらに多くの要素を吸収します。Flux内のイベントも全てディスパッチャから一方向に流れており、そこには格納が必要なペイロードデータが含まれる場合もあります。

ディスパッチャ – 心臓

体内にある心臓は、(大抵の場合)1つだけです。これはディスパッチャにも言えることで、1つのアプリケーションにつき1つのディスパッチャしかありません。ディスパッチャは単に待機のイベントを送信します。中央のハブと考えてください。

ストア – 臓器

私たちの臓器は血液から必要なものを摂取します(自然科学は私の苦手分野でした)。Fluxのストアもこれと同じです。ストアはイベントが来るのを待って、私たちに提供するための適当なデータを格納します。複数のストアが1つのイベントを監視するだけでなく、各ストアは異なるイベントを監視することもできます。

アクション – 肺

これは少しこじつけかもしれませんが、肺が血液に酸素を送り込んで心臓に渡し、それが心臓から全身に送り出されることによく似ています。アクション(肺)は残りのアプリケーションに広げるために、イベント(血液)をディスパッチャ(心臓)へと送ります。

ビュー – 体

血液から必要な要素を全て得られれば、臓器はその機能を果たすことができます。これはビューの概念と同じです。必要なデータを全て手に入れてしまえば、画面に表示することができるのです。

チャットを始めましょう

ヘタな例え話で申し訳ありませんが、上記の例えはFluxの各部分がどのように機能するかを簡単に説明するのに役立ちます。では、お互いに同期し合う複数のコンポーネントを使って、簡単なメッセージアプリを作成してみましょう(GitHubのexampleの1つに似ているものです)。Fluxを始めるためのリポジトリをここからcloneできます。これにはアプリケーションを記述するためのgulpタスクが含まれます。そっくりそのまま同じにする必要はありません。チュートリアルの中で見ていきましょう。

インストール

ボイラープレートを使うためには、以下のコードを実行する必要があります。

npm install && bower install

これで、私たちが今から使う、FluxとReactを含むブラウザコンポーネントとNodeモジュールの全てをインストールできます。ローカルWebサーバを起動するのは、以下のコマンドです。

gulp

そしてhttp://localhost:4000を開きます。簡単ですね。

このアプリはJavaScriptファイルをビルドするのにbrowserifyを利用します。もしbrowserifyになじみがなければこちらのチュートリアルに進んでください。

ローカルホストを開くと、Reactアプリのボイラープレートが表示されるはずです。

上の画像は、このリンク先のスクリーンショットです。モデルとビューを同期させておくためにFluxを使い、この上にビルドしていきましょう。

ビュー

UserListとMessageBoxと呼ばれる2つのメインビューがあります。メインのメッセージボックスは、ユーザリストで選択されたチャットを表示する必要があるだけではありません。UserListとMessageBoxはメッセージを表示するのに同じデータを使っているので、もし2つのビューが同期していなかったら、大惨事になる恐れがあります。ではここでFluxの魔法の出番です。

ディスパッチャの作成

既に説明したとおり、アプリケーションにはディスパッチャが1つ必要です。では、ディスパッチャを作っていきましょう。アプリケーションはpublic/src/js/の中にdispatchersと呼ばれるフォルダを持っています。このディレクトリ配下にapp.jsという名前の新たなファイルを作成します。私たちはbrowserifyを使っているので、Fluxのnpmモジュールを直接コードに組み込むことができます。

var Dispatcher = require('flux').Dispatcher;

var assign = require('object-assign');

var appDispatcher = assign(new Dispatcher(), {
    handleServerAction: function (action) {
        this.dispatch({
            source: 'server',
            action: action
        })
    },
    handleViewAction: function (action) {
        this.dispatch({
            source: 'view',
            action: action
        });
    }
});

module.exports = appDispatcher;

ディスパッチャを作成し、アクションを2種類追加しました。これでビュー(ユーザがアプリケーションにアクセスするところ)とサーバ(後ほど説明します)の両方からアクションを受け取ることができますね。コードを見ると、両方のメソッド内で、this.dispatchが呼び出されていることに気付くと思います。つまり、(sourceとactionを含む)所定のペイロードをここで渡しているのです。

メッセージの格納

ビューはどこかからデータを取得しなければならないので、メッセージのストアを作ってみましょう。ボイラープレートはpublic/src/jsにstoresというフォルダを持っていて、このフォルダには既にuser.js(単なる情報の基本的なストアで、通常はサーバを介して情報を受け取ります)が格納されています。今から作るのはmessages.jsという名前のファイルです。

今の時点では、静的なデフォルトデータを使いましょう。通常このデータは事前にサーバから受信しているものですが、私たちは(まだ)データとサーバを統合していないので、このような形にしているわけです。

var Dispatcher = require('../dispatchers/app');
var EventEmitter = require('events').EventEmitter;
var assign = require('object-assign');

var messages = {
    2: {
        user: {
            profilePicture: 'https://avatars0.githubusercontent.com/u/7922109?v=3&s=460',
            id: 2,
            name: 'Ryan Clark',
            status: 'online'
        },
        lastAccess: {
            recipient: 1424469794050,
            currentUser: 1424469794080
        },
        messages: [
            {
                contents: 'Hey!',
                from: 2,
                timestamp: 1424469793023
            },
            {
                contents: 'Hey, what\'s up?',
                from: 1,
                timestamp: 1424469794000
            }
        ]
    },
    ...
};

var openChatID = parseInt(Object.keys(messages)[0], 10);

var messagesStore = assign({}, EventEmitter.prototype, {
    addChangeListener: function (callback) {
        this.on('change', callback);
    },
    removeChangeListener: function (callback) {
        this.off('change', callback);
    },
    getOpenChatUserID: function () {
        return openChatID;
    },
    getChatByUserID: function (id) {
        return messages[id];
    },
    getAll: function () {
        return messages;
    }
});

messagesStore.dispatchToken = Dispatcher.register(function (payload) {

});

ペイロードの説明

私たちはメッセージのためにオブジェクトを使い、キーとしてユーザID、そして値の中のユーザに関する様々なデータを使っています。public/src/js/partials/にあるmessageBox.jsxとuserList.jsxを開いてみると、ストアの中にある静的なデータは、Reactのビューでレンダリングしたものとほぼ一緒だということが分かるでしょう。つまりストアとビューを結びつけることは非常に簡単なのです。

lastAccessが何か疑問に思っているでしょうか。lastAccessは2つの値を持っています。アプリケーションにアクセスしている現在のユーザの値と、その他のユーザの値です。これらの値はチャットの最終アクセス時を示すUNIXタイムスタンプなので、メッセージが未読か既読か分かります。

また、openChatIDを取得します。これはメッセージの最初のキー(最初のユーザID)なので、最初のチャットはデフォルトで開いています。

ストアの説明

ストアを作成するために、eventsというデフォルトのノードモジュール上に拡張します。上記のとおり、browserifyが使われているので、eventsを入れるだけでいいのです。このおかげで、イベントを発するだけでなく、コールバック関数の登録や取り消しが可能なオブジェクトを素早く作成できます。

私はaddChangeListenerとremoveChangeListenerの両方をストアに追加しました。これはonとoffのより詳細なメソッドなので、動作が正確に分かります。さらに、メッセージデータを取り出すメソッドを2つ追加しました。全てのメッセージデータを取得するメソッドと、ユーザIDを指定してメッセージデータを取得するメソッドです。どちらのビューでも、この2つの関数を使って必要な関連データを取得できます。

また、ストアのdispatchTokenがあります。これはディスパッチャからの全てのイベントを待機します。この時点でイベントは気にしていないので、単なる空のオブジェクトです。

全てを接続

これで両方のビューで同一のストアからデータを取得できるようになりました。public/src/js/partials内でMessageBoxビューに入れば、現在開いているチャットのメッセージデータを含むように状態が設定されていることが分かるでしょう。これはストア内で、getChatByUserID(現在のユーザのIDを取得する場合はgetOpenChatUserID)経由でアクセスできるので、ストアへの呼び出しによって削除や取り替えが可能です。

var ReplyBox = require('../components/replyBox');

var UserStore = require('../stores/user');

var Utils = require('../utils');

var MessageBox = React.createClass({
    getInitialState: function () {
        return {
            user: ...,
            lastAccess: ...,
            messages: [...]
        };
    },
    render: function () {
        // ommited logic

        return (
            <div className="message-box">
                <ul className="message-box__list">
                    { messages }
                </ul>
                <ReplyBox />
            </div>
        );
    }
});

module.exports = MessageBox;

上記が以下のようになります。

var ReplyBox = require('../components/replyBox');

var MessagesStore = require('../stores/messages');
var UserStore = require('../stores/user');

var Utils = require('../utils');

var MessageBox = React.createClass({
    getInitialState: function () {
        return MessagesStore.getChatByUserID(MessagesStore.getOpenChatUserID());
    },
    render: function () {
        // ommited logic

        return (
            <div className="message-box">
                <ul className="message-box__list">
                    { messages }
                </ul>
                <ReplyBox />
            </div>
        );
    }
});

module.exports = MessageBox;

この変更を行ったら、ローカルホストを見てください。全く同じに見えますね。あなたが喜ぶのは、大きなコード変更があってもUIで見た目が変わっていない時だけでしょうが、それでも私たちは一歩進んでいるのです。

これでUserListに対して同じことができます。最後に送信されたメッセージが分かればいいだけなのですが、格納されたデータの前処理が少し要求されます。public/src/js/partials内でuserList.jsxを開けば、どんなデータが使われているか分かるでしょう。

var utils = require('../utils');

var UserStore = require('../stores/user');

var UserList = React.createClass({
    getInitialState: function () {
        return {
            openChatID: 0,
            messageList: [
                {
                    lastMessage: {
                        contents: 'Hey, what\'s up?',
                        from: 1,
                        timestamp: 1424469794000
                    },
                    lastAccess: {
                        recipient: 1424469794050,
                        currentUser: 1424469794080
                    },
                    user: {
                        profilePicture: 'https://avatars0.githubusercontent.com/u/7922109?v=3&s=460',
                        id: 2,
                        name: 'Ryan Clark',
                        status: 'online'
                    }
                },
                ...
            ]
        }
    },
    render: function () {
        // omitted logic

        return (
            <div className="user-list">
                <ul className="user-list__list">
                    { messages }
                </ul>
            </div>
        );
    }
});

module.exports = UserList;

必要なデータは格納されたデータからそれほどかけ離れていないので、処理して必要なデータにするのは比較的簡単です。

var utils = require('../utils');

var MessagesStore = require('../stores/messages');
var UserStore = require('../stores/user');

var UserList = React.createClass({
    getInitialState: function () {
        var allMessages = MessagesStore.getAllChats();

        var messageList = [];
        for (var id in allMessages) {
            var item = allMessages[id];

            var messagesLength = item.messages.length;
            messageList.push({
                lastMessage: item.messages[messagesLength -1],
                lastAccess: item.lastAccess,
                user: item.user
            })
        }

        return {
            openChatID: MessagesStore.getOpenChatUserID(),
            messageList: messageList
        };
    },
    render: function () {
        // omitted logic

        return (
            <div className="user-list">
                <ul className="user-list__list">
                    { messages }
                </ul>
            </div>
        );
    }
});

module.exports = UserList;

驚くほど簡単ですよね。得られる結果は変更前と全く同じですが、ソースは単一になりました。

ここまでの出力結果をこのリンク先で確認できます。変更されたのは根底にあるコードだけなのでスクリーンショットは省きました。ボイラープレート上でstep-twoブランチをチェックして、コードを全てチェックすることもできます。

By Ryan Clark