2015年5月5日
FacebookのFluxアーキテクチャの始め方 Part 2
(2015-02-21)by Ryan Clark
本記事は、原著者の許諾のもとに翻訳・掲載しております。
前編はこちら: 【翻訳】FacebookのFluxアーキテクチャの始め方(前編)
血液(イベント)を流す
次は、イベントがアプリの周りに流れるようにします。そうすれば、UserList上の様々なユーザをクリックしてチャットを切り替えることができます。
最初のアクション
アクションはどこからでも生じる可能性があります。私たちは、アクションは何かに影響を与えるということと、自分たちがディスパッチャを通してアクションをストアに流し込む必要があるということさえ分かっていればいいのです。ユーザがあるユーザをクリックしたらアクションを作成し、イベントを送り出します。
public/src/js内にactionsと呼ばれるフォルダがあります。messages.jsというファイルを作成しましょう。
var Dispatcher = require('../dispatchers/app');
var messagesActions = {
changeOpenChat: function (newUserID) {
Dispatcher.handleViewAction({
type: 'updateOpenChatID',
userID: newUserID
});
}
};
module.exports = messagesActions;
特別なアクションではありません。npmモジュールや他の依存関係は不要で、必要なのは作成済みのディスパッチャだけです。これで、IDでchangeOpenChatを呼び出せるようになり、アクションがストアに送り出されます。しかし、待ってください。ストアはそれに備えて待機しておく必要があります。
ストア内のイベントを監視
もう一度MessagesStoreを開いて、dispatchTokenを見てみましょう。ディスパッチャが送信すべきアクションを受信するたびに、この関数が呼び出されます。これにより、私たちは送信されたペイロードが自分たちに関係があるかどうか確認でき、関係があれば少し凝ったことを行います。
messagesStore.dispatchToken = Dispatcher.register(function (payload) {
var actions = {
updateOpenChatID: function (payload) {
}
};
actions[payload.action.type] && actions[payload.action.type](payload);
});
アクション内で送信されたイベントのtypeプロパティ値であるキーを持つオブジェクトを追加しました。この関数は、与えられた型と一致するプロパティがあるかどうかを確認して、もしあればそれを呼び出します。これで私たちはこの関数内でストアの値をアップデートし、何かが変更されたことをビューに通知できます。
messagesStore.dispatchToken = Dispatcher.register(function (payload) {
var actions = {
updateOpenChatID: function (payload) {
openChatID = payload.action.userID;
messagesStore.emit('change');
}
};
actions[payload.action.type] && actions[payload.action.type](payload);
});
新しいIDで変数openChatIDをアップデートして、ストアで変更のイベントを発行しました(注:emitメソッドはEventEmitterによって提供されます)。
すばらしいですが、ビューには何かが変更されたことが伝わっていません。ビューがイベントを購読していないからです。
ビューにlistenさせる
それでは、ビューにリスナを追加して、何かが変更されたという通知を受けるたびに状態をアップデートするようにしていきます。MessageBoxを開けば、componentWillMountとcomponentWillUnmountメソッドを追加できます。これは、ビューが作成される時には必ずイベントを追加し、何らかの理由でビューが削除されたらイベントも取り除き、その後エラーが発生しないようにするためです。
var ReplyBox = require('../components/replyBox');
var MessagesStore = require('../stores/messages');
var UserStore = require('../stores/user');
var Utils = require('../utils');
function getStateFromStore() {
return MessagesStore.getChatByUserID(MessagesStore.getOpenChatUserID())
}
var MessageBox = React.createClass({
getInitialState: function () {
return getStateFromStore();
},
componentWillMount: function () {
MessagesStore.addChangeListener(this.onStoreChange);
},
componentWillUnmount: function () {
MessagesStore.removeChangeListener(this.onStoreChange);
},
onStoreChange: function () {
this.setState(getStateFromStore());
},
render: function () {
// omitted logic
}
});
module.exports = MessageBox;
getStateFromStoreという新しい関数が使われていることに気づくでしょう。状態を取得する呼び出しを抽象化して何度も再利用できるようにしました。そうすれば、状態を取得する方法を変更しなければならなくなっても、あちこち変更せずに済みます。
コンポーネントをマウントする時は、変更リスナを追加してonStoreChangeメソッドを実行し、ビューをストア内の最新データにアップデートします。コンポーネントをアンマウントする時は、使ったところをきれいにしてリスナを削除します。
そして再び…
UserListにも同じことをする必要があります。UserListビューに対して単に手動で状態をアップデートすることもできますが、ベストプラクティスはストアからのデータに頼ることです。全てのビューが同じデータを見ることになるからです。
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
}
});
module.exports = UserList;
これが以下のようになります。
var utils = require('../utils');
var MessagesStore = require('../stores/messages');
var UserStore = require('../stores/user');
function getStateFromStore() {
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
};
}
var UserList = React.createClass({
getInitialState: function () {
return getStateFromStore();
},
componentWillMount: function () {
MessagesStore.addChangeListener(this.onStoreChange);
},
componentWillUnmount: function () {
MessagesStore.removeChangeListener(this.onStoreChange);
},
onStoreChange: function () {
this.setState(getStateFromStore());
},
render: function () {
// omitted logic
}
});
module.exports = UserList;
できました。これでUserListもイベントに耳を傾けるようになります。
イベントの発行
イベントをビューに表示する準備はできました。あとは実行するだけです。UserListビュー上でユーザユーザが別のユーザをクリックすると、イベントを発するようにします。まず、UserListを開き、onClickリスナを追加します。
var utils = require('../utils');
var MessagesStore = require('../stores/messages');
var UserStore = require('../stores/user');
var UserList = React.createClass({
...,
changeOpenChat: function (ID) {
},
render: function () {
// omitted logic
var messages = this.state.messageList.map(function (message, index) {
// omitted logic
return (
<li onClick={ this.changeOpenChat.bind(this, message.user.ID) } className={ itemClasses } key={ message.user.ID }>
<div className="user-list__item__picture">
<img src={ message.user.profilePicture } />
</div>
<div className="user-list__item__details">
<h4 className="user-list__item__name">
{ message.user.name }
<abbr className="user-list__item__timestamp">
{ date }
</abbr>
</h4>
<span className="user-list__item__message">
{ statusIcon } { message.lastMessage.contents }
</span>
</div>
</li>
)
}, this);
// omitted logic
}
});
module.exports = UserList;
これで、ユーザがクリックしたユーザIDでchangeOpenChatメソッドを呼び出すonClickイベントができました。ここで前に作成したアクションを起動します。
var utils = require('../utils');
var MessagesStore = require('../stores/messages');
var UserStore = require('../stores/user');
var MessagesActions = require('../actions/messages');
var UserList = React.createClass({
...,
changeOpenChat: function (ID) {
MessagesActions.changeOpenChat(ID);
},
render: function () {
// omitted logic
}
});
module.exports = UserList;
なかなかですね。これで設定できたので、定義されたアクションをIDで呼び出すだけです。ディスパッチャによってイベントがストアに送られ、ビューに更新が通知されます。本当に簡単です。別のユーザをクリックすると、スクリーンショットのように、クリックしたUserListビュー上のユーザのメッセージを見ることができます。
ここで結果をこのリンク先で試すことができます 。ボイラープレート上でstep-threeブランチをチェックして、コードを全てチェックすることもできます。
メッセージの送信
次のステップは、メッセージの送信です。とても簡単なので気に入ってもらえると思います。初めに、ユーザ入力が分かるイベントを入力ボックスに作成する必要があります。やってみましょう。
イベントの作成
public/src/js/componentsの中にReplyBoxと呼ばれるコンポーネントがあります。開くと、骨子のみのReactコンポーネントがあるはずです。まず、コンポーネントに設定されている入力の値をトラッキングする必要がありますので、入力ボックスにonChangeイベントを追加します。さらに、このコンポーネントの状態もトラッキングするので、getInitialStateメソッドを追加して初期状態に戻るようにします。
var ReplyBox = React.createClass({
getInitialState: function () {
return {
value: ''
};
},
updateValue: function (e) {
},
render: function () {
return (
<div className="reply-box">
<input onChange={ this.updateValue } className="reply-box__input" placeholder="Type message to reply.." />
<span className="reply-box__tip">
Press <span className="reply-box__tip__button">Enter</span> to send
</span>
</div>
);
}
});
module.exports = ReplyBox;
Reactはユーザ入力から関数が呼び出されると、イベントを通して作成済みの関数に送られます。e.target.valueを見ればユーザ入力にアクセスできます。あとは、状態をe.target.valueに設定し、入力値を状態に設定するだけです。 Reactイベントに関する詳細はこちらをご覧ください。
var ReplyBox = React.createClass({
getInitialState: function () {
return {
value: ''
};
},
updateValue: function (e) {
this.setState({
value: e.target.value
});
},
render: function () {
return (
<div className="reply-box">
<input value={ this.state.value } onChange={ this.updateValue } className="reply-box__input" placeholder="Type message to reply.." />
<span className="reply-box__tip">
Press <span className="reply-box__tip__button">Enter</span> to send
</span>
</div>
);
}
});
module.exports = ReplyBox;
すばらしいですね。これで返信ボックスの中のコンポーネントのstateオブジェクトに入力されたものにアクセスできます。さらに、入力ボックスにonKeyDownイベントを追加すれば、イベントのkeyCodeをチェックすることができます。13(エンターキーを表すJavaScriptキーコードの値)であれば、送信したあと、入力ボックスの値をクリアします。
var MessagesActions = require('../actions/messages');
var MessagesStore = require('../stores/messages');
var ReplyBox = React.createClass({
getInitialState: function () {
return {
value: ''
};
},
handleKeyDown: function(e) {
if (e.keyCode === 13) {
MessagesActions.sendMessage(MessagesStore.getOpenChatUserID(), this.state.value);
this.setState({
value: ''
});
}
},
updateValue: function (e) {
this.setState({
value: e.target.value
});
},
render: function () {
return (
<div className="reply-box">
<input value={ this.state.value } onKeyDown={ this.handleKeyDown } onChange={ this.updateValue } className="reply-box__input" placeholder="Type message to reply.." />
<span className="reply-box__tip">
Press <span className="reply-box__tip__button">Enter</span> to send
</span>
</div>
);
}
});
module.exports = ReplyBox;
イベントのkeyCodeのチェックを追加したので、ユーザが入力をすると、送信したいメッセージの値でMessagesActionsの中のsentMessagesが呼び出されます。しかし、まだアクションを作成していません。
アクションの作成
MessagesAcetionsオブジェクトに新しいアクションを追加します。これで送りたい内容と送り先のユーザIDが認識されます。そして、ストアにイベントを送り、接続を待ちます。通常は、この時点でメッセージがサーバに送られ、問題がなければ、サーバの応答があり、イベントが送られます。
var Dispatcher = require('../dispatchers/app');
var messagesActions = {
changeOpenChat: function (newUserID) {
Dispatcher.handleViewAction({
type: 'updateOpenChatID',
userID: newUserID
});
},
sendMessage: function (userID, message) {
Dispatcher.handleViewAction({
type: 'sendMessage',
userID: userID,
message: message,
timestamp: +new Date()
});
}
};
module.exports = messagesActions;
これでアクションの作成が完了しました。返信ボックスにメッセージを入力して、エンターキーを押すと、ボックスが空になることが確認できるはずです。なお、この時点でストアは単に入力内容をクリアしているだけで、イベントの監視はまだ行っていません。では新しいメッセージを監視するようにストアを設定しましょう。
ストアウォッチャの作成
まずMessagesStore内のdispatchTokenに戻って、オブジェクトにsendMessageという新たなアイテムを追加しましょう。ここでは送信先のユーザID、送信時のタイムスタンプ、メッセージの内容を取得します。
以下のコードでは、messagesオブジェクトでメッセージを送った相手を検索して、送信先となるユーザのメッセージ配列に新規メッセージをプッシュしています。
messagesStore.dispatchToken = Dispatcher.register(function (payload) {
var actions = {
updateOpenChatID: function (payload) {
openChatID = payload.action.userID;
messagesStore.emit('change');
},
sendMessage: function (payload) {
var userID = payload.action.userID;
messages[userID].messages.push({
contents: payload.action.message,
timestamp: payload.action.timestamp,
from: UserStore.user.id
});
messagesStore.emit('change');
}
};
actions[payload.action.type] && actions[payload.action.type](payload);
});
これでストアがメッセージを監視するよう準備ができました。ではローカルホストを見てみましょう。返信ボックスにメッセージを入力してエンターキーを押すと、入力内容が最新の送信メッセージとして、MessageBoxビューとUserListビューの両方に表示されるはずです。
ここで私宛にメッセージを送信してみると、「Read」という既読状態を示す文言が消えることに気づくでしょう。これは、チャットの最終アクセス時は(lastAccessによって)常に監視されているためです。あなたがメッセージを送ってから、私はまだチャットにアクセスしていないので、このような結果になります。
さらにToddやJillesにメッセージを送信すると、彼らはリストの上位に表示されるようになります。つまり最新メッセージが送信された時間に基づいて、ユーザの並び替えが行われるのです。
このリンク先でメッセージ送信をお試しください。 。step-fourブランチをチェックして、コードを確認することもできます。
最終アクセス時間の更新
この時点ではまだ、lastAccessのステータスを更新するメソッドはありません。そのためUserLsitビュー上では、あなたが送信したメッセージの横には、全て丸印が付いていることになります。
以下のように、ストア内でユーザがメソッドを呼び出すと、lastAccessのステータスを簡単に更新することができます。
messagesStore.dispatchToken = Dispatcher.register(function (payload) {
var actions =
updateOpenChatID: function (payload) {
openChatID = payload.action.userID;
messages[openChatID].lastAccess.currentUser = +new Date();
messagesStore.emit('change');
},
sendMessage: function (payload) {
var userID = payload.action.userID;
messages[userID].messages.push({
contents: payload.action.message,
timestamp: payload.action.timestamp,
from: UserStore.user.id
});
messages[userID].lastAccess.currentUser = +new Date();
messagesStore.emit('change');
}
};
actions[payload.action.type] && actions[payload.action.type](payload);
});
updateOpenChatID(ユーザがチャットを開いた直後)とsendMessage(ユーザがメッセージを送信した直後)の両方の場合について、lastAccessの値を更新する処理を追加しました。
ここで未読メッセージがあるユーザ(Toddなど)をクリックすると、既読に変わります。またJillesに返信をした場合は、UserListビューにきちんと返信アイコンが表示されるはずです。
このリンク先で完成したチャットアプリを試すことができます 。step-fiveブランチをチェックして、コードを確認することもできます。
waitFor関数
Fluxを使うと、他のストアでデータ処理が終わるのを待ってから、自身のストアのdispatchTokenを実行させることが可能です。そのためには、自身のストア開始時に、dispatchToken関数の1行目で、ディスパッチャであるwaitFor関数を呼び出し、処理を先に終わらせたいストアの配列を渡します。
Store.dispatchToken = Dispatcher.register(function (payload) {
Dispatcher.waitFor([
OtherStore.dispatchToken,
AndAnotherStore.dispatchToken
]);
});
まとめ
一見するとFluxはとっつきにくく感じるかもしれませんが、実際はとても簡単に、そしてすばやく開発ができるアーキテクチャです。上記のステップに従うだけで、自身のアプリケーションでもFluxを使用することができます。もし何か困ったことがあれば、Twitterで @rynclark までご連絡ください。将来的にはこの記事で紹介した内容をnode.jsやsocket.ioに統合しようと検討していますので、お楽しみに。
By Ryan Clark
株式会社リクルート プロダクト統括本部 プロダクト開発統括室 グループマネジャー 株式会社ニジボックス デベロップメント室 室長 Node.js 日本ユーザーグループ代表
- Twitter: @yosuke_furukawa
- Github: yosuke-furukawa