Nell'architettura Flux, come gestite il ciclo di vita dello Store?


132

Sto leggendo Flux, ma l' app Todo di esempio è troppo semplicistica per me per capire alcuni punti chiave.

Immagina un'app a pagina singola come Facebook con pagine del profilo utente . In ogni pagina del profilo utente, vogliamo mostrare alcune informazioni utente e i loro ultimi post, con scorrimento infinito. Possiamo spostarci da un profilo utente a un altro.

Nell'architettura Flux, come corrisponderebbe a negozi e spedizionieri?

Ne useremmo uno PostStoreper utente o avremmo una specie di negozio globale? Che dire degli spedizionieri, creeremmo un nuovo Dispatcher per ogni "pagina utente" o useremmo un singleton? Infine, quale parte dell'architettura è responsabile della gestione del ciclo di vita dei negozi “specifici della pagina” in risposta al cambio di rotta?

Inoltre, una singola pseudo-pagina può avere diversi elenchi di dati dello stesso tipo. Ad esempio, in una pagina di profilo, voglio mostrare entrambi i seguaci e Segue . Come può funzionare un singleton UserStorein questo caso? Sarebbe UserPageStoreriuscito followedBy: UserStoree follows: UserStore?

Risposte:


124

In un'app Flux dovrebbe esserci un solo Dispatcher. Tutti i dati fluiscono attraverso questo hub centrale. Avere un Dispatcher singleton consente di gestire tutti i negozi. Ciò diventa importante quando è necessario l'aggiornamento dello Store n. 1 e quindi l'aggiornamento dello Store n. 2 in base all'azione e allo stato dello Store n. 1. Flux presume che questa situazione sia un'eventualità in un'applicazione di grandi dimensioni. Idealmente, questa situazione non dovrebbe verificarsi e gli sviluppatori dovrebbero cercare di evitare questa complessità, se possibile. Ma il Singleton Dispatcher è pronto a gestirlo quando arriva il momento.

I negozi sono anche singoli. Dovrebbero rimanere il più indipendenti e disaccoppiati possibile - un universo autonomo che si può interrogare da una vista controller. L'unica strada verso lo Store è attraverso il callback che registra con il Dispatcher. L'unica strada da percorrere è attraverso le funzioni getter. I negozi pubblicano anche un evento quando il loro stato è cambiato, in modo che Controller-Views possa sapere quando eseguire una query per il nuovo stato, utilizzando i getter.

Nella tua app di esempio, ci sarebbe un singolo PostStore. Questo stesso negozio potrebbe gestire i post su una "pagina" (pseudo-pagina) che è più simile al Newsfeed di FB, in cui i post vengono visualizzati da utenti diversi. Il suo dominio logico è l'elenco di post e può gestire qualsiasi elenco di post. Quando passiamo dalla pseudo-pagina alla pseudo-pagina, vogliamo reinizializzare lo stato del negozio per riflettere il nuovo stato. Potremmo anche voler memorizzare nella cache lo stato precedente in localStorage come un'ottimizzazione per spostarsi avanti e indietro tra le pseudo-pagine, ma la mia inclinazione sarebbe quella di impostare un PageStoreche aspetta tutti gli altri negozi, gestisce il rapporto con localStorage per tutti i negozi su la pseudo-pagina e quindi aggiorna il proprio stato. Nota che questo PageStorenon memorizzerebbe nulla sui post - questo è il dominio diPostStore. Saprebbe semplicemente se una particolare pseudo-pagina è stata memorizzata nella cache o meno, perché le pseudo-pagine sono il suo dominio.

L' PostStoreavrebbe un initialize()metodo. Questo metodo eliminerebbe sempre il vecchio stato, anche se questa è la prima inizializzazione, e quindi creerebbe lo stato in base ai dati ricevuti tramite l'azione, tramite Dispatcher. Passare da una pseudo-pagina all'altra implicherebbe probabilmente PAGE_UPDATEun'azione, che provocherebbe l'invocazione di initialize(). Ci sono dettagli per elaborare il recupero dei dati dalla cache locale, il recupero dei dati dal server, il rendering ottimistico e gli stati di errore XHR, ma questa è l'idea generale.

