次期プロダクトでReact.jsを使ってみようと思っていて、その設計をどうすれば良いのかと試行錯誤した結果、それなりにイケてる結論に辿り着いたので、そのメモ書きです。
作ってみれば、Fluxとはこういうことか!というのがわかります。(若干アレンジはされてると思うけど。)
それまで漠然と「ふーん、なるほどね。。」みたいな感じでなんとなくしか理解してなかったFluxが実は超画期的なパラダイムシフトであったことに気がついて結構衝撃を受けています。(^^;
ちなみにプログラミングの文脈でeと言ったらまず思いつくのがEventかExceptionのどちらかだと思うけど、この場合はもちろんEventのことです。
すべてがExceptionになるのなら、即刻使うのを止めた方が良い。(^^;
Fluxとは
Fluxの説明では必ずと言って良いほど参照される図なので見たことある人も多いと思うけど、こういうアーキテクチャのことです。
ポイントはReactViews, ActionCreators, Dispatcher, Storeから形成される円の矢印が一方向にしか流れていないこと。
詳細な説明は公式サイトを読んでくれれば良いんですが、個人的にはDispatcher, ViewはまだしもActionCreator, Storeという用語がイマイチぴんと来ておらずそれが理解を阻害していた印象があります。
自分の言葉で置き換えるならばActionCreator, Storeは両方ともModelです。
そして、DispatcherはシングルトンのEventEmitterそのものです。
この考え方で実際にどういう実装になるかを見ていきます。
EventService
以下、プロトタイプのEventServiceの全ソースです。
WebPackを使っているので、node.jsっぽくmodule.exportsを使ってますが、通常のクライアントサイドのJSとして書く場合でも中身は一緒です。
var EventEmitter = require("wolfy87-eventemitter");
function EventService() {
this.on("error", function(source, data) {
console.log("!!!error!!!", source, data);
});
}
EventService.prototype = new EventEmitter();
EventService.prototype.error = function(source, data) {
this.emit("error", source, data);
};
module.exports = new EventService();
module.exports = {
USER_SIGNIN: "user.signin",
USER_FAIL_SIGNIN: "user.failSignin",
USER_SIGNOUT: "user.signout",
ERROR: "error"
};
SyntaxSugarとしてデバッグ用のerrorメソッドが追加してありますが、実質的にはEventEmitterそのままです。
なんなら「var EventService = new EventEmitter()」の1行だけでも構いません。
こいつがシングルトンのDispatcherとしてアプリケーション内の全イベントを集約します。
イベント名のリテラルが各所に分散して使用されるのはイケてないので、イベント名は定数クラスに集約しています。
API
続いてAPIの抜粋
var $ = require("jquery");
function API() {
this.endpoint = "";
}
API.prototype.exec = function(method, path, data) {
return $.ajax({
url: this.endpoint + path,
type: method,
data: data
});
};
API.prototype.signin = function(nameOrEmail, password) {
return this.exec("POST", "/api/auth/signin", {
nameOrEmail: nameOrEmail,
password: password
});
};
API.prototype.signout = function() {
return this.exec("GET", "/api/auth/signout");
};
module.exports = new API();
通信のライブラリには使い慣れているのでjQueryを使うことにしました。
要は$.ajaxでAPIをキックしてそのPromiseを返り値として返しているだけ。
ポイントは余計な処理を一切挟まず、ひたすらサーバの提供するAPIを写経することに徹していること。(後から共通のエラー処理は追加するかもしれませんが。)
APIを実行した結果を受けて何をするか、という部分はModelあるいはViewの役割です。
ちなみにendpointは通常は空文字として、コンテンツを生成したOriginサーバと通信を行いますが、CORS対応した開発サーバを用意してそのURLを差し込めばサーバなしでフロントエンド開発を行うことができます。(という目論見でつけていますが、実際にはまだ試していません。(^^;;; 多分webpack-dev-serverが使えるんじゃないかと思っていますが、うまくいったらその話はまたそのうち。)
Model
例としてsignin, signoutを処理するUserServiceの簡略化版を示します。
var $ = require("jquery");
var api = require("../api/API");
var EventService = require("./event.service");
var Events = require("../constants/event.constants");
function UserService() {
var user;
function getAuthorizedUser() {
return user;
}
function setAuthorizedUser(v) {
user = v;
}
function signin(nameOrEmail, password) {
api.signin(nameOrEmail, password).done(function(data) {
if (data.code == 200) {
user = data.user;
EventService.emit(Events.SIGNIN, data.user);
} else {
EventService.emit(Events.FAIL_SIGNIN);
}
}).fail(function(xhr) {
EventService.error("UserService.signin", xhr);
});
}
function signout() {
if (user) {
api.signout().done(function(data) {
user = null;
EventService.emit(Events.SIGNOUT);
}).fail(function(xhr) {
EventService.error("UserService.signout", xhr);
});
}
}
$.extend(this, {
getAuthorizedUser: getAuthorizedUser,
setAuthorizedUser: setAuthorizedUser,
signin: signin,
signout: signout
});
}
module.exports = new UserService();
getAuthorizedUserはログインしているユーザを返すメソッドです。
setAuthorizedUserはHTMLの最初のロード(またはリロード)時に既にログイン済みのユーザがいれば、それを設定するためのメソッドです。
signin/signoutはAPIを実行して、その結果を受けてuserを設定したあとイベントを発火しています。
ポイントはこれらのActionメソッドが返り値として何も返していないという点です。
同期APIの設計に慣れているとsigninメソッドではUserオブジェクトを返したくなりますが、非同期処理ではそれは無理です。
だからと言って、ここでPromiseを返して。。。とかやりはじめると処理がいたずらに複雑になってしまいます。
なので、これらのActionメソッドではイベントを発火するだけで返り値は持ちません。
つまり、呼び出し側は自分がUserService#signinを呼び出したにも関わらず実際にsigninに成功したかどうかを知ることができないわけで、この潔さがFlux設計の最大のキモです。
signinの結果を知りたい場合は別途EventServiceでsigninイベントをwatchします。
(こうして整理するとfail時の処理は常に同じになりそうなのでこれはやはりAPI側で共通処理にした方が良さそうです。)
View
まず、ユーザのsignin/signoutを制御するユーザコンポーネントを示します。
このコンポーネントはQiitaやGitHubの画面右上にあるドロップダウン形式のユーザアイコンのようになるイメージですが、ここでは簡略化のために
- signin済みであればユーザ名とsignoutボタンを表示
- signinしていなければsignin画面へのリンクを表示
としています。(cssも考慮していません。)
var React = require("react");
var Navigation = require('react-router').Navigation;
var Link = require('react-router').Link;
var UserService = require("../models/user.service");
var EventService = require("../models/event.service");
var Events = require("../constants/event.constant");
module.exports = React.createClass({
mixins: [Navigation],
getInitialState: function() {
return {
user: UserService.getAuthorizedUser()
};
},
onSigninOrSignup: function(user) {
this.setState({
user: user
});
this.transitionTo("home");
},
onSignout: function() {
this.setState({
user: null
});
this.transitionTo("home");
},
signout: function() {
UserService.signout();
},
componentDidMount: function() {
EventService.on(Events.USER_SIGNIN, this.onSigninOrSignup);
EventService.on(Events.USER_SIGNUP, this.onSigninOrSignup);
EventService.on(Events.USER_SIGNOUT, this.onSignout);
},
componentWillUnmount: function() {
EventService.off(Events.USER_SIGNIN, this.onSigninOrSignup);
EventService.off(Events.USER_SIGNUP, this.onSigninOrSignup);
EventService.off(Events.USER_SIGNOUT, this.onSignout);
},
render: function() {
var user = this.state.user;
if (user) {
return <div>
<div>{user.name}</div>
<div><button onClick={this.signout}>MSG.signout</button></div>
</div>
} else {
return <div><Link to="signin">MSG.signin</Link></div>
}
}
});
signoutボタンのクリックでUserService#signoutを実行していますが、ここでは表示の切り替えは行っていません。
というより実際にsignoutに成功したかどうかがこの段階ではわからないので見切りで表示を変更してはいけません。
表示の切り替えは(UserService#signoutの呼び出しの結果発生する)signoutイベントを拾ってそこで行っています。
signin/signupはこのコンポーネントが行う処理ではありませんが、それらのイベントも拾って表示の切り替えを行っています。
各種イベントリスナの登録/解除はcomponentDidMount/componentWillUnmountで行うのがセオリーです。
ちなみに本筋から外れますがMSGはキーと文字列を定義しただけのグローバルオブジェクトです。
HTML側で参照するjsファイルを切り替えることで日本語・英語を切り替えることができます。
var MSG = {
"signin" : "サインイン",
"signout" : "サインアウト",
"username" : "ユーザ名",
"password" : "パスワード",
"format" : function(fmt) {
for (i = 1; i < arguments.length; i++) {
var reg = new RegExp("\\{" + (i - 1) + "\\}", "g")
fmt = fmt.replace(reg,arguments[i]);
}
return fmt;
}
}
もう一例、signinのソースも示します。
var React = require("react/addons");
var Link = require('react-router').Link;
var UserService = require("../models/user.service");
var EventService = require("../models/event.service");
var Events = require("../constants/event.constant");
var Alert = require("./alert.components.jsx");
module.exports = React.createClass({
mixins: [React.addons.LinkedStateMixin],
getInitialState: function() {
return {
nameOrEmail: "",
password: "",
message: ""
};
},
signin: function() {
UserService.signin(this.state.nameOrEmail, this.state.password);
},
onFailSignin: function() {
this.setState({
message: MSG.signinFailed
});
},
componentDidMount: function() {
EventService.on(Events.USER_FAIL_SIGNIN, this.onFailSignin);
},
componentWillUnmount: function() {
EventService.off(Events.USER_FAIL_SIGNIN, this.onFailSignin);
},
render: function() {
return <div>
<h2>{MSG.signin}</h2>
<ul>
<li>
<label>{MSG.usernameOrEmail}</label>
<input type="text" valueLink={this.linkState('nameOrEmail')} />
</li>
<li>
<label>{MSG.password}</label>
<input type="password" valueLink={this.linkState('password')} />
</li>
</ul>
<button onClick={this.signin}>{MSG.signin}</button>
<Alert message={this.state.message}></Alert>
<hr/>
<Link to="signup">{MSG.signup}</Link>
</div>
}
});
siginコンポーネントではユーザの入力を元にUserService#signinメソッドを実行していますが、成功時にはその結果をハンドルしていません。
それはユーザコンポーネントがやっているからです。
ただし、signinの失敗(パスワード間違いなど)はこの画面上で表示する必要があるのでそちらのイベントは拾ってエラーメッセージを表示しています。(Alertはエラー表示のために作成した別コンポーネントです。)
このようにViewコンポーネントは
- ユーザActionに応答して何かする
- 自分の関心のあるイベントに応答して何かする
の2つを別々に考えてそれぞれ実装すれば良いわけです。
signoutのようにユーザActionの結果、それに対応するイベントが発生することがわかりきっている場合でも、決してその二つを混ぜて実装してはいけません。
Flux再考
以上がこれからReactでアプリを作る際に使おうと思っている設計パターンです。
ここでもう一度signinを例に各種モジュールがやっていることを整理します。
- Viewからsigninボタンをクリック
- Modelのsigninアクションを実行
- Modelがsigninイベントを発行
ユーザによる「signinボタンのクリック」というEventから始まって、ここまでで処理の流れが一度途切れます。
そしてそれとは別にDispatcherによる「signinイベントの発火」というEventから始まる処理の流れがあるわけです。
- Dispatcherがsigninイベントを発火
- ModelまたはViewが対応するアクションを実行
ここに至って再度Fluxの概念図を見ると、これらの一連の処理を眺めると完全に一方通行のFluxサイクルを踏襲していることがわかります。
正直、試行錯誤中はそこまでFluxを強く意識していたわけではありませんが、出来上がったものを見てFluxとはこういうことか!というのを強く実感しました。(多分ね。。。(^^;;; 正規ドキュメントをしっかりと読んでないので細部は違うところもあると思いますが、大筋は外してないはずです。)
まとめ
イベントドリブンは昔からあるパラダイムですが、JavaのEventListenerなどは各種コンポーネントのインスタンスがイベントを管理しているため、結局疎結合になりきれていない部分がありました。
このやり方の場合各種Viewコンポーネントを独立して自律したコンポーネントとして作成できるので、例えば「signoutのボタンをユーザコンポーネントとは別のところにもつける。」みたいな変更がある場合も既存のコンポーネントには影響がないので修正範囲が局所化できます。
個人的にはこれは結構革命的なパラダイムシフトです。
Angularとの比較で考えた場合、おそらくReactの方が記述しなければならないコード量は増えます。
個人的にはAngularはいかなる手段を使ってでもいかにコード量少なくアプリを作成できるか、という命題を追求したフレームワークだと思っていて、その結果として一貫性が無かったり、「そんなんありかよ?」と思うような強引な力技が使われている部分もあると思っています。
一方のReact+Fluxは一方向のデータフローをルール化することによりアプリをシンプルに保つことが可能になっている気がします。
プロダクトが成長した時にどちらが複雑性が増大するかと言えば、それは圧倒的にAngularの方なんじゃないですかね。
業務システムやサービスの内部向け管理システムなどを作る場合はAngularの方が向いていると思いますが、コンシューマ向けのアプリの場合はReactを使ってみるのもアリかと思います。
この記事では触れていませんが、コンポーネント指向によるメリット(例えばAlertを後から差し替えられるなど)も変化の激しいコンシューマアプリを作る上では有利です。