Fluxアーキテクチャでは、ストアのライフサイクルをどのように管理しますか?


132

Fluxについて読んでいますが、Todoアプリは単純すぎて、いくつかの重要なポイントを理解できません。

ユーザープロフィールページを備えたFacebookのような単一ページのアプリを想像してみてください。各ユーザープロファイルページで、無限のスクロールでユーザー情報とその最後の投稿を表示します。ユーザープロファイル間を移動できます。

Fluxアーキテクチャーでは、これはどのようにストアとディスパッチャーに対応しますか?

PostStoreユーザーごとに1つ使用するのでしょうか、それとも何らかのグローバルストアを使用するのでしょうか。ディスパッチャーについてはどうですか?「ユーザーページ」ごとに新しいDispatcherを作成しますか、それともシングルトンを使用しますか?最後に、ルートの変更に応じて「ページ固有」ストアのライフサイクルを管理するのは、アーキテクチャのどの部分ですか。

さらに、単一の疑似ページには、同じタイプのデータの複数のリストがある場合があります。たとえば、プロフィールページで、フォロワーフォローの両方を表示したいとします。UserStoreこの場合、シングルトンはどのように機能しますか?だろUserPageStore管理followedBy: UserStorefollows: UserStore

回答:


124

Fluxアプリでは、ディスパッチャは1つだけです。すべてのデータはこの中央ハブを通過します。シングルトンディスパッチャーがあると、すべてのストアを管理できます。これは、ストア#1自体を更新する必要があり、アクションとストア#1の状態の両方に基づいてストア#2を更新する必要がある場合に重要になります。Fluxは、この状況が大規模なアプリケーションでは偶然であると想定しています。理想的には、この状況が発生する必要はなく、可能であれば、開発者はこの複雑さを回避するよう努力する必要があります。しかし、シングルトンディスパッチャーは、時が来ればそれを処理する準備ができています。

ストアもシングルトンです。それらは、可能な限り独立して分離する必要があります-Controller-Viewからクエリできる自己完結型のユニバース。ストアへの唯一の道は、ディスパッチャーに登録するコールバックを経由することです。唯一の道はゲッター関数を経由することです。ストアはまた、状態が変化したときにイベントを発行するため、コントローラービューは、ゲッターを使用して、新しい状態をいつクエリするかを知ることができます。

あなたのサンプルアプリでは、1つになりPostStoreます。この同じストアは、FBのニュースフィードに似た「ページ」(疑似ページ)で投稿を管理できます。ここでは、さまざまなユーザーからの投稿が表示されます。その論理ドメインは投稿のリストであり、投稿の任意のリストを処理できます。疑似ページから疑似ページに移動するときに、ストアの状態を再初期化して、新しい状態を反映させます。疑似ページ間を行き来するための最適化として、以前の状態をlocalStorageにキャッシュすることもできますが、私の傾向はPageStore、他のすべてのストアを待機し、上のすべてのストアのlocalStorageとの関係を管理するを設定することです疑似ページを更新し、独自の状態を更新します。これPageStoreは投稿について何も保存しないことに注意してください-それはのドメインですPostStore。疑似ページはそのドメインであるため、特定の疑似ページがキャッシュされているかどうかを簡単に知ることができます。

PostStore必要がありますinitialize()方法を。このメソッドは、これが最初の初期化であっても、常に古い状態をクリアし、Dispatcherを介してアクションを介して受け取ったデータに基づいて状態を作成します。ある疑似ページから別の疑似ページに移動するには、おそらくPAGE_UPDATEアクションが必要であり、そのためにが呼び出されますinitialize()。ローカルキャッシュからのデータの取得、サーバーからのデータの取得、オプティミスティックレンダリング、およびXHRエラー状態に関する問題の詳細はありますが、これは一般的な考え方です。

