LoginSignup
18
20

More than 5 years have passed since last update.

Falcor入門 4日目 FalcorとReactを組み合わせる

Last updated at Posted at 2016-03-02

このFalcor入門シリーズも4回目です。実装方法については、第2回でクライアントサイドでのAPI利用方法、第3回でサーバサイドの実装方法をそれぞれ解説してきました。

ちなみに過去回はこちら:

今まで何度か書いてきた通り、Falcorは「クライアントサイドから効率的に値を取得してViewに受け渡すこと」を目的に開発されたMiddlewareです。
前回までは、話の焦点をFalcor自体の使い方に絞るために、コンソールで文字を表示するばっかりのサンプルを書いていました。
しかし、本来Falcorは画面系のフレームワークと組み合わせて使ってこそ意味のあるライブラリです。
今回のエントリでは、皆大好きReactとFalcorを組み合わせてみたいと思います。

前提

最初に何点か断っておきます。

  1. 前回まではトランスパイラにBabelを選択してサンプルを書いてきました。でも、やっぱりTypeScriptの方が好きなので今日は.ts, .tsxでコード書いてます。とはいえ、特にTypeScriptならではの話をするつもりもないですので、ECMA Script 6thとReactJSXが読めれば問題無いと思います。
  2. 今回はサンプルコードをQuramy/react-falcor-demoにupしておきました。環境構築手順等はこのレポジトリのREADMEに書いてあるので、本文中での説明はしません。
  3. 前回は色気出してサーバサイドの話をしましたが、今回は面倒なので第2回の時と同様、cacheオプション利用することで、クライアントオンリーで動かすコードを書いてます。

Falcor.Modelの変更検知

Reactに限った話ではないですが、FluxのようにPub/Subのパターンに即したMVCでは、ViewがModelの変更イベントを購読するパターンが王道です。
このエントリにおいても、ReactのComponent(View)がFalcor.Modelを変更を購読するように実装します。データフローで言うと、[Model] -> [View]ですね。

FalcorはModelが内包するJSON Graphの状態に変更が発生した場合に発火するコールバックをModelの作成時に仕込むことが出来ます。

var model = new Falcor.Model({
  onChange: () => {
    console.log('中身変わったよ!')
  },
  /* 略 */
});

こいつを利用して、FalcorのModelを拡張してやりましょう。

lib/src/store.ts
import {EventEmitter} from 'events';
import * as Falcor from 'falcor';

export interface StoreCreateParam {
    [key: string]: any;
    onChange?: Function;
}

export default class Store extends Falcor.Model {
    private emitter: EventEmitter;
    constructor(option: StoreCreateParam) {
        var opt: StoreCreateParam = option;
        var originalCallback = option.onChange || (() => {});
        opt.onChange = () => {
            this.emitter && this.emitter.emit('change');
        };
        super(opt);
        this.emitter = new EventEmitter();
    }

    static createFromModel(rawModel: Falcor.Model) {
        return new Store({
            source: rawModel.asDataSource()
        });
    }

    static createFromCache(cache: any) {
        return new Store({
            cache
        });
    }

    addChangeListener(query: string, onSuccess: (json: any) => any) {
        var handler = () => {
            this.get(query).then(res => {
                if(res) {
                    onSuccess(res);
                }
            });
        };
        this.emitter.on('change', handler);
        var off = () => {
            this.emitter.removeListener('change', handler);
        };
        return off;
    }
}

ここで作成したStoreは、Falcor.Modelを継承したClassです。
また、Model変更時にaddChangeListener にて登録したハンドラが発火するようにNode.jsのEventEmitterを仕込んでいます。

Viewの作成

上記で用意したStore を使い、早速ReactのComponentを作成してみます。ただのHello worldです。

app.tsx
import * as React from 'react';
import * as Falcor from 'falcor';
import {render} from 'react-dom';

import {Store} from './lib/built/store'   //上で作ったStore class

// FalcorのModelからStoreを作成
const model = Store.createFromModel(new Falcor.Model({
    cache: {
        message: 'Hello, World'
    }
}));

class App extends React.Component<{}, {message: string}> {

    state = {message: ''};                  // stateの初期化
    off: () => void;