Se una particolare pseudo-pagina non ha bisogno di tutti gli Store nell'applicazione, non sono del tutto sicuro che ci sia un motivo per distruggere quelli inutilizzati, a parte i vincoli di memoria. Ma i negozi in genere non consumano molta memoria. Devi solo assicurarti di rimuovere i listener di eventi nelle Controller-Views che stai distruggendo. Questo viene fatto nel componentWillUnmount()metodo di React .


5
Ci sono sicuramente alcuni approcci diversi a ciò che vuoi fare e penso che dipenda da ciò che stai cercando di costruire. Un approccio sarebbe un UserListStore, con tutti gli utenti pertinenti in esso. E ogni utente avrebbe un paio di flag booleani che descrivono la relazione con il profilo utente corrente. Qualcosa come { follower: true, followed: false }, per esempio. I metodi getFolloweds()e getFollowers()recuperare i diversi set di utenti necessari per l'interfaccia utente.
fisherwebdev,

4
In alternativa, potresti avere un FollowedUserListStore e un FollowerUserListStore che entrambi ereditano da un UserListStore astratto.
fisherwebdev,

Ho una piccola domanda: perché non utilizzare pub sub per emettere dati direttamente dai negozi anziché richiedere agli abbonati di recuperare i dati?
sunwukung,

2
@sunwukung Ciò richiederebbe ai negozi di tenere traccia di quali visualizzazioni di controller necessitano di quali dati. È più chiaro che i negozi pubblicino il fatto che sono cambiati in qualche modo, quindi consentono alle visualizzazioni dei controller interessate di recuperare le parti dei dati di cui hanno bisogno.
fisherwebdev,

E se avessi una pagina del profilo in cui mostro informazioni su un utente ma anche un elenco dei suoi amici. Sia l'utente che gli amici sarebbero lo stesso tipo di quello. In tal caso dovrebbero rimanere nello stesso negozio?
Nick Dima,

79

