Wie verwalten Sie in der Flux-Architektur den Store-Lebenszyklus?

132

Ich lese über Flux, aber das Beispiel der Todo-App ist zu einfach, als dass ich einige wichtige Punkte verstehen könnte.

Stellen Sie sich eine einseitige App wie Facebook mit Benutzerprofilseiten vor . Auf jeder Benutzerprofilseite möchten wir einige Benutzerinformationen und ihre letzten Beiträge mit unendlichem Bildlauf anzeigen. Wir können von einem Benutzerprofil zu einem anderen navigieren.

Wie würde dies in der Flux-Architektur Stores und Dispatchern entsprechen?

Würden wir einen PostStorepro Benutzer verwenden oder würden wir eine Art globalen Laden haben? Was ist mit Dispatchern? Würden wir für jede „Benutzerseite“ einen neuen Dispatcher erstellen oder würden wir einen Singleton verwenden? Welcher Teil der Architektur ist schließlich für die Verwaltung des Lebenszyklus von "seitenspezifischen" Speichern als Reaktion auf Routenänderungen verantwortlich?

Darüber hinaus kann eine einzelne Pseudoseite mehrere Listen von Daten desselben Typs enthalten. Auf einer Profilseite möchte ich beispielsweise sowohl Follower als auch Follows anzeigen . Wie kann ein Singleton UserStorein diesem Fall funktionieren? Würde es UserPageStoreschaffen followedBy: UserStoreund follows: UserStore?

Dan Abramov
quelle

Antworten:

124

In einer Flux-App sollte es nur einen Dispatcher geben. Alle Daten fließen über diesen zentralen Hub. Mit einem Singleton-Dispatcher können alle Stores verwaltet werden. Dies ist wichtig, wenn Sie Store # 1 selbst aktualisieren müssen und Store # 2 sich dann selbst basierend auf der Aktion und dem Status von Store # 1 aktualisieren muss. Flux geht davon aus, dass diese Situation in einer großen Anwendung möglich ist. Im Idealfall muss diese Situation nicht eintreten, und Entwickler sollten sich bemühen, diese Komplexität nach Möglichkeit zu vermeiden. Aber der Singleton Dispatcher ist bereit, es zu handhaben, wenn es soweit ist.

Geschäfte sind auch Singletons. Sie sollten so unabhängig und entkoppelt wie möglich bleiben - ein in sich geschlossenes Universum, das man aus einer Controller-Ansicht abfragen kann. Der einzige Weg in den Store führt über den Rückruf, den er beim Dispatcher registriert. Der einzige Ausweg führt über Getter-Funktionen. Stores veröffentlichen auch ein Ereignis, wenn sich ihr Status geändert hat, sodass Controller-Views mithilfe der Getter wissen können, wann sie nach dem neuen Status fragen müssen.

In Ihrer Beispiel-App würde es eine einzige geben PostStore. Derselbe Speicher könnte die Beiträge auf einer "Seite" (Pseudoseite) verwalten, die eher dem Newsfeed von FB ähnelt, in dem Beiträge von verschiedenen Benutzern angezeigt werden. Seine logische Domäne ist die Liste der Beiträge, und er kann jede Liste von Beiträgen verarbeiten. Wenn wir von Pseudoseite zu Pseudoseite wechseln, möchten wir den Status des Geschäfts neu initialisieren, um den neuen Status widerzuspiegeln. Möglicherweise möchten wir auch den vorherigen Status in localStorage als Optimierung für das Hin- und Herwechseln zwischen Pseudoseiten zwischenspeichern, aber meine Neigung wäre, eine einzurichten, PageStoredie auf alle anderen Geschäfte wartet und die Beziehung zu localStorage für alle Geschäfte verwaltet die Pseudoseite und aktualisiert dann ihren eigenen Status. Beachten Sie, dass dies PageStorenichts über die Beiträge speichern würde - das ist die Domäne derPostStore. Es würde einfach wissen, ob eine bestimmte Pseudoseite zwischengespeichert wurde oder nicht, weil Pseudoseiten ihre Domäne sind.