    componentDidMount() {
        var handler = () => {
            this.off = model.get('message').then(res => {
                this.setState({message: res.json.message});
            });
        });
        model.addChangeListener(handler);   // 値の変更検知
        handler();                          // 初回の値取得
    }
    componentWillUnmount() {
        this.off && this.off();             // 購読解除
    }
    render() {
        return (
            <div>
                <h1>{this.state.message}</h1>
            </div>
        );
    }
}

render(<App />, document.getElementById('app'));

Hello worldなのでここまでの機能は本来不要なのですが、ReactのLifecycleフックを使って下記を実現しています。

  • componentDidMount で先ほどのStoreに対する変更検知の購読と初回の値取得を実行
  • componentWillUnmount で購読の解除

もう少しなんとかならんものか

とりあえずViewからModelの値をGetして表示することは出来るようになりましたが、幾つかの課題があります。

  1. componentDidMount, componentWillUnmountでの購読・購読解除は定形文なのにModelとViewを結合する度に毎回書くのは馬鹿げている
  2. とくにcomponentDidMountでは、初回の値取得と値の購読を両方書いてやる必要もある
  3. 値の取得処理(Serverへの問い合わせを含む)と表示処理が密に結合している

3.については、react-redux等のメジャーなフレームワークにおいても、Componentはpropsを介してStore(Model)の値を参照するようになっていることが多いです。

データの取得と表示をどのように分解すれば良いのでしょうか。

Relayを覗いてみた

今回のようにReactと組み合わせる文脈において、Falcorと比較されるライブラリの筆頭がFacebookが開発したRelay です。

FalcorがJSON Graphのpathで問い合わせるのに対し、RelayではGraphQLという独自のクエリ言語でデータを取得しますが、双方とも解決しようとしている問題は同じです。

Relayでは、以下のようになっています。

class Tea extends React.Component {
  render() {
    var {name, steepingTime} = this.props.tea;
    return (
      <li key={name}>
        {name} (<em>{steepingTime} min</em>)
      </li>
    );
  }
}
TeaContainer = Relay.createContainer(Tea, {
  fragments: {
    tea: () => Relay.QL`
      fragment on Tea {
        name,
        steepingTime,
      }
    `,
  },
});

Relay, 格好良いコードですね。
何が良いって、以下のように書いたらTea コンポーネント単品でも動作するようにできています。

React.createElement(<Tea name={'JAPANESE GREEN'} steepingTime={3} />)

(大分どうでも良い話ですが、このコードをRelayのサイトで読んでsteepingTimeという言葉の意味を知りました。僕は英国紳士じゃないので、一生こんな単語使わない気がしますけど)

Component classからComponent classを生成する関数があればよい

閑話休題。
RelayやReduxを覗いた感じ、「React Component Classを引数に受け取って、デコったReact Component Classを動的に返す関数」を作れば、何かいい感じのモノが出来そうな気がする訳です。

やってみましょう。

先に利用側のコードを書いておきます。先述のHello worldのJSXを書き直してみました:

app.tsx(改)
import * as React from 'react';
import {render} from 'react-dom';
import {Model} from 'falcor';

import {connectModel, Store} from '../../../lib/built';

const store = Store.createFromModel(new Model({
    cache: {
        message: 'Hello, world'
    }
}));

class App extends React.Component<{message: string}, {}> {
    render() {
        return (
            <div>
                <h1>{this.props.message}</h1>
            </div>
        );
    }
}

const AppContainer = connectModel(App, store, {
    message: 'message'
});

render(<AppContainer />, document.getElementById('app'));
  • AppというReact ComponentはPropsのみでrender出来るようになっています
  • connectModel という関数(後述)にて「FalcorのModelからmessageというJSON Graphのパスを引いて、同名のpropsに値を注入してね」という内容を表現しています

さて、そんなconnectModelの実体コードが下記です。結構長いですので、流し読む程度で結構です。ポイントとなる箇所にはコメントを付記しているので参考まで。

lib/src/connectModel.ts
import * as React from 'react';
import Store from './store';
import {toArray} from './util';

export interface QueryHolder {
    query: string;
    toArray?: boolean;
    handler?: (res: any) => any;
}

export interface ModelToProps<T> {
    [propName: string]: (string | QueryHolder | ((externalProps: T) => QueryHolder));
}