特定の疑似ページがアプリケーション内のすべてのストアを必要としない場合、メモリの制約以外に、未使用のものを破棄する理由があるかどうかは完全にはわかりません。ただし、ストアは通常、大量のメモリを消費しません。破壊するController-Viewsのイベントリスナーを確実に削除する必要があります。これは、ReactのcomponentWillUnmount()メソッドで行われます。


5
あなたがしたいことには確かにいくつかの異なるアプローチがあり、それはあなたが何を構築しようとしているかに依存すると私は思います。1つのアプローチはUserListStore、関連するすべてのユーザーが含まれるです。また、各ユーザーには、現在のユーザープロファイルとの関係を説明するブールフラグがいくつかあります。{ follower: true, followed: false }たとえばのようなものです。メソッドgetFolloweds()getFollowers()は、UIに必要なさまざまなユーザーセットを取得します。
fisherwebdev 2014年

4
または、抽象UserListStoreから継承するFollowedUserListStoreとFollowerUserListStoreを使用することもできます。
fisherwebdev 2014年

小さな質問があります-サブスクライバーにデータの取得を要求するのではなく、pub subを使用してストアから直接データを発行しないのはなぜですか?
sunwukung

2
@sunwukungこれにより、ストアは、どのコントローラービューがどのデータを必要とするかを追跡する必要があります。ストアに何らかの変更があったという事実をストアに公開し、関係するコントローラービューに必要なデータのどの部分を取得させるかは、よりクリーンです。
fisherwebdev 14

ユーザーに関する情報だけでなく、友達のリストも表示するプロファイルページがある場合はどうなりますか。ユーザーも友達も同じタイプです。もしそうなら、彼らは同じ店にとどまるべきですか?
Nick Dima

79

(注:JSX Harmonyオプションを使用してES6構文を使用しました。)

演習として、閲覧とリポジトリ作成を可能にするサンプルFluxアプリを作成しましたGithub users
これはfisherwebdevの回答に基づいていますが、API応答の正規化に使用するアプローチも反映しています。

Fluxの学習中に試したいくつかのアプローチを文書化するために作成しました。
私はそれを現実の世界に近づけようとしました(ページネーション、偽のlocalStorage APIはありません)。

ここに私が特に興味を持ったいくつかのビットがあります:

ストアの分類方法

他のFluxの例、特にストアで見られた重複のいくつかを回避しようとしました。ストアを論理的に3つのカテゴリに分類すると便利だと思いました。

Content Storeはすべてのアプリエンティティを保持します。IDを持つすべてのものには、独自のコンテンツストアが必要です。個々のアイテムをレンダリングするコンポーネントは、コンテンツストアに最新のデータを要求します。

Content Storeは、すべてのサーバーアクションからオブジェクトを取得します。たとえば、UserStore に見えますaction.response.entities.usersが存在する場合にかかわらず、アクションが解雇されました。の必要はありませんswitchNormalizrを使用すると、この形式に対するAPI応答を簡単にフラット化できます。

// Content Stores keep their data like this
{
  7: {
    id: 7,
    name: 'Dan'
  },
  ...
}

リストストアは、いくつかのグローバルリストに表示されるエンティティのID(「フィード」、「通知」など)を追跡します。今回のプロジェクトではそういうお店はありませんが、とにかくお店に言及したいと思いました。彼らはページネーションを扱います。

彼らは通常、(例えば、ほんの数操作に反応しREQUEST_FEEDREQUEST_FEED_SUCCESSREQUEST_FEED_ERROR)。

// Paginated Stores keep their data like this
[7, 10, 5, ...]

インデックス付きリストストアリストストアに似ていますが、1対多の関係を定義します。たとえば、「ユーザーのサブスクライバー」、「リポジトリのスターゲイザー」、「ユーザーのリポジトリ」などです。また、ページネーションも処理します。

彼らはまた、通常、(例えば、ほんの数操作に反応しREQUEST_USER_REPOSREQUEST_USER_REPOS_SUCCESSREQUEST_USER_REPOS_ERROR)。

