Edited at

React.jsとFlux

More than 3 years have passed since last update.

今回はReact.jsとも関わりがあるFluxについて紹介したいと思います。

http://facebook.github.io/flux/


Flux is Architecture

Flux

https://github.com/facebook/flux

↑の図はfacebookのfluxのリポジトリにあるものですが、Fluxは上記のようなArchitectureの名称です。facebook/fluxのrepositoryに行ってもらうとわかるのですが、実装としてはDispatcherの部分があるだけです。


Unidirectional data flow

先ほどの図を見てもらうとわかる通り、Fluxではアプリケーションの複雑さをなくすため、データの流れを一方向にします。

そのため全体の処理の流れはわかりやすくなりますが、Angular.jsなどで書くときに比べて冗長に感じることもあるかと思います。

しかしながら単純なデータの流れを作ることで、ある程度の規模になってアプリケーションが複雑化してもデータやイベントの流れがスパゲッティにならず把握しやすい構造を保つことが出来るとされています。

(実際にFluxで大規模なアプリを作ってないので言い切れないですが...)


それでは各要素について下記の簡単なコードをもとに紹介していきたいと思います。

https://github.com/koba04/react-boilerplate

これはfacebookのFluxをベースに実装しています。


Fluxを構成する要素


Constants

Fluxでは、各要素間でやりとりするtypeを定数のように定義します。

var keyMirror = require('react/lib/keyMirror');

module.exports = {
ActionTypes: keyMirror({
RECEIVE_TRACKS_BY_ARTIST: null,
RECEIVE_TRACKS_BY_COUNTRY: null
}),
PayloadSources: keyMirror({
VIEW_ACTION: null
})
};

ちなみにこのkeyMirrorはkeyをvalueにもセットするだけのUtilです。


Dispatcher

DispatcherはActionを受け付けて登録されたcallbackを実行します。

ここではfacebook/fluxが唯一提供しているDispatcherを拡張するような形でオブジェクトを作ってシングルトンとして返しています。

ここではActionCreatorsからDispatcherにActionを投げるためのhandleViewActionを定義しています。

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

assign = require('object-assign'),
AppConstants = require('../constants/AppConstants')
;

var PayloadSources = AppConstants.PayloadSources;

module.exports = assign(new Dispatcher(), {
handleViewAction: function(action) {
this.dispatch({
source: PayloadSources.VIEW_ACTION,
action: action
});
}
});


Store

Storeはアプリケーションのデータと、ビジネスロジックを担当します。

Storeのデータはメッセージ一覧のようにデータの集合も扱います。


var AppDispatcher = require('../dispatcher/AppDispatcher'),
AppConstants = require('../constants/AppConstants'),
EventEmitter = require('events').EventEmitter,
assign = require('object-assign')
;

var ActionTypes = AppConstants.ActionTypes;
var CHANGE_EVENT = 'change';
var tracks = [];

var TrackStore = assign({}, EventEmitter.prototype, {

emitChange: function() {
this.emit(CHANGE_EVENT);
},
addChangeListener: function(callback) {
this.on(CHANGE_EVENT, callback);
},
removeChangeListener: function(callback) {
this.removeListener(CHANGE_EVENT, callback);
},
getAll: function() {
return tracks;
},
});

TrackStore.dispatchToken = AppDispatcher.register(function(payload) {
var action = payload.action;

switch (action.type) {
case ActionTypes.RECEIVE_TRACKS_BY_ARTIST:
tracks = action.tracks;
TrackStore.emitChange();
break;
case ActionTypes.RECEIVE_TRACKS_BY_COUNTRY:
tracks = action.tracks;
TrackStore.emitChange();
break;
}
});

module.exports = TrackStore;