function parse(modelToProps?: ModelToProps<any>) {
    return Object.keys(modelToProps).map(stateKey => {
        var queryObj = modelToProps[stateKey];
        var query: string, toArray = false;
        var fn: Function;
        if(typeof queryObj === 'string') {
            fn = (props: any) => {
                return {
                    query: queryObj,
                    toArray: false,
                }
            };
        }else if(typeof queryObj === 'object') {
            var obj = queryObj as QueryHolder;
            fn = (props: any) => {
                return {
                    query: obj.query,
                    toArray: obj.toArray,
                    handler: obj.handler
                };
            };
        }else if(typeof queryObj === 'function') {
            fn = queryObj as ((props: any) => QueryHolder);
        }
        return {
            stateKey,
            queryFn: fn as any
        };
    });
}

interface ContainerState {
    $loading: boolean;
    [key: string]: any;
}

// Containerのbase class
class Container extends React.Component<{[propKey: string]: any}, ContainerState> {

    state = { $loading: true };

    private queries: {stateKey: string; queryFn: (props: any) => QueryHolder}[];
    private unregisters: (() => void)[] = [];

    constructor(
        private model: Store,
        modelToProps: ModelToProps<any>,            // Containerに注入するFalcorのクエリ
        private delegate: (typeof React.Component)  // ラップ対象のReact Component
    ){
        super();
        this.queries = parse(modelToProps);
    }

    componentDidMount() {
        this.queries.forEach(q => {
            var stateKey = q.stateKey;
            var queryHolder = q.queryFn(this.props);

            // Falcor.Model#get(...) に対応するハンドラ
            var handler = (res => {
                var json = res.json;
                var newState: ContainerState = {$loading: false};
                var value: any;
                if(!json) return;
                if(typeof queryHolder.handler === 'function') {
                    value = queryHolder.handler(res);
                }else{
                    value = queryHolder.toArray ? toArray(json[stateKey]): json[stateKey];
                }
                // ContainerのStateにFalcor.Modelの値をセットする
                newState[stateKey] = value;
                this.setState(newState);
            });

            this.unregisters.push(this.model.addChangeListener(queryHolder.query, handler));

            this.model.get(queryHolder.query).then((res) => handler(res));
        });
    }

    componentWillUnmount() {
        this.unregisters.forEach($unreg => $unreg());
    }

    render() {
        var props = {};
        if(this.props) {
            Object.keys(this.props).forEach(key => {
                props[key] = this.props[key];
            });
        }
        // ContainerのStateをラップしたComponentのpropsにセット
        this.queries.forEach(q => {
            props[q.stateKey] = this.state[q.stateKey];
        });
        if(this.state.$loading) {
            return null;
        }else{
            // 初回ローディングが完了していればラップしたコンポーネントをレンダリング
            return React.createElement(this.delegate, props);
        }
    }
}

export class ContainerBase extends React.Component<any, any>{}

export default function connectModel<T>(delegate: typeof React.Component, model: Store, modelToProps: ModelToProps<T>) {
    // Containerのbase classを継承したクラスを返す
    return class extends Container {
        constructor() {
            super(model, modelToProps, delegate);
        }
    } as (typeof ContainerBase);
}

サンプルの割にconnectModelの実装が長くなってしまっている理由の1つに、第3引数(modelToProps)に色々なパターンを持たせたから、というのがあります。

汎用的にpropsへ値を注入できるようにしようとすると、Falcor.Model#getの引数とthen相当をほぼそのまま書けるような口を用意することになると思います。

もう少し複雑なクエリ
const TodoItemContainer = connectModel<{id: string}>(TodoItem, store, {
    todo(props) {                                                 // 外部から振ってくるpropsの値が参照できると色々嬉しいはず.
        query: `todoByIds.${props.id}["text", "completedAt"]`,    // 要求するJSON Graphのpath. 
        handler: json => {                                        // ModelのResponseから値を取得する処理.
            return json.todoByIds[props.id];
        }
    }
});

汎用性を確保しつつも、毎回これを書くのも微妙かと思い、幾つかのShortHandを用意していたら、実装が煩雑になってしまいました。

FalcorとFlux

ここまでFalcor.Modelのうち、getの話ばかりで、set/callの話題に触れないままになってしまいました。

よくよく考えると、今回のエントリでset/callのテーマが主役になり得ないのは、実は当たり前かもしれません。

上の図は、Falcorの公式サイトで「Async MVC」というキーワードと共に紹介されています。FluxやReduxの説明で良く見かける、単方向にデータが流れている図のアナロジーになっていますね。

