このFalcor入門シリーズも4回目です。実装方法については、第2回でクライアントサイドでのAPI利用方法、第3回でサーバサイドの実装方法をそれぞれ解説してきました。
ちなみに過去回はこちら:
- Falcor入門 1日目 Falcorとは何者か
- Falcor入門 2日目 FalcorのJSON Graphに触れてみる
- Falcor入門 3日目 Falcor Routerでサーバサイドを実装してみる
今まで何度か書いてきた通り、Falcorは「クライアントサイドから効率的に値を取得してViewに受け渡すこと」を目的に開発されたMiddlewareです。
前回までは、話の焦点をFalcor自体の使い方に絞るために、コンソールで文字を表示するばっかりのサンプルを書いていました。
しかし、本来Falcorは画面系のフレームワークと組み合わせて使ってこそ意味のあるライブラリです。
今回のエントリでは、皆大好きReactとFalcorを組み合わせてみたいと思います。
前提
最初に何点か断っておきます。
- 前回まではトランスパイラにBabelを選択してサンプルを書いてきました。でも、やっぱりTypeScriptの方が好きなので今日は.ts, .tsxでコード書いてます。とはいえ、特にTypeScriptならではの話をするつもりもないですので、ECMA Script 6thとReactJSXが読めれば問題無いと思います。
- 今回はサンプルコードをQuramy/react-falcor-demoにupしておきました。環境構築手順等はこのレポジトリのREADMEに書いてあるので、本文中での説明はしません。
- 前回は色気出してサーバサイドの話をしましたが、今回は面倒なので第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を拡張してやりましょう。
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です。
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して表示することは出来るようになりましたが、幾つかの課題があります。
-
componentDidMount
,componentWillUnmount
での購読・購読解除は定形文なのにModelとViewを結合する度に毎回書くのは馬鹿げている - とくに
componentDidMount
では、初回の値取得と値の購読を両方書いてやる必要もある - 値の取得処理(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を書き直してみました:
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
の実体コードが下記です。結構長いですので、流し読む程度で結構です。ポイントとなる箇所にはコメントを付記しているので参考まで。
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を呼び出すサンプルを貼っておきます。
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
については、まだまだ満足出来ていない部分もあります。不満に感じている箇所をいくつか挙げてみました:
- Composition問題: Relayでは、
createConteainer
関数の呼び出し時にGraphQLの表現で、下層でrenderされるComponentへの値注入方法を記述できます。一方、今回僕の書いたconnnectModel
にはそこまでの機能がありません。 - 値検知方法の課題: これはFalcor.ModelのonChange実装とも関連するので利用側だけでどうこう出来る話はないのですが、onChangeのAPI Referenceを読む限り、「JSON Graph上のどの部分が変更されたか」がリスナ側に渡ってきません。このため、実際はViewと関係すべきJSON Graphの部分集合には変化がないかもしれないのに、
get
~setState
の一連処理が実行されてしまいます。性能を考えると結構微妙な気がする。。。 - 値取得タイミングの問題: 現状、ComponentがMountされるタイミングでいきなりFalcorへのgetが動作するんですけど、場合によっては遅延実行したいケースも出てくるんじゃないだろうか。
いずれにせよ、Falcorの活用事例も充分に溜まっているとは言えないのが現況ですので、しばらくの間は「こうするとより良くなるんじゃないか」と頭を回せるのが楽しみです。