Das PostStorehätte eine initialize()Methode. Diese Methode löscht immer den alten Status, auch wenn dies die erste Initialisierung ist, und erstellt dann den Status basierend auf den Daten, die über die Aktion über den Dispatcher empfangen wurden. Das Verschieben von einer Pseudoseite zu einer anderen würde wahrscheinlich eine PAGE_UPDATEAktion beinhalten, die den Aufruf von auslösen würde initialize(). Es gibt Details zum Abrufen von Daten aus dem lokalen Cache, zum Abrufen von Daten vom Server, zum optimistischen Rendern und zu XHR-Fehlerzuständen. Dies ist jedoch die allgemeine Idee.

Wenn eine bestimmte Pseudoseite nicht alle Speicher in der Anwendung benötigt, bin ich mir nicht ganz sicher, ob es einen Grund gibt, die nicht verwendeten zu zerstören, außer Speicherbeschränkungen. Geschäfte verbrauchen jedoch normalerweise nicht viel Speicher. Sie müssen nur sicherstellen, dass die Ereignis-Listener in den Controller-Ansichten, die Sie zerstören, entfernt werden. Dies geschieht nach der componentWillUnmount()Methode von React .

Fisherwebdev
quelle
5
Es gibt sicherlich ein paar verschiedene Ansätze für das, was Sie tun möchten, und ich denke, es hängt davon ab, was Sie bauen möchten. Ein Ansatz wäre ein UserListStoremit allen relevanten Benutzern. Und jeder Benutzer hätte ein paar boolesche Flags, die die Beziehung zum aktuellen Benutzerprofil beschreiben. So etwas { follower: true, followed: false }zum Beispiel. Die Methoden getFolloweds()und getFollowers()würden die verschiedenen Benutzergruppen abrufen, die Sie für die Benutzeroberfläche benötigen.
Fisherwebdev
4
Alternativ können Sie einen FollowedUserListStore und einen FollowerUserListStore haben, die beide von einem abstrakten UserListStore erben.
Fisherwebdev
Ich habe eine kleine Frage: Warum nicht Pub Sub verwenden, um Daten direkt aus den Filialen zu senden, anstatt die Abonnenten zum Abrufen der Daten zu verpflichten?
Sunwukung
2
@sunwukung Dies würde erfordern, dass die Geschäfte verfolgen, welche Controller-Ansichten welche Daten benötigen. Es ist sauberer, wenn die Geschäfte die Tatsache veröffentlichen, dass sie sich auf irgendeine Weise geändert haben, und dann die interessierten Controller-Ansichten abrufen lassen, welche Teile der Daten sie benötigen.
Fisherwebdev
Was ist, wenn ich eine Profilseite habe, auf der ich Informationen über einen Benutzer, aber auch eine Liste seiner Freunde anzeige? Sowohl Benutzer als auch Freunde wären der gleiche Typ. Sollten sie im selben Geschäft bleiben, wenn ja?
Nick Dima
79

(Hinweis: Ich habe die ES6-Syntax mit der Option JSX Harmony verwendet.)

Als Übung habe ich eine Beispiel-Flux-App geschrieben , die das Durchsuchen Github usersund Repos ermöglicht.
Es basiert auf der Antwort von Fisherwebdev , spiegelt aber auch einen Ansatz wider, den ich zur Normalisierung von API-Antworten verwende.

Ich habe es geschafft, einige Ansätze zu dokumentieren, die ich beim Erlernen von Flux ausprobiert habe.
Ich habe versucht, es nahe an der realen Welt zu halten (Paginierung, keine gefälschten localStorage-APIs).