connectModelで実装した部分が、図中でいうところの[Model] -> [View]へデータを受け渡す部分です。
今回のテーマが「ReactとFalcorをどう組み合わせるか」なのですから、Viewは一方的にModelに対してgetするだけです。

set/callについては、ComponentからActionをDispatchし、Actionに応じてStore(=Falcor.Model)が自身に対して更新系の処理を実行させれば良いだけです。上図でいうところの[Controller] -> [Model]の部分です。Viewが出てくる余地はありません。

本エントリの締めとして、facebook/flux を使ってFalcor.Modelのsetを呼び出すサンプルを貼っておきます。

Flux,React,Falcorによるカウントアップアプリの例
import * as React from 'react';
import {render} from 'react-dom';
import {Dispatcher} from 'flux';
import {Model} from 'falcor';

import {connectModel, Store} from '../../../lib/built';

/*** Common ***/
interface Action<T> {
    type: string;
    body: T;
}

/*** Dispatcher ***/
const dispatcher = new Dispatcher<Action<any>>();


/*** Store ***/
const store = Store.createFromModel(new Model({
    cache: {
        count: 0
    }
}));

// Liseten to action and set value to model
const id = dispatcher.register((action) => {
    switch(action.type) {
        case 'COUNTUP':
            store.set(action.body).subscribe();
    }
});


/*** ActionCreator ***/
const countUpAction = (value: number) => {
    return {
        type: 'COUNTUP',
        // It's compatible for parameters Falcor.model#set.
        body: {
            path: 'count',
            value
        }
    };
};


/*** Containers ***/

// Component
class App extends React.Component<{count: number, dispatch: (action: Action<any>) => void}, {}> {
    countUp() {
        this.props.dispatch(countUpAction(this.props.count + 1));
    }
    render() {
        var {count} = this.props;
        return (
            <div>
                <p>
                This is simple demonstation of Falcor, React and flux.
                </p>
                <button onClick={this.countUp.bind(this)}>Click + 1</button>
                <span>{count}</span>
            </div>
        );
    }
}

// App Container
const AppContainer = connectModel(App, store, {
    count: 'count'
});


/*** Bootstrap ***/
render(<AppContainer dispatch={dispatcher.dispatch.bind(dispatcher)}/>, document.getElementById('app'));

まとめと課題

今回は書きたかったのは「RelayやReduxのようにComponent to Componentな関数のアプローチをFalcorと組み合わせることで、何かいい感じのモノが出来そうな気がする」です。
少なくとも「Viewに対する値の注入に専念させる箇所を用意する」という考え方が結構気に入っています。

本エントリではViewのライブラリにReactを選びましたが、Angular2とFalcorを組み合わせるケースについて新鮮なFalcorとAngular2のサンプル、季節のRxJSとminimongoを添えて(TypeScriptのAbstract Class風味)が参考になります(紹介されているサンプルでも、Falcorからの値取得箇所はComponentの特定箇所で記述を強制させる工夫がなされていました)

一方、今回作成したStoreやらconnectModelについては、まだまだ満足出来ていない部分もあります。不満に感じている箇所をいくつか挙げてみました:

  1. Composition問題: Relayでは、createConteainer関数の呼び出し時にGraphQLの表現で、下層でrenderされるComponentへの値注入方法を記述できます。一方、今回僕の書いたconnnectModelにはそこまでの機能がありません。
  2. 値検知方法の課題: これはFalcor.ModelのonChange実装とも関連するので利用側だけでどうこう出来る話はないのですが、onChangeのAPI Referenceを読む限り、「JSON Graph上のどの部分が変更されたか」がリスナ側に渡ってきません。このため、実際はViewと関係すべきJSON Graphの部分集合には変化がないかもしれないのに、get ~ setState の一連処理が実行されてしまいます。性能を考えると結構微妙な気がする。。。
  3. 値取得タイミングの問題: 現状、ComponentがMountされるタイミングでいきなりFalcorへのgetが動作するんですけど、場合によっては遅延実行したいケースも出てくるんじゃないだろうか。

いずれにせよ、Falcorの活用事例も充分に溜まっているとは言えないのが現況ですので、しばらくの間は「こうするとより良くなるんじゃないか」と頭を回せるのが楽しみです。

参考:

18
20
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
18
20