132 votes

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

Je suis en train de lire sur Flux mais le Exemple d'application Todo est trop simpliste pour que je comprenne certains points clés.

Imaginez une application à page unique comme Facebook qui a pages de profil des utilisateurs . Sur chaque page de profil d'utilisateur, nous voulons afficher des informations sur l'utilisateur et ses derniers messages, avec un défilement infini. Nous pouvons naviguer d'un profil d'utilisateur à un autre.

Dans l'architecture Flux, comment cela correspondrait-il aux magasins et aux répartiteurs ?

Est-ce que nous utiliserions un PostStore par utilisateur, ou aurions-nous une sorte de magasin global ? Qu'en est-il des répartiteurs, devons-nous créer un nouveau répartiteur pour chaque "page utilisateur", ou devons-nous utiliser un singleton ? Enfin, quelle partie de l'architecture est responsable de la gestion du cycle de vie des magasins "spécifiques à une page" en réponse à un changement d'itinéraire ?

En outre, une même pseudo-page peut comporter plusieurs listes de données du même type. Par exemple, sur une page de profil, je veux afficher à la fois Suiveurs y Suit . Comment un singleton UserStore fonctionne dans ce cas ? Est-ce que UserPageStore gérer followedBy: UserStore y follows: UserStore ?

124voto

fisherwebdev Points 5636

Dans une application Flux, il ne devrait y avoir qu'un seul répartiteur. Toutes les données passent par ce concentrateur central. Avoir un Dispatcher singleton lui permet de gérer tous les magasins. Cela devient important lorsque vous avez besoin que le magasin #1 se mette à jour, puis que le magasin #2 se mette à jour en fonction de l'action et de l'état du magasin #1. Flux suppose que cette situation est une éventualité dans une grande application. Idéalement, cette situation ne devrait pas se produire, et les développeurs devraient s'efforcer d'éviter cette complexité, si possible. Mais le Dispatcher singleton est prêt à la 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'un contrôleur-vue. La seule voie d'accès au magasin est le rappel qu'il enregistre auprès du répartiteur. La seule voie de sortie est celle des fonctions getter. Les magasins publient également un événement lorsque leur état a changé, de sorte que les vues-contrôleurs peuvent savoir quand demander le nouvel état, en utilisant les récupérateurs.

Dans votre application d'exemple, il y aurait une seule et unique PostStore . Ce même magasin pourrait gérer les messages d'une "page" (pseudo-page) qui ressemble davantage au fil d'actualité de FB, où apparaissent les messages 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 autre, nous voulons 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 afin d'optimiser les allers-retours entre les pseudo-pages. PageStore qui attend tous les autres magasins, gère la relation avec localStorage pour tous les magasins de la pseudo-page, puis met à jour son propre état. Notez que cette PageStore ne stockerait rien sur les postes -- c'est le domaine de la PostStore . Il saurait simplement si une pseudo-page particulière a été mise en cache ou non, car les pseudo-pages sont son domaine.

El PostStore aurait un 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 par l'action, via le distributeur. Le passage d'une pseudo-page à une autre impliquerait probablement une méthode PAGE_UPDATE qui déclencherait l'invocation de l'action initialize() . Il reste des détails à régler concernant la récupération des données dans le cache local, la récupération des données sur le serveur, le rendu optimiste et les é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 magasins de l'application, je ne suis pas tout à fait sûr qu'il y ait une raison de détruire les magasins inutilisés, en dehors 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 Controller-Views que vous détruisez. Ceci est fait dans la méthode React componentWillUnmount() méthode.

79voto

Dan Points 16670

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

À titre d'exercice, j'ai écrit un exemple d'application Flux qui permet de parcourir les utilisateurs et les dépôts de Github.
Il est basé sur La réponse de fisherwebdev mais reflète également une approche que j'utilise pour normaliser les réponses aux 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 fausse API localStorage).

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

Comment je classe les magasins

J'ai essayé d'éviter certaines des duplications que j'ai vues dans d'autres exemples de Flux, notamment dans les magasins. J'ai trouvé utile de diviser logiquement les magasins en trois catégories :

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

Les magasins de contenu récoltent leurs objets à partir de todo actions du serveur. Par exemple, UserStore regarde dans action.response.entities.users s'il existe indépendamment de dont l'action a été tirée. Il n'y a pas besoin d'un switch . Normalizr permet d'aplatir facilement les réponses de l'API à ce format.

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

Liste des magasins garder la trace des ID des entités qui apparaissent dans une liste globale (par exemple, "feed", "vos notifications"). Dans ce projet, je n'ai pas de tels magasins, mais j'ai pensé que je devais les mentionner quand même. Ils gèrent la pagination.

Ils ne répondent normalement qu'à quelques actions (par ex. REQUEST_FEED , REQUEST_FEED_SUCCESS , REQUEST_FEED_ERROR ).

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

Magasins de listes indexées sont comme les magasins de liste, mais ils définissent une relation de type un à plusieurs. Par exemple, "les abonnés de l'utilisateur", "les astrologues du référentiel", "les référentiels de l'utilisateur". Ils gèrent également la pagination.

En outre, ils ne répondent normalement qu'à quelques actions (par ex. REQUEST_USER_REPOS , REQUEST_USER_REPOS_SUCCESS , REQUEST_USER_REPOS_ERROR ).

Dans la plupart des applications sociales, vous en aurez beaucoup et vous voulez être en mesure d'en créer rapidement une de plus.

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

Note : il ne s'agit pas de classes réelles ou autre ; c'est juste la façon dont j'aime penser aux magasins. J'ai cependant créé 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

Petites aides 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 la pagination et applique certaines assertions (ne peut pas récupérer la page pendant 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 passe-partout et une 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 s'accorder 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;
}

27voto

Spoike Points 32082

Ainsi, en Reflux le concept de répartiteur est supprimé et il suffit de 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 modélise ici 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 pour le 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 openUserProfile qui initie le chargement du profil de l'utilisateur et le changement de page, ainsi que des actions de chargement de messages qui chargeront les messages lorsque la page du profil de l'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 du profil de l'utilisateur qui charge le profil de l'utilisateur lorsque la page est ouverte.
  • Un magasin de données de la liste des messages qui charge et gère les messages visibles.

Dans le cas du Reflux, on le mettrait en place 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 d'utilisateurs

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 de postes

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 l'affichage de la page entière, la page du profil de l'utilisateur et la liste des messages. Ce qui suit doit être câblé :

  • Les boutons qui ouvrent le profil de l'utilisateur doivent invoquer la fonction Action.openUserProfile avec l'identifiant correct lors de l'événement de clic.
  • Le composant de la page doit écouter l'adresse currentPageStore pour qu'il sache à quelle page passer.
  • Le composant de la page du profil de l'utilisateur doit écouter l'adresse de l'utilisateur. currentUserProfileStore pour qu'il sache quelles données du profil de l'utilisateur montrer
  • La liste des messages doit écouter le currentPostsStore pour recevoir les postes chargés
  • L'événement de défilement infini doit appeler la fonction Action.loadMorePosts .

Et ça devrait être à peu près tout.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X