Hier sind einige Punkte, die mich besonders interessiert haben:

  • Es verwendet die Flux-Architektur und den React-Router .
  • Es kann eine Benutzerseite mit teilweise bekannten Informationen anzeigen und unterwegs Details laden.
  • Es unterstützt die Paginierung sowohl für Benutzer als auch für Repos.
  • Es analysiert Githubs verschachtelte JSON-Antworten mit normalizr .
  • Content Stores müssen keinen Riesen switchmit Aktionen enthalten .
  • "Zurück" ist sofort verfügbar (da sich alle Daten in Geschäften befinden).

Wie ich Geschäfte klassifiziere

Ich habe versucht, einige der Duplikate zu vermeiden, die ich in anderen Flux-Beispielen gesehen habe, insbesondere in Stores. Ich fand es nützlich, Stores logisch in drei Kategorien zu unterteilen:

Inhaltsspeicher enthalten alle App-Entitäten. Alles, was eine ID hat, benötigt einen eigenen Content Store. Komponenten, die einzelne Elemente rendern, fragen Content Stores nach den neuen Daten.

Inhaltsspeicher sammeln ihre Objekte aus allen Serveraktionen. Zum Beispiel UserStore untersuchtaction.response.entities.users , wenn es vorhanden ist, unabhängig davon Aktion gefeuert. Es besteht keine Notwendigkeit für eine switch. Normalizr macht es einfach, API-Antworten auf dieses Format zu reduzieren.

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

Listenspeicher verfolgen die IDs von Entitäten, die in einer globalen Liste angezeigt werden (z. B. "Feed", "Ihre Benachrichtigungen"). In diesem Projekt habe ich keine solchen Geschäfte, aber ich dachte, ich würde sie trotzdem erwähnen. Sie behandeln die Paginierung.

