Dans l'architecture Flux, comment gérez-vous le cycle de vie du Store?

132

Je lis sur Flux mais le exemple d'application Todo est trop simpliste pour que je puisse comprendre certains points clés.

Imaginez une application d'une seule page comme Facebook avec des pages de profil utilisateur . Sur chaque page de profil utilisateur, nous voulons afficher certaines informations utilisateur et leurs derniers messages, avec un défilement infini. Nous pouvons naviguer d'un profil utilisateur à un autre.

Dans l'architecture Flux, comment cela correspondrait-il aux Stores et Dispatchers?

En utiliserions-nous un PostStorepar utilisateur ou aurions-nous une sorte de magasin mondial? Qu'en est-il des répartiteurs, créerions-nous un nouveau répartiteur pour chaque «page utilisateur» ou utiliserions-nous un singleton? Enfin, quelle partie de l'architecture est responsable de la gestion du cycle de vie des magasins «spécifiques à la page» en réponse au changement d'itinéraire?

De plus, une même pseudo-page peut avoir plusieurs listes de données du même type. Par exemple, sur une page de profil, je souhaite afficher à la fois les abonnés et les suivis . Comment un singleton peut-il UserStorefonctionner dans ce cas? Serait UserPageStoregérer followedBy: UserStoreet follows: UserStore?

Dan Abramov
la source

Réponses:

124

Dans une application Flux, il ne devrait y avoir qu'un seul Dispatcher. Toutes les données transitent par ce hub central. Avoir un répartiteur singleton lui permet de gérer tous les magasins. Cela devient important lorsque vous avez besoin de la mise à jour du magasin n ° 1 proprement dit, puis que le magasin n ° 2 se met à jour lui-même en fonction de l'action et de l'état du magasin n ° 1. Flux suppose que cette situation est une éventualité dans une grande application. Idéalement, cette situation n'aurait pas besoin de se produire et les développeurs devraient s'efforcer d'éviter cette complexité, si possible. Mais le répartiteur singleton est prêt à le gérer le moment venu.

Les magasins sont également des singletons. Ils doivent rester aussi indépendants et découplés que possible - un univers autonome que l'on peut interroger à partir d'une Controller-View. La seule route vers le magasin passe par le rappel qu'il enregistre auprès du répartiteur. La seule issue est les fonctions getter. Les magasins publient également un événement lorsque leur état a changé, de sorte que Controller-Views puisse savoir quand demander le nouvel état, à l'aide des getters.

Dans votre exemple d'application, il y en aurait un seul PostStore. Ce même magasin pourrait gérer les messages sur une "page" (pseudo-page) qui ressemble plus au fil d'actualité de FB, où les messages apparaissent de différents utilisateurs. Son domaine logique est la liste des messages et il peut gérer n'importe quelle liste de messages. Lorsque nous passons d'une pseudo-page à une pseudo-page, nous souhaitons réinitialiser l'état du magasin pour refléter le nouvel état. Nous pourrions également vouloir mettre en cache l'état précédent dans localStorage pour optimiser les allers-retours entre les pseudo-pages, mais mon inclination serait de configurer un PageStorequi attend tous les autres magasins, gère la relation avec localStorage pour tous les magasins sur la pseudo-page, puis met à jour son propre état. Notez que ceciPageStore ne stockera rien sur les messages - c'est le domaine duPostStore. Il saurait simplement si une pseudo-page particulière a été mise en cache ou non, car les pseudo-pages sont son domaine.

Le PostStoreaurait une initialize()méthode. Cette méthode effacerait toujours l'ancien état, même s'il s'agit de la première initialisation, puis créerait l'état en fonction des données reçues via l'action, via le Dispatcher. Passer d'une pseudo-page à une autre impliquerait probablement une PAGE_UPDATEaction, qui déclencherait l'invocation de initialize(). Il y a des détails à résoudre autour de la récupération des données du cache local, de la récupération des données du serveur, du rendu optimiste et des états d'erreur XHR, mais c'est l'idée générale.

Si une pseudo-page particulière n'a pas besoin de tous les Stores de l'application, je ne suis pas tout à fait sûr qu'il y ait une raison de détruire les non utilisées, autre que des contraintes de mémoire. Mais les magasins ne consomment généralement pas beaucoup de mémoire. Vous devez simplement vous assurer de supprimer les écouteurs d'événements dans les vues de contrôleur que vous êtes en train de détruire. Ceci est fait dans la componentWillUnmount()méthode de React .

