web-dev-qa-db-ja.com

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

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

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

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

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

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

132
Dan Abramov

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

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

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

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

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

124
fisherwebdev

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

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

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

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

ストアの分類方法

他のFluxの例、特にStoresで見た重複の一部を避けようとしました。ストアを論理的に3つのカテゴリに分けると便利です。

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

Content Storeは、allサーバーアクションからオブジェクトを収集します。たとえば、UserStoreaction.response.entities.usersを調べます 存在する場合関係なく、どのアクションが起動されました。 switchの必要はありません。 Normalizr を使用すると、この形式に対するAPI応答を簡単にフラット化できます。

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

List Stores何らかのグローバルリストに表示されるエンティティの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;
}
79
Dan Abramov

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

コンポーネント

ページビュー全体、ユーザープロファイルページ、投稿リスト用のコンポーネントがあると仮定しています。以下を配線する必要があります。

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

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

27
Spoike