ここでのポイントは、


  • データは外部からアクセスできない形で保持して、getterメソッドは定義してもsetterメソッドは定義しません。

  • データの更新は、ActionCreatorでDispatcherにdispatchさせて登録したcallback関数を呼ぶことで行います。


    • この説明だとわかりにくいかもですが...。



  • Dispatcherにcallbackを登録して処理を受けられるようにします。

  • StoreはEventEmitterの機能を持っていてデータが更新されるとイベントを発行します。


    • Viewはそのイベントを購読しています。




ActionCreators (Action)

Actionを作ってDispatcherに渡します。それだけです。

このサンプルではAjaxリクエストもActionCreatorsの中でやっていますが、facebook/fluxのサンプルではUtilsという名前空間を切ってその中で行う感じになっていました。

また、Ajaxリクエストが返ってきた時だけでなく投げた時にもActionを投げることでローディングのViewを表示したりといったことも出来るかなと思います。

var request = require('superagent'),

AppDispatcher = require('../dispatcher/AppDispatcher'),
AppConstants = require('../constants/AppConstants')
;

var ActionTypes = AppConstants.ActionTypes;
var urlRoot = "http://ws.audioscrobbler.com/2.0/?api_key=xxxx&format=json&";

// TODO Loading
module.exports = {
fetchByArtist: function(artist) {
request.get(
urlRoot + 'method=artist.gettoptracks&artist=' + encodeURIComponent(artist),
function(res) {
AppDispatcher.handleViewAction({
type: ActionTypes.RECEIVE_TRACKS_BY_ARTIST,
tracks: res.body.toptracks.track
});
}.bind(this)
);
},
fetchByCountry: function(country) {
request.get(
urlRoot + 'method=geo.gettoptracks&country=' + encodeURIComponent(country),
function(res) {
AppDispatcher.handleViewAction({
type: ActionTypes.RECEIVE_TRACKS_BY_ARTIST,
tracks: res.body.toptracks.track
});
}.bind(this)
);
}
};

Actionはこのような形のObjectです。

{

type: ActionTypes.RECEIVE_TRACKS_BY_ARTIST,
tracks: res.body.toptracks.track
}


View (ReactComponent)

データを表示するViewとActionを発行するViewに分けて紹介します。


  • Storeのデータを表示するReact Component

ViewではcomponentDidMountでStoreのchangeイベントを購読して、componentWillUnmountで購読を解除しています。

changeイベントが発行された時はStoreから再度データを取得してsetStateします。

Storeのデータ取得は同期的に取得できることが前提です。

module.exports = React.createClass({

getInitialState() {
return {
tracks: TrackStore.getAll(),
};
},
componentDidMount: function() {
TrackStore.addChangeListener(this._onChange);
},
componentWillUnmount: function() {
TrackStore.removeChangeListener(this._onChange);
},
_onChange: function() {
this.setState({ tracks: TrackStore.getAll() });
},
render() {
var tracks = this.state.tracks.map( (track, index) => {
return (
<li className="list-group-item" key={index}>
<span className="label label-info">{index+1}</span>
<a href={track.url} target="_blank"><span className="track">{track.name}</span></a>
<span className="artist">{track.artist.name}</span>
<small className="listeners glyphicon glyphicon-headphones">{track.listeners}</small>
</li>
);
});
return (
<div className="tracks">
<ul className="list-group">
{tracks}
</ul>
</div>
);
}
});


  • Actionを発行するReact Component

こちらはComponentのイベントを受けて、ActionCreatorsに投げてるだけです。

var React   = require('react/addons'),

AppTracksActionCreators = require('../actions/AppTracksActionCreators')
;

module.exports = React.createClass({
mixins: [React.addons.LinkedStateMixin],
getInitialState() {
return {
inputArtist: 'radiohead'
};
},
handleSubmit(e) {
e.preventDefault();
var artist = this.state.inputArtist;
if (artist) {
AppTracksActionCreators.fetchByArtist(artist);
}
},
render() {
return (
<form className="form-horizontal" role="form" onSubmit={this.handleSubmit} >
<div className="form-group">
<label htmlFor="js-input-location" className="col-sm-1 control-label">Artist</label>
<div className="col-sm-11">
<input type="text" className="form-control" placeholder="Input Atrist Name" valueLink={this.linkState('inputArtist')} required />
</div>
</div>
<div className="form-group">
<div className="col-sm-offset-1 col-sm-11">
<button type="submit" className="btn btn-primary"><span className="glyphicon glyphicon-search">search</span></button>
</div>
</div>
</form>
);
}
});


