web-dev-qa-db-fra.com

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

Je lis à propos de Flux mais le exemple de l'application Todo est trop simpliste pour que je puisse comprendre certains points clés.

Imaginez une application simple page telle que Facebook qui possède des pages de profil utilisateur . Sur chaque page de profil utilisateur, nous souhaitons afficher certaines informations utilisateur et leurs derniers messages, avec un défilement infini. Nous pouvons naviguer d'un profil d'utilisateur à un autre.

Dans l'architecture Flux, comment cela correspond-il à Stores and Dispatchers?

Utiliserions-nous un PostStore par utilisateur ou aurions-nous une sorte de magasin global? 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 à une 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 les deux suiveurs et suit. Comment un singleton UserStore peut-il fonctionner dans ce cas? Est-ce que UserPageStore gérerait followedBy: UserStore et follows: UserStore?

132
Dan Abramov

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

Les magasins sont des singletons aussi. Ils doivent rester aussi indépendants et découplés que possible - un univers autonome que l'on peut interroger à partir d'une vue Contrôleur. Le seul chemin qui mène au magasin passe par le rappel qu'il enregistre auprès de Dispatcher. La seule voie à suivre est via les fonctions getter. Les magasins publient également un événement lorsque leur état a changé. Par conséquent, Controller-Views peut savoir quand demander le nouvel état, à l'aide des getters.

Dans votre exemple d'application, il n'y aurait qu'un seul PostStore. Ce même magasin pourrait gérer les messages sur une "page" (pseudo-page) qui ressemble davantage au flux d'actualités de FB, où les messages apparaissent de différents utilisateurs. Son domaine logique est la liste des publications, et il peut gérer n’importe quelle liste de publications. Lorsque nous passons de pseudo-page à 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 afin d’optimiser le passage d’une pseudo-page à une autre, mais j’aimerais plutôt mettre en place un 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 ce PageStore ne stockerait rien sur les posts - c’est le domaine du PostStore. Il saurait simplement savoir si une pseudo-page particulière a été mise en cache ou non, car les pseudo-pages sont son domaine.

Le PostStore aurait une méthode initialize(). Cette méthode efface toujours l'ancien état, même s'il s'agit de la première initialisation, puis crée l'état en fonction des données reçues via l'action, via le répartiteur. Passer d'une pseudo-page à une autre impliquerait probablement une action PAGE_UPDATE, Ce qui déclencherait l'appel de initialize(). Il existe des détails à résoudre concernant la récupération de données à partir du cache local, la récupération de données à partir du serveur, le rendu optimiste et les états d'erreur XHR, mais c'est là 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, à l'exception 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énement dans les vues de contrôleur que vous détruisez. Ceci est fait dans la méthode componentWillUnmount() de React.

124
fisherwebdev

(Remarque: j'ai utilisé la syntaxe ES6 à l'aide de l'option JSX Harmony.)

À titre d’exercice, j’ai écrit un exemple d’application Flux qui permet de parcourir Github users Et de mettre en pension.
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 tout en apprenant Flux.
J'ai essayé de le garder proche du monde réel (pagination, pas de fausses API de stockage local).

Il y a quelques passages ici qui m'ont particulièrement intéressé:

Comment classer les magasins

J'ai essayé d'éviter certains des doublons que j'ai vus 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 de l'application. Tout ce qui a un identifiant doit avoir son propre Content Store. Les composants qui rendent des éléments individuels demandent aux magasins de contenu les nouvelles données.

Les magasins de contenu récoltent leurs objets à partir de tous actions du serveur. Par exemple, UserStorerecherche dans action.response.entities.users s'il existe peu importe quelle action a été déclenchée. Il n'y a pas besoin de switch. Normalizr facilite l'aplatissement des réponses d'API à ce format.

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

Listes de magasins assurent le suivi des identifiants d'entités apparaissant dans une liste globale (par exemple, "flux", "vos notifications"). Dans ce projet, je n'ai pas de tels magasins, mais je pensais les mentionner de toute façon. Ils gèrent la pagination.

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

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

Les magasins de liste indexés sont similaires aux magasins de liste, 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 également normalement à quelques actions seulement (par exemple, REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

Dans la plupart des applications sociales, vous en avez beaucoup et vous voulez pouvoir en créer rapidement une autre.

// 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; c'est comme ça que j'aime penser aux magasins. J'ai fait quelques aides cependant.

StoreUtils

createStore

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

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 d'extraire une page pendant l'extraction, 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

Simplifie le plus possible la création de magasins à liste indexée en fournissant des méthodes standard et un traitement 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 d’être connectés 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;
}
79
Dan Abramov

Ainsi, dans Reflux , le concept de Dispatcher est supprimé et il vous suffit de penser en termes de flux de données à travers des actions et des magasins. C'est à dire.

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

Chaque flèche ici modélise la manière dont le flux de données est écouté, ce qui signifie que les données circulent dans le sens opposé. 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 action openUserProfile qui initie le chargement et le changement de profil de l'utilisateur, ainsi que des actions de chargement de publications qui chargeront des publications lorsque la page de profil d'utilisateur sera ouverte et é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 configureriez comme suit:

Les actions

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

Le magasin de page

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

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 page entière, la page de profil d'utilisateur et la liste de publications. Les éléments suivants doivent être câblés:

  • Les boutons qui ouvrent le profil utilisateur doivent invoquer le Action.openUserProfile avec l'identifiant correct lors de l'événement click.
  • Le composant de page devrait écouter le currentPageStore afin de savoir sur quelle page basculer.
  • Le composant de page de profil utilisateur doit écouter le currentUserProfileStore afin de savoir quelles données de profil utilisateur afficher
  • La liste des publications doit écouter le currentPostsStore pour recevoir les publications chargées.
  • L'événement de défilement infini doit appeler le Action.loadMorePosts.

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

27
Spoike