ほとんどのソーシャルアプリでは、これらのアプリがたくさんあり、それらをすばやくもう1つ作成できるようにしたいと考えています。

// Indexed Paginated Stores keep their data like this
{
  2: [7, 10, 5, ...],
  6: [7, 1, 2, ...],
  ...
}

注:これらは実際のクラスなどではありません。それは私がストアについて考えるのが好きな方法です。私はいくつかのヘルパーを作りました。

StoreUtils

createStore

このメソッドは、最も基本的なストアを提供します。

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;
}

すべてのストアを作成するために使用します。

isInBagmergeIntoBag

Content Storeに役立つ小さなヘルパー。

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

ページネーションの状態を保存し、特定のアサーションを適用します(フェッチ中にページをフェッチすることはできません)。

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

createListStorecreateIndexedListStorecreateListActionHandler

ボイラープレートメソッドとアクション処理を提供することで、インデックス付きリストストアの作成を可能な限り簡単にします。

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

コンポーネントが関心のあるストアにチューニングできるようにするミックスイン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;
}

1
Stampsyを作成したという事実を踏まえて、クライアント側のアプリケーション全体を書き換える場合、FLUXを使用し、このサンプルアプリのビルドに使用したのと同じアプローチを使用しますか?
eAbi

2
eAbi:これは、FluxでStampsyを書き換えるときに使用しているアプローチです(来月リリースする予定です)。理想的ではありませんが、私たちにとってはうまくいきます。それを行うためのより良い方法を見つけたとき/私たちはそれらを共有します。
ダンアブラモフ2014

1
eAbi:ただし、正規化された応答を返すために、チームの1人がすべてのAPIを書き直したので、もはやnormalizrを使用しません。それが行われる前にそれは役に立ちました。
ダンアブラモフ2014

情報ありがとうございます。私はあなたのgithubリポジトリを確認し、あなたのアプローチでプロジェクト(YUI3に組み込まれています)を開始しようとしていますが、コードのコンパイルに問題があります(可能であれば)。ノードの下でサーバーを実行していないので、静的ディレクトリにソースをコピーしたかったのですが、それでもいくつかの作業を行う必要があります...少し面倒であり、また、JS構文が異なるファイルがいくつか見つかりました。特にjsxファイルでは。
eAbi

2
@ショーン:私はそれを問題としてはまったく見ていません。データフローは、それを読んでいない、データの書き込みについてです。確かにアクションが店舗にとらわれないのが最善ですが、リクエストを最適化するために、店舗から読み取るのはまったく問題ないと思います。結局のところ、コンポーネントはストアから読み取り、それらのアクションを起動します。あなたはすべてのコンポーネントでこのロジックを繰り返してできましたが、どのようなアクションクリエイターその者は..です
ダン・アブラモフ

27

したがって、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クリックイベント中に正しいIDでを呼び出す必要があります。
  • ページコンポーネントはをリッスンする必要があるcurrentPageStoreため、どのページに切り替えるかがわかります。
  • ユーザープロファイルページコンポーネントはをリッスンする必要があるcurrentUserProfileStoreため、表示するユーザープロファイルデータを認識します。
  • currentPostsStoreロードされた投稿を受信するには、投稿リストがをリッスンする必要があります
  • 無限スクロールイベントはを呼び出す必要がありますAction.loadMorePosts

そして、それはほとんどそれのはずです。


書き込みありがとうございます!
ダンアブラモフ2014

2
パーティーには少し遅れるかもしれませんが、ストアから直接APIを呼び出さないようにする理由を説明した素晴らしい記事を紹介します。私はまだベストプラクティスが何であるかを理解していますが、これが他のつまずきに役立つかもしれないと思いました。店舗に関しては、さまざまなアプローチが浮かんでいます。
Thijs Koerselman、2015
弊社のサイトを使用することにより、あなたは弊社のクッキーポリシーおよびプライバシーポリシーを読み、理解したものとみなされます。
Licensed under cc by-sa 3.0 with attribution required.