まとめ

というわけで、Dispatcher -> Store -> View -> ActionCreator -> Dispatcher...と一方向なデータの流れになっているのがわかるかと思います。


その他のFlux実装

FluxのArchitectureは比較的単純なので、実際にアプリケーションを作っている人がそれぞれ拡張してオレオレFluxライブラリが色々出来ている状態になっていますので少し紹介しておきます。

Fluxで実装する時の参考にするといいかと思います。


Flux + server-side rendering

Fluxの場合、Storeのデータがシングルトンになるのですが、server-side renderingの場合はシングルトンになると困るので、リクエスト毎にStoreを作る必要が出てくるので注意が必要です。

どうすればいいのかについては、Yahooの人によるこのスライドがものすごくわかりやすいので見てみることをオススメします。

https://speakerdeck.com/mridgway/isomorphic-flux


データのValidationについて

と聞かれたので考えてみました。上で紹介したフレームワークの中にも答えがありそうな気もしますが...。

個人的にはValidationのロジック自体は役割としてはStoreにあるのが正しいのかなと思っています。ViewがActionを投げてStoreが受け取ったときに不正なデータの場合は、エラーのイベントをViewに投げてViewは必要あればエラーの表示をする流れがいいのかなと個人的には思っています。

------         ------------        -------------------        ------

|View| --------|Dispatcher|--------|StoreでValidation|--------|View|--- エラー表示
------ action ------------ action ------------------- error ------

エラーの投げ方はいくつかのパターンがあるかなと思うのですが、Nodeっぽく第一引数にerrを入れるとかでもいいのかなと思ったりしました。

var TrackStore = assign({}, EventEmitter.prototype, {

emitChange: function(err) {
this.emit(CHANGE_EVENT, err);
},
addChangeListener: function(callback) {
this.on(CHANGE_EVENT, callback);
},
removeChangeListener: function(callback) {
this.removeListener(CHANGE_EVENT, callback);
},
getAll: function() {
return tracks;
},
});

TrackStore.dispatchToken = AppDispatcher.register(function(payload) {
var action = payload.action;

switch (action.type) {
case ActionTypes.RECEIVE_TRACKS_BY_ARTIST:
var err = null;
if (action.tracks.length === 0) {
err = 'no tracks';
} else {
tracks = action.tracks;
}
TrackStore.emitChange(err);
break;
case ActionTypes.RECEIVE_TRACKS_BY_COUNTRY:
tracks = action.tracks;
TrackStore.emitChange();
break;
}
});

module.exports = React.createClass({

getInitialState() {
return {
tracks: TrackStore.getAll(),
err: null
};
},
componentDidMount: function() {
TrackStore.addChangeListener(this._onChange);
},
componentWillUnmount: function() {
TrackStore.removeChangeListener(this._onChange);
},
_onChange: function(err) {
if (err) {
this.setState({err: err});
} else {
this.setState({ tracks: TrackStore.getAll() });
}
},

またはerrではなくてObjectを渡してtypeとしてerrorを指定する方法だったり、エラーは別のイベントとして発行する方法(CHANGE_EVENTではなくてERROR_EVENTなど)もあるかなと思いました。

Fluxは概念の提供な部分が多いので、この辺りはそれぞれが最適な実装をするのがいいのかなと思っていますが、オレオレFluxが乱立してよくわからなくなるのもなぁと思ったり悩ましい感じがしています。


というわけで今回はFluxについて簡単に紹介しました。

明日はReact.jsとCSSということで、CSS in JSという考え方を紹介したいと思います。