Flux]1を読んでいますが、Todoアプリの例があまりにも単純すぎて、いくつかの重要なポイントを理解することができません。
Facebookのような、ユーザープロファイルページを持つシングルページアプリを想像してください。各ユーザープロファイルページでは、ユーザ情報と直近の投稿を、無限スクロールで表示したいと思います。あるユーザープロファイルから別のユーザープロファイルに移動することができます。
Fluxのアーキテクチャでは、これはStoreとDispatcherにどう対応するのでしょうか?
ユーザーごとに1つの PostStore
を使うのか、それともある種のグローバルなストアを持つのか? ディスパッチャについては、「ユーザーページ」ごとに新しいディスパッチャを作成するのか、それともシングルトンを使用するのか?最後に、ルート変更に対応する「ページ固有の」ストアのライフサイクルを管理するのは、アーキテクチャのどの部分でしょうか?
さらに、1つの擬似ページが、同じ型のデータのリストを複数持つことがあります。例えば、プロフィールページで、FollowersとFollowsの両方を表示したいです。この場合、シングルトンの UserStore
はどのように機能するのでしょうか?UserPageStoreは
followedBy.UserStoreを管理するのでしょうか?UserStore
と follows:UserStore
は管理できますか?
Fluxアプリでは、Dispatcherは1つだけであるべきです。 すべてのデータはこの中心的なハブを経由して流れます。 シングルトンのDispatcherを持つことで、すべてのStoreを管理することができます。 これは、Store #1が自身を更新し、Store #2がActionとStore #1の状態の両方を基に自身を更新する必要がある場合に重要になります。 Fluxは、このような状況が大規模なアプリケーションで起こりうることを想定しています。 理想を言えば、このような状況は発生する必要がなく、開発者は可能であればこのような複雑な状況を避けるように努力すべきです。 しかし、シングルトンDispatcherは、いざというときに対処できるようになっています。
ストアも同様にシングルトンです。 ストアは可能な限り独立かつ非結合のままであるべきで、Controller-Viewから問い合わせることができる自己完結した宇宙です。 ストアに入るには、ディスパッチャに登録されたコールバックを経由する必要があります。 Storeに入るには、Dispatcherに登録したコールバックを経由し、出るにはゲッター関数を経由します。 Storeは状態が変化したときにイベントも発行するので、Controller-Viewはゲッターを使って新しい状態を問い合わせるタイミングを知ることができます。
この例では、1つの PostStore
が存在することになります。 この同じストアで、FB のニュースフィードのようなページ(疑似ページ)の投稿を管理できます。 論理的なドメインは投稿のリストであり、どんな投稿のリストも扱うことができます。 擬似ページから擬似ページに移行する際には、新しい状態を反映させるために、ストアの状態を再初期化したいと思います。 擬似ページ間を行き来するための最適化として、以前の状態をlocalStorageにキャッシュすることもできますが、私の考えでは、他のすべてのストアを待ち、擬似ページ上のすべてのストアについてlocalStorageとの関係を管理し、それから自身の状態を更新する PageStore
をセットアップすることをお勧めします。 この PageStore
は投稿については何も保存しないことに注意してください -- それは PostStore
の領域です。 これは単に、特定の擬似ページがキャッシュされているかどうかを知っているだけです。なぜなら、擬似ページはそのドメインだからです。
PostStoreは
initialize()メソッドを持っています。 このメソッドは、たとえ最初の初期化であっても、常に古い状態をクリアし、Actionを通して受け取ったデータに基づいて、Dispatcherを介して状態を作成することになる。 ある疑似ページから別の疑似ページへの移動には、おそらく
PAGE_UPDATEアクションが必要で、これが
initialize()` の起動のトリガーとなるでしょう。 ローカルキャッシュからのデータ取得、サーバーからのデータ取得、楽観的なレンダリング、XHRエラーの状態などの詳細な作業が必要ですが、これが一般的なアイデアです。
もし、特定の擬似ページがアプリケーション内のすべてのストアを必要としないのであれば、メモリの制約以外に、未使用のストアを破棄する理由があるとは全く思えません。 しかし、ストアは通常、大量のメモリを消費するわけではありません。 ただ、破棄するController-Viewsのイベントリスナーを確実に削除する必要があります。 これはReactの componentWillUnmount()
メソッドで行われます。
(注:JSX Harmonyオプションを使用して、ES6構文を使用しています)。
練習として、Github users
とreposをブラウズできる sample Flux app を書きました。
これはfisherwebdev's answerに基づいていますが、私がAPIのレスポンスを正規化するために使っているアプローチも反映しています。
Fluxの学習中に私が試したいくつかのアプローチを記録するために作りました。
現実の世界に近づけようとしました(ページネーション、偽のlocalStorage APIを使わない)。
この中で、特に興味を持った部分がいくつかあります。
switch
です;他のFluxの例で見た、特にStoresの重複を避けようとしました。 特にStoresについては、他のFluxの例で見られたような重複を避けるようにしました。Storesを論理的に3つのカテゴリーに分けることが有効であると考えました。
コンテンツストアは、すべてのアプリのエンティティを格納します。コンテンツストア**は、すべてのアプリのエンティティを保持します。IDを持つすべてのものは、それ自身のコンテンツストアが必要です。個々のアイテムをレンダリングするコンポーネントは、新鮮なデータをコンテンツ ストアに要求します。
コンテンツ ストアは すべての サーバー アクションからオブジェクトを取得します。例えば、UserStore
は action.response.entities.users
が存在すれば、どのアクションが実行されたかに関係なく、そのオブジェクトを取得することができます。スイッチ`は必要ありません。Normalizr を使えば、どんなAPIレスポンスも簡単にこの形式にすることができます。
// Content Stores keep their data like this
{
7: {
id: 7,
name: 'Dan'
},
...
}
リストストア は、グローバルなリスト (例: "feed", "your notifications") に出現するエンティティの ID を記録します。このプロジェクトでは、そのようなストアはありませんが、とりあえず触れておこうと思います。これらはページネーションを扱います。
通常、いくつかのアクション(例: REQUEST_FEED
, REQUEST_FEED_SUCCESS
, REQUEST_FEED_ERROR
)に反応するだけです。
// Paginated Stores keep their data like this
[7, 10, 5, ...]
インデックス付きリストストア はリストストアに似ていますが、1対多のリレーションシップを定義します。例えば、"user's subscribers", "repository's stargazers", "user's repositories "のような関係です。また、ページネーションも扱えます。
また、通常はいくつかのアクションに反応します (例: REQUEST_USER_REPOS
、REQUEST_USER_REPOS_SUCCESS
、REQUEST_USER_REPOS_ERROR
など)。
ほとんどのソーシャルアプリでは、このようなものがたくさんあり、それらのうちの1つを素早く作成できるようにしたいと思うでしょう。
// Indexed Paginated Stores keep their data like this
{
2: [7, 10, 5, ...],
6: [7, 1, 2, ...],
...
}
注:これらは実際のクラスなどではなく、私がStoreについて考えたい方法です。 でも、いくつかヘルパーを作りました。
StoreUtils
createStore
(ストア作成)このメソッドは、最も基本的なStoreを提供します。
createStore(spec) {
var store = merge(EventEmitter.prototype, merge(spec, {
emitChange() {
this.emit(CHANGE_EVENT);
},
addChangeListener(callback) {
this.on(CHANGE_EVENT, callback);
},
removeChangeListener(callback) {
this.removeListener(CHANGE_EVENT, callback);
}
}));
_.each(store, function (val, key) {
if (_.isFunction(val)) {
store[key] = store[key].bind(store);
}
});
store.setMaxListeners(0);
return store;
}
私はすべてのStoreを作成するためにこの方法を使用しています。
isInBag
, mergeIntoBag
.コンテンツストアに便利な小さなヘルパーです。
isInBag(bag, id, fields) {
var item = bag[id];
if (!bag[id]) {
return false;
}
if (fields) {
return fields.every(field => item.hasOwnProperty(field));
} else {
return true;
}
},
mergeIntoBag(bag, entities, transform) {
if (!transform) {
transform = (x) => x;
}
for (var key in entities) {
if (!entities.hasOwnProperty(key)) {
continue;
}
if (!bag.hasOwnProperty(key)) {
bag[key] = transform(entities[key]);
} else if (!shallowEqual(bag[key], entities[key])) {
bag[key] = transform(merge(bag[key], entities[key]));
}
}
}
PaginatedList
ページ送りの状態を保存し、特定のアサーション(can't fetch during fetch page, etc)を強制する。
class PaginatedList {
constructor(ids) {
this._ids = ids || [];
this._pageCount = 0;
this._nextPageUrl = null;
this._isExpectingPage = false;
}
getIds() {
return this._ids;
}
getPageCount() {
return this._pageCount;
}
isExpectingPage() {
return this._isExpectingPage;
}
getNextPageUrl() {
return this._nextPageUrl;
}
isLastPage() {
return this.getNextPageUrl() === null && this.getPageCount() > 0;
}
prepend(id) {
this._ids = _.union([id], this._ids);
}
remove(id) {
this._ids = _.without(this._ids, id);
}
expectPage() {
invariant(!this._isExpectingPage, 'Cannot call expectPage twice without prior cancelPage or receivePage call.');
this._isExpectingPage = true;
}
cancelPage() {
invariant(this._isExpectingPage, 'Cannot call cancelPage without prior expectPage call.');
this._isExpectingPage = false;
}
receivePage(newIds, nextPageUrl) {
invariant(this._isExpectingPage, 'Cannot call receivePage without prior expectPage call.');
if (newIds.length) {
this._ids = _.union(this._ids, newIds);
}
this._isExpectingPage = false;
this._nextPageUrl = nextPageUrl || null;
this._pageCount++;
}
}
PaginatedStoreUtils
createListStore
, createIndexedListStore
, createListActionHandler
を作成する。定型的なメソッドとアクション処理を提供することで、 Indexed List Store の作成を可能な限りシンプルにします。
var PROXIED_PAGINATED_LIST_METHODS = [
'getIds', 'getPageCount', 'getNextPageUrl',
'isExpectingPage', 'isLastPage'
];
function createListStoreSpec({ getList, callListMethod }) {
var spec = {
getList: getList
};
PROXIED_PAGINATED_LIST_METHODS.forEach(method => {
spec[method] = function (...args) {
return callListMethod(method, args);
};
});
return spec;
}
/**
* Creates a simple paginated store that represents a global list (e.g. feed).
*/
function createListStore(spec) {
var list = new PaginatedList();
function getList() {
return list;
}
function callListMethod(method, args) {
return list[method].call(list, args);
}
return createStore(
merge(spec, createListStoreSpec({
getList: getList,
callListMethod: callListMethod
}))
);
}
/**
* Creates an indexed paginated store that represents a one-many relationship
* (e.g. user's posts). Expects foreign key ID to be passed as first parameter
* to store methods.
*/
function createIndexedListStore(spec) {
var lists = {};
function getList(id) {
if (!lists[id]) {
lists[id] = new PaginatedList();
}
return lists[id];
}
function callListMethod(method, args) {
var id = args.shift();
if (typeof id === 'undefined') {
throw new Error('Indexed pagination store methods expect ID as first parameter.');
}
var list = getList(id);
return list[method].call(list, args);
}
return createStore(
merge(spec, createListStoreSpec({
getList: getList,
callListMethod: callListMethod
}))
);
}
/**
* Creates a handler that responds to list store pagination actions.
*/
function createListActionHandler(actions) {
var {
request: requestAction,
error: errorAction,
success: successAction,
preload: preloadAction
} = actions;
invariant(requestAction, 'Pass a valid request action.');
invariant(errorAction, 'Pass a valid error action.');
invariant(successAction, 'Pass a valid success action.');
return function (action, list, emitChange) {
switch (action.type) {
case requestAction:
list.expectPage();
emitChange();
break;
case errorAction:
list.cancelPage();
emitChange();
break;
case successAction:
list.receivePage(
action.response.result,
action.response.nextPageUrl
);
emitChange();
break;
}
};
}
var PaginatedStoreUtils = {
createListStore: createListStore,
createIndexedListStore: createIndexedListStore,
createListActionHandler: createListActionHandler
};
createStoreMixin
コンポーネントが興味のあるStoreにチューニングするためのMixinです。例: mixins: [createStoreMixin(UserStore)]
.
function createStoreMixin(...stores) {
var StoreMixin = {
getInitialState() {
return this.getStateFromStores(this.props);
},
componentDidMount() {
stores.forEach(store =>
store.addChangeListener(this.handleStoresChanged)
);
this.setState(this.getStateFromStores(this.props));
},
componentWillUnmount() {
stores.forEach(store =>
store.removeChangeListener(this.handleStoresChanged)
);
},
handleStoresChanged() {
if (this.isMounted()) {
this.setState(this.getStateFromStores(this.props));
}
}
};
return StoreMixin;
}
そこでRefluxでは、Dispatcherの概念を取り除き、アクションとストアによるデータフローだけで考えるようにしました。すなわち
Actions <-- Store { <-- Another Store } <-- Components
ここでの各矢印は、データの流れをどのように聞いているのかをモデル化しており、逆にデータが流れることを意味しています。実際のデータの流れはこのような図になります。
Actions --> Stores --> Components
^ | |
+----------+------------+
あなたのユースケースでは、私が正しく理解していれば、ユーザープロファイルの読み込みとページの切り替えを開始する openUserProfile
アクションと、ユーザープロファイルのページを開いたときと無限スクロールイベントの間に投稿を読み込むいくつかの投稿読み込みアクションが必要です。そこで、アプリケーションに以下のデータストアがあることを想像してみました。
Refluxでは、このように設定します。
// Set up the two actions we need for this use case.
var Actions = Reflux.createActions(['openUserProfile', 'loadUserProfile', 'loadInitialPosts', 'loadMorePosts']);
var currentPageStore = Reflux.createStore({
init: function() {
this.listenTo(openUserProfile, this.openUserProfileCallback);
},
// We are assuming that the action is invoked with a profileid
openUserProfileCallback: function(userProfileId) {
// Trigger to the page handling component to open the user profile
this.trigger('user profile');
// Invoke the following action with the loaded the user profile
Actions.loadUserProfile(userProfileId);
}
});
var currentUserProfileStore = Reflux.createStore({
init: function() {
this.listenTo(Actions.loadUserProfile, this.switchToUser);
},
switchToUser: function(userProfileId) {
// Do some ajaxy stuff then with the loaded user profile
// trigger the stores internal change event with it
this.trigger(userProfile);
}
});
var currentPostsStore = Reflux.createStore({
init: function() {
// for initial posts loading by listening to when the
// user profile store changes
this.listenTo(currentUserProfileStore, this.loadInitialPostsFor);
// for infinite posts loading
this.listenTo(Actions.loadMorePosts, this.loadMorePosts);
},
loadInitialPostsFor: function(userProfile) {
this.currentUserProfile = userProfile;
// Do some ajax stuff here to fetch the initial posts then send
// them through the change event
this.trigger(postData, 'initial');
},
loadMorePosts: function() {
// Do some ajaxy stuff to fetch more posts then send them through
// the change event
this.trigger(postData, 'more');
}
});
ページビュー全体、ユーザープロファイルページ、投稿一覧のコンポーネントを用意することを想定しています。以下は配線が必要です。
Action.openUserProfile
を呼び出す必要があります。currentPageStore
をリッスンして、どのページに切り替わるかを知っておく必要があります。currentUserProfileStore
をリスンする必要があり、どのユーザープロファイルのデータを表示すればよいかがわかります。currentPostsStore
をリスンする必要があります。Action.loadMorePosts
を呼び出す必要があります。そして、これでほぼ完了です。