Sie reagieren normalerweise auf wenige Aktionen (zB REQUEST_FEED, REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

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

Indizierte Listenspeicher sind wie Listenspeicher, definieren jedoch eine Eins-zu-Viele-Beziehung. Zum Beispiel "Abonnenten des Benutzers", "Sterngucker des Repositorys", "Repositorys des Benutzers". Sie behandeln auch die Paginierung.

Sie reagieren auch normalerweise nur ein paar Aktionen (zB REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

In den meisten sozialen Apps gibt es viele davon, und Sie möchten schnell eine weitere erstellen können.

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

Hinweis: Dies sind keine tatsächlichen Klassen oder ähnliches. So denke ich gerne über Geschäfte nach. Ich habe allerdings ein paar Helfer gemacht.

StoreUtils

createStore

Diese Methode bietet Ihnen den grundlegendsten Store:

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

Ich benutze es, um alle Stores zu erstellen.

isInBag, mergeIntoBag

Kleine Helfer für Content Stores.

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

Speichert den Paginierungsstatus und erzwingt bestimmte Zusicherungen (beim Abrufen können keine Seiten abgerufen werden usw.).

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

Vereinfacht die Erstellung indizierter Listenspeicher so einfach wie möglich, indem Boilerplate-Methoden und die Handhabung von Aktionen bereitgestellt werden:

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

Ein Mixin, mit dem Komponenten auf Stores zugreifen können, an denen sie interessiert sind, z 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
quelle
1
Würden Sie angesichts der Tatsache, dass Sie Stampsy geschrieben haben, FLUX und dieselbe Vorgehensweise verwenden, die Sie zum Erstellen dieser Beispiel-App verwendet haben, wenn Sie die gesamte clientseitige Anwendung neu schreiben würden?
eAbi
2
eAbi: Dies ist der Ansatz, den wir derzeit verwenden, wenn wir Stampsy in Flux umschreiben (in der Hoffnung, ihn nächsten Monat veröffentlichen zu können). Es ist nicht ideal, aber es funktioniert gut für uns. Wenn wir bessere Möglichkeiten finden, dies zu tun, werden wir sie teilen.
Dan Abramov
1
eAbi: Wir verwenden jedoch nicht mehr normalizr, weil ein Mitarbeiter unseres Teams alle unsere APIs neu geschrieben hat, um normalisierte Antworten zurückzugeben. Es war jedoch nützlich, bevor dies getan wurde.
Dan Abramov
Vielen Dank für Ihre Informationen. Ich habe Ihr Github-Repo überprüft und versuche, ein Projekt (in YUI3 erstellt) mit Ihrem Ansatz zu beginnen, aber ich habe einige Probleme beim Kompilieren des Codes (wenn Sie dies sagen können). Ich führe den Server nicht unter Knoten aus, also wollte ich die Quelle in mein statisches Verzeichnis kopieren, aber ich muss noch etwas arbeiten ... Es ist etwas umständlich, und ich habe auch einige Dateien mit unterschiedlicher JS-Syntax gefunden. Besonders in jsx-Dateien.
eAbi
2
@ Sean: Ich sehe es überhaupt nicht als Problem. Beim Datenfluss geht es darum, Daten zu schreiben, nicht zu lesen. Sicher ist es am besten, wenn Aktionen für Geschäfte agnostisch sind, aber zur Optimierung von Anfragen denke ich, dass es vollkommen in Ordnung ist, aus Geschäften zu lesen. Schließlich lesen Komponenten aus Geschäften und lösen diese Aktionen aus. Sie könnten diese Logik in jeder Komponente wiederholen, aber dafür ist Action Creator
Dan Abramov
27

In Reflux wird das Konzept des Dispatchers entfernt und Sie müssen nur noch an den Datenfluss durch Aktionen und Speicher denken. Dh

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

Jeder Pfeil hier modelliert, wie der Datenfluss abgehört wird, was wiederum bedeutet, dass die Daten in die entgegengesetzte Richtung fließen. Die tatsächliche Zahl für den Datenfluss lautet wie folgt:

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

In Ihrem Anwendungsfall benötigen wir, wenn ich das richtig verstanden habe, eine openUserProfileAktion, die das Laden und Wechseln des Benutzerprofils initiiert, sowie einige Aktionen zum Laden von Posts, die Posts laden, wenn die Benutzerprofilseite geöffnet wird und während des unendlichen Bildlaufereignisses. Ich würde mir also vorstellen, dass die Anwendung die folgenden Datenspeicher enthält:

  • Ein Seitendatenspeicher, der das Wechseln von Seiten übernimmt
  • Ein Benutzerprofildatenspeicher, der das Benutzerprofil beim Öffnen der Seite lädt
  • Ein Datenspeicher-Datenspeicher, der die sichtbaren Posts lädt und verarbeitet

In Reflux würden Sie es so einrichten:

Die Taten

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

Der Seitenspeicher

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

Der Benutzerprofilspeicher

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

Die Beiträge speichern

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

Die Komponenten

Ich gehe davon aus, dass Sie eine Komponente für die gesamte Seitenansicht, die Benutzerprofilseite und die Liste der Beiträge haben. Folgendes muss verkabelt werden:

  • Die Schaltflächen, die das Benutzerprofil öffnen, müssen die Action.openUserProfilemit der richtigen ID während des Klickereignisses aufrufen .
  • Die Seitenkomponente sollte das abhören, currentPageStoredamit sie weiß, zu welcher Seite sie wechseln soll.
  • Die Benutzerprofilseitenkomponente muss die abhören, currentUserProfileStoredamit sie weiß, welche Benutzerprofildaten angezeigt werden sollen
  • Die Liste der Beiträge muss sich anhören currentPostsStore, um die geladenen Beiträge zu erhalten
  • Das unendliche Bildlaufereignis muss das aufrufen Action.loadMorePosts.

Und das sollte so ziemlich alles sein.

Spoike
quelle
Danke für das Schreiben!
Dan Abramov
2
Ein bisschen zu spät zur Party vielleicht, aber hier ist ein schöner Artikel, der erklärt, warum Sie es vermeiden sollten, Ihre API direkt aus Geschäften aufzurufen . Ich finde immer noch heraus, was die besten Praktiken sind, aber ich dachte, es könnte anderen helfen, darüber zu stolpern. In Bezug auf Geschäfte gibt es viele verschiedene Ansätze.
Thijs Koerselman