(Nota: ho usato la sintassi ES6 usando l'opzione JSX Harmony.)

Come esercizio, ho scritto un'app Flux di esempio che consente di navigare Github userse riposizionare.
Si basa sulla risposta di fisherwebdev ma riflette anche un approccio che utilizzo per normalizzare le risposte API.

L'ho fatto per documentare alcuni approcci che ho provato durante l'apprendimento di Flux.
Ho cercato di tenerlo vicino al mondo reale (impaginazione, nessuna API locale di archivio falsa).

Ci sono alcuni bit qui a cui ero particolarmente interessato:

Come classifico i negozi

Ho cercato di evitare alcune delle duplicazioni che ho visto in altri esempi di Flux, in particolare nei negozi. Ho trovato utile dividere logicamente i negozi in tre categorie:

I Content Store contengono tutte le entità app. Tutto ciò che ha un ID ha bisogno del proprio Content Store. I componenti che rendono singoli elementi richiedono Content Store per i nuovi dati.

I Content Store raccolgono i loro oggetti da tutte le azioni del server. Ad esempio, UserStore esaminaaction.response.entities.users se esiste indipendentemente dall'azione attivata. Non è necessario per a switch. Normalizr semplifica l'appiattimento di qualsiasi risposta API in questo formato.

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

I List List tengono traccia degli ID delle entità che compaiono in alcuni elenchi globali (ad es. "Feed", "le tue notifiche"). In questo progetto, non ho tali negozi, ma ho pensato di menzionarli comunque. Gestiscono l'impaginazione.

Normalmente rispondono a poche azioni (ad es REQUEST_FEED. REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

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

I List List List sono simili ai List List ma definiscono una relazione uno-a-molti. Ad esempio, "abbonati dell'utente", "stargazers del repository", "repository dell'utente". Gestiscono anche l'impaginazione.

Normalmente rispondono anche a poche azioni (ad es REQUEST_USER_REPOS. REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

Nella maggior parte delle app social, ne avrai molte e desideri poterne creare rapidamente un'altra.

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

Nota: queste non sono classi reali o qualcosa del genere; è proprio come mi piace pensare ai negozi. Ho fatto alcuni aiutanti però.

StoreUtils

createStore

Questo metodo ti offre lo Store più semplice:

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

Lo uso per creare tutti i negozi.

isInBag, mergeIntoBag

Piccoli aiutanti utili per i 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

Memorizza lo stato di impaginazione e applica alcune asserzioni (impossibile recuperare la pagina durante il recupero, ecc.).

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

Semplifica al massimo la creazione di elenchi di elenchi indicizzati fornendo metodi di gestione delle piastre e gestione delle azioni:

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 che consente ai componenti di sintonizzarsi sui negozi a cui sono interessati, ad es 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
Dato che hai scritto Stampsy, se riscrivessi l'intera applicazione lato client, utilizzeresti FLUX e lo stesso approccio che hai usato per creare questa app di esempio?
eAbi,

2
eAbi: questo è l'approccio attualmente in uso mentre stiamo riscrivendo Stampsy in Flux (sperando di rilasciarlo il mese prossimo). Non è l'ideale ma funziona bene per noi. Quando / se scopriamo modi migliori per fare queste cose, le condivideremo.
Dan Abramov,

1
eAbi: Tuttavia, non usiamo più normalizr perché un membro del nostro team ha riscritto tutte le nostre API per restituire risposte normalizzate. È stato utile prima che fosse fatto però.
Dan Abramov,

Grazie per le informazioni. Ho controllato il tuo repository github e sto cercando di iniziare un progetto (costruito in YUI3) con il tuo approccio, ma ho dei problemi a compilare il codice (se puoi dirlo). Non sto eseguendo il server sotto il nodo, quindi volevo copiare l'origine nella mia directory statica, ma devo ancora fare un po 'di lavoro ... È un po' ingombrante e ho anche trovato alcuni file con sintassi JS diversa. Soprattutto nei file jsx.
eAbi,

2
@Sean: non lo vedo affatto come un problema. Il flusso di dati riguarda la scrittura di dati, non la loro lettura. Certo è meglio se le azioni sono agnostiche nei negozi, ma per l'ottimizzazione delle richieste penso che sia perfettamente corretto leggere dai negozi. Dopo tutto, i componenti leggono dai negozi e attivano tali azioni. Potresti ripetere questa logica in ogni componente, ma questo è ciò che è il creatore di azioni per ..
Dan Abramov,

27

Quindi in Reflux il concetto di Dispatcher viene rimosso e devi solo pensare in termini di flusso di dati attraverso azioni e archivi. ie

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

Ogni freccia qui modella il modo in cui il flusso di dati viene ascoltato, il che a sua volta significa che i dati scorrono nella direzione opposta. Il dato reale per il flusso di dati è questo:

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

Nel tuo caso d'uso, se ho capito bene, abbiamo bisogno di openUserProfileun'azione che avvii il caricamento del profilo utente e il cambio della pagina e anche alcune azioni di caricamento dei post che caricheranno i messaggi quando viene aperta la pagina del profilo utente e durante l'evento di scorrimento infinito. Immagino quindi che nell'applicazione siano presenti i seguenti archivi di dati:

  • Un archivio di dati di pagina che gestisce il cambio di pagina
  • Un archivio di dati del profilo utente che carica il profilo utente all'apertura della pagina
  • Un archivio di dati dell'elenco di post che carica e gestisce i post visibili

In Reflux lo avresti impostato in questo modo:

Le azioni

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

Il negozio di pagine

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

L'archivio dei profili utente

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

Il negozio di articoli

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

I componenti

Suppongo che tu abbia un componente per l'intera visualizzazione della pagina, la pagina del profilo utente e l'elenco dei post. È necessario cablare quanto segue:

  • I pulsanti che aprono il profilo utente devono richiamare Action.openUserProfilecon l'id corretto durante l'evento click.
  • Il componente della pagina dovrebbe essere in ascolto in currentPageStoremodo da sapere a quale pagina passare.
  • Il componente della pagina del profilo utente deve ascoltare in currentUserProfileStoremodo da sapere quali dati del profilo utente mostrare
  • L'elenco dei post deve essere ascoltato currentPostsStoreper ricevere i post caricati
  • L'evento di scorrimento infinito deve chiamare il Action.loadMorePosts.

E dovrebbe essere praticamente così.


Grazie per il commento!
Dan Abramov,

2
Forse un po 'in ritardo alla festa, ma ecco un bell'articolo che spiega perché evitare di chiamarti API direttamente dai negozi . Sto ancora cercando di capire quali sono le migliori pratiche, ma ho pensato che potesse aiutare altri inciampare su questo. Ci sono molti approcci diversi in circolazione per quanto riguarda i negozi.
Thijs Koerselman,
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.