fisherwebdev
la source
5
Il existe certainement plusieurs approches différentes de ce que vous voulez faire, et je pense que cela dépend de ce que vous essayez de construire. Une approche serait a UserListStore, avec tous les utilisateurs pertinents. Et chaque utilisateur aurait quelques indicateurs booléens décrivant la relation avec le profil utilisateur actuel. Quelque chose comme { follower: true, followed: false }, par exemple. Les méthodes getFolloweds()et getFollowers()récupéreraient les différents ensembles d'utilisateurs dont vous avez besoin pour l'interface utilisateur.
fisherwebdev
4
Vous pouvez également avoir un FollowedUserListStore et un FollowerUserListStore qui héritent tous deux d'un UserListStore abstrait.
fisherwebdev
J'ai une petite question - pourquoi ne pas utiliser pub sub pour émettre des données directement depuis les magasins plutôt que d'exiger des abonnés qu'ils récupèrent les données?
sunwukung
2
@sunwukung Cela obligerait les magasins à garder une trace de quelles vues du contrôleur ont besoin de quelles données. Il est plus propre que les magasins publient le fait qu'ils ont changé d'une manière ou d'une autre, puis permettent aux vues de contrôleur intéressées de récupérer les parties des données dont elles ont besoin.
fisherwebdev
Que faire si j'ai une page de profil où je montre des informations sur un utilisateur mais aussi une liste de ses amis. L'utilisateur et les amis seraient du même type. Devraient-ils rester dans le même magasin si oui?
Nick Dima
79

(Remarque: j'ai utilisé la syntaxe ES6 en utilisant l'option JSX Harmony.)

En guise d'exercice, j'ai écrit un exemple d'application Flux qui permet de parcourir Github userset de déposer.
Il est basé sur la réponse de fisherwebdev mais reflète également une approche que j'utilise pour normaliser les réponses API.

Je l'ai fait pour documenter quelques approches que j'ai essayées en apprenant Flux.
J'ai essayé de le garder proche du monde réel (pagination, pas de fausses API localesStorage).

Il y a quelques éléments qui m'intéressaient particulièrement:

Comment je classe les magasins

J'ai essayé d'éviter une partie de la duplication que j'ai vue dans d'autres exemples de Flux, en particulier dans les magasins. J'ai trouvé utile de diviser logiquement les magasins en trois catégories:

Les magasins de contenu contiennent toutes les entités d'application. Tout ce qui a un identifiant a besoin de son propre Content Store. Les composants qui rendent des éléments individuels demandent aux Content Stores les nouvelles données.

Les magasins de contenu récupèrent leurs objets de toutes les actions du serveur. Par exemple, UserStore regardeaction.response.entities.users s'il existe quelle que soit l'action déclenchée. Il n'y a pas besoin d'un switch. Normalizr facilite l'aplatissement des réponses d'API à ce format.

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

Les magasins de listes gardent une trace des identifiants des entités qui apparaissent dans une liste globale (par exemple «flux», «vos notifications»). Dans ce projet, je n'ai pas de tels magasins, mais je pensais les mentionner quand même. Ils gèrent la pagination.

Ils répondent normalement à quelques actions (par exemple REQUEST_FEED, REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

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

Les magasins de listes indexées sont comme les magasins de listes, mais ils définissent une relation un-à-plusieurs. Par exemple, «abonnés de l'utilisateur», «astronomes du référentiel», «référentiels de l'utilisateur». Ils gèrent également la pagination.

Ils répondent aussi normalement que quelques actions (par exemple REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

Dans la plupart des applications sociales, vous en aurez beaucoup et vous voulez pouvoir en créer rapidement une de plus.

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

Remarque: ce ne sont pas des classes réelles ou quelque chose du genre; c'est comme ça que j'aime penser aux magasins. J'ai cependant fait quelques aides.

StoreUtils

createStore

Cette méthode vous donne le magasin le plus basique:

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

Je l'utilise pour créer tous les magasins.

isInBag, mergeIntoBag

Petits assistants utiles pour les magasins de contenu.

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

Stocke l'état de pagination et applique certaines assertions (impossible de récupérer la page lors de la récupération, 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

Rend la création de magasins de listes indexées aussi simple que possible en fournissant des méthodes standard et la gestion des actions:

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

Un mixin qui permet aux composants de se connecter aux magasins qui les intéressent, par exemple 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;
}
Dan Abramov
la source
1
Étant donné que vous avez écrit Stampsy, si vous réécriviez l'ensemble de l'application côté client, utiliseriez-vous FLUX et la même approche que vous avez utilisée pour créer cet exemple d'application?
eAbi
2
eAbi: C'est l'approche que nous utilisons actuellement car nous réécrivons Stampsy dans Flux (dans l'espoir de le publier le mois prochain). Ce n'est pas idéal mais cela fonctionne bien pour nous. Quand / si nous trouvons de meilleures façons de faire ces choses, nous les partagerons.
Dan Abramov
1
eAbi: Cependant, nous n'utilisons plus normalizr car un gars de notre équipe a réécrit toutes nos API pour renvoyer des réponses normalisées. C'était utile avant que cela ne soit fait.
Dan Abramov
Merci pour ton information. J'ai vérifié votre dépôt github et j'essaie de commencer un projet (construit dans YUI3) avec votre approche, mais j'ai des problèmes pour compiler le code (si vous pouvez le dire). Je n'exécute pas le serveur sous le nœud donc je voulais copier la source dans mon répertoire statique mais je dois encore faire du travail ... C'est un peu encombrant, et aussi, j'ai trouvé des fichiers ayant une syntaxe JS différente. Surtout dans les fichiers jsx.
eAbi
2
@Sean: Je ne vois pas du tout cela comme un problème. Le flux de données consiste à écrire des données, pas à les lire. Bien sûr, il est préférable que les actions soient indépendantes des magasins, mais pour optimiser les demandes, je pense que c'est parfaitement bien de lire dans les magasins. Après tout, les composants lisent dans les magasins et déclenchent ces actions. Vous pouvez répéter cette logique dans chaque composant, mais c'est à cela que sert le créateur d'action.
Dan Abramov
27

Ainsi, dans Reflux, le concept de Dispatcher est supprimé et vous n'avez qu'à penser en termes de flux de données à travers les actions et les magasins. C'est à dire

Actions <-- Store { <-- Another Store } <-- Components

Chaque flèche ici modélise la façon dont le flux de données est écouté, ce qui signifie à son tour que les données circulent dans la direction opposée. Le chiffre réel du flux de données est le suivant:

Actions --> Stores --> Components
   ^          |            |
   +----------+------------+

Dans votre cas d'utilisation, si j'ai bien compris, nous avons besoin d'une openUserProfileaction qui initie le chargement et le changement de page du profil utilisateur, ainsi que des actions de chargement de publications qui chargeront les publications lorsque la page de profil utilisateur est ouverte et pendant l'événement de défilement infini. J'imagine donc que nous avons les magasins de données suivants dans l'application:

  • Un magasin de données de page qui gère le changement de page
  • Un magasin de données de profil utilisateur qui charge le profil utilisateur lorsque la page est ouverte
  • Un magasin de données de liste de publications qui charge et gère les publications visibles

Dans Reflux, vous le configurez comme ceci:

Les actions

// Set up the two actions we need for this use case.
var Actions = Reflux.createActions(['openUserProfile', 'loadUserProfile', 'loadInitialPosts', 'loadMorePosts']);

Le magasin de pages

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

Le magasin de profils utilisateur

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

Le magasin d'articles

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

Les composants

Je suppose que vous avez un composant pour la vue de la page entière, la page de profil utilisateur et la liste des publications. Les éléments suivants doivent être câblés:

  • Les boutons qui ouvrent le profil utilisateur doivent appeler le Action.openUserProfileavec l'ID correct pendant son événement de clic.
  • Le composant de page doit écouter le currentPageStoreafin de savoir à quelle page basculer.
  • Le composant de page de profil utilisateur doit écouter le currentUserProfileStoreafin de savoir quelles données de profil utilisateur afficher
  • La liste des messages doit écouter le currentPostsStorepour recevoir les messages chargés
  • L'événement de défilement infini doit appeler le Action.loadMorePosts.

Et cela devrait être à peu près tout.

Spoike
la source
Merci pour le compte rendu!
Dan Abramov
2
Un peu tard à la fête peut-être, mais voici un bel article expliquant pourquoi éviter de vous appeler API directement depuis les magasins . Je suis toujours en train de déterminer quelles sont les meilleures pratiques, mais j'ai pensé que cela pourrait aider d'autres personnes à trébucher sur ce sujet. Il existe de nombreuses approches différentes en ce qui concerne les magasins.
Thijs Koerselman