Jak w architekturze Flux zarządzasz cyklem życia sklepu?


132

Czytam o Flux, ale przykładowa aplikacja Todo jest zbyt uproszczona, abym mógł zrozumieć kilka kluczowych punktów.

Wyobraź sobie aplikację z jedną stroną, taką jak Facebook, która ma strony profilu użytkownika . Na każdej stronie profilu użytkownika chcemy pokazać niektóre informacje o użytkowniku i jego ostatnie posty z nieskończonym przewijaniem. Możemy przejść od jednego profilu użytkownika do innego.

Jak w architekturze Flux miałoby to odpowiadać sklepom i dyspozytorom?

Czy użylibyśmy jednego PostStorena użytkownika, czy też mielibyśmy jakiś globalny sklep? A co z dyspozytorami, czy stworzylibyśmy nowego Dispatchera dla każdej „strony użytkownika”, czy też użylibyśmy singletona? Wreszcie, która część architektury jest odpowiedzialna za zarządzanie cyklem życia Sklepów „specyficznych dla strony” w odpowiedzi na zmianę trasy?

Ponadto pojedyncza pseudo-strona może mieć kilka list danych tego samego typu. Na przykład, na stronie profilu, chcę pokazać zarówno Następni i Follows . Jak UserStorew takim przypadku może działać singleton ? Będzie UserPageStorezarządzać followedBy: UserStorei follows: UserStore?

Odpowiedzi:


124

W aplikacji Flux powinien znajdować się tylko jeden dyspozytor. Wszystkie dane przepływają przez to centralne centrum. Posiadanie pojedynczego Dyspozytora pozwala na zarządzanie wszystkimi Sklepami. Staje się to ważne, gdy potrzebujesz samej aktualizacji Sklepu nr 1, a następnie sam sklep nr 2 zaktualizuje się w oparciu o działanie i stan Sklepu nr 1. Flux zakłada, że ​​taka sytuacja jest ewentualnością w dużej aplikacji. W idealnym przypadku taka sytuacja nie musiałaby mieć miejsca, a programiści powinni starać się unikać tej złożoności, jeśli to możliwe. Ale pojedynczy Dispatcher jest gotowy, aby sobie z tym poradzić, gdy nadejdzie czas.

Sklepy też są singletonami. Powinny pozostać tak niezależne i odseparowane, jak to tylko możliwe - samodzielny wszechświat, do którego można zapytać z widoku kontrolera. Jedyną drogą do Sklepu jest wywołanie zwrotne, które rejestruje u Dyspozytora. Jedyne wyjście prowadzi przez funkcje getter. Sklepy publikują również zdarzenie, gdy ich stan się zmienił, dzięki czemu kontrolery-Views mogą wiedzieć, kiedy wykonać zapytanie o nowy stan, używając metod pobierających.

W Twojej przykładowej aplikacji będzie pojedynczy plik PostStore. Ten sam sklep mógłby zarządzać postami na „stronie” (pseudo stronie), która bardziej przypomina kanał informacyjny FB, gdzie pojawiają się posty od różnych użytkowników. Jego domeną logiczną jest lista postów i może obsłużyć dowolną listę postów. Kiedy przechodzimy od pseudo-strony do pseudo-strony, chcemy ponownie zainicjować stan sklepu, aby odzwierciedlić nowy stan. Moglibyśmy również chcieć buforować poprzedni stan w localStorage jako optymalizację do przechodzenia między pseudo-stronami, ale wolałbym skonfigurować taki, PageStorektóry czeka na wszystkie inne sklepy, zarządza relacją z localStorage dla wszystkich sklepów w pseudo-stronę, a następnie aktualizuje swój własny stan. Zauważ, że to PageStorenie przechowuje nic o postach - to domenaPostStore. Po prostu wiedziałby, czy dana pseudo-strona została zapisana w pamięci podręcznej, czy nie, ponieważ pseudo-strony są jej domeną.

PostStoreMiałoby initialize()metody. Ta metoda zawsze usuwałaby stary stan, nawet jeśli jest to pierwsza inicjalizacja, a następnie tworzyłaby stan na podstawie danych otrzymanych za pośrednictwem akcji, za pośrednictwem Dispatchera. Przejście z jednej pseudo strony na inną prawdopodobnie wiązałoby się z PAGE_UPDATEdziałaniem, które spowodowałoby wywołanie initialize(). Istnieją szczegóły dotyczące pobierania danych z lokalnej pamięci podręcznej, pobierania danych z serwera, optymistycznego renderowania i stanów błędów XHR, ale taka jest ogólna idea.

Jeśli dana pseudo-strona nie potrzebuje wszystkich Sklepów w aplikacji, nie jestem do końca pewien, czy istnieje jakikolwiek powód do niszczenia nieużywanych poza ograniczeniami pamięci. Ale sklepy zazwyczaj nie zajmują dużo pamięci. Musisz tylko upewnić się, że usunąłeś detektory zdarzeń z kontrolerów-widoków, które niszczisz. Odbywa się to componentWillUnmount()metodą Reacta .


5
Z pewnością istnieje kilka różnych podejść do tego, co chcesz zrobić i myślę, że zależy to od tego, co próbujesz zbudować. Jednym z podejść byłoby UserListStore, ze wszystkimi istotnymi użytkownikami w nim. Każdy użytkownik miałby kilka flag logicznych opisujących związek z bieżącym profilem użytkownika. Na przykład coś takiego { follower: true, followed: false }. Metody getFolloweds()i getFollowers()będą pobierać różne zestawy użytkowników, których potrzebujesz dla interfejsu użytkownika.
fisherwebdev

4
Alternatywnie możesz mieć FollowedUserListStore i FollowerUserListStore, które dziedziczą po abstrakcyjnym magazynie UserListStore.
fisherwebdev

Mam małe pytanie - dlaczego nie wykorzystać pub sub do bezpośredniego wysyłania danych ze sklepów, zamiast wymagać od abonentów pobierania danych?
sunwukung

2
@sunwukung Wymagałoby to od sklepów śledzenia tego, jakie widoki kontrolerów potrzebują jakich danych. Czystsze jest publikowanie przez sklepy informacji, że zmieniły się w jakiś sposób, a następnie pozwolenie zainteresowanym widokom kontrolera na pobranie potrzebnych części danych.
fisherwebdev

Co jeśli mam stronę profilową, na której pokazuję informacje o użytkowniku, ale także listę jego znajomych. Zarówno użytkownik, jak i znajomi byliby tego samego typu. Czy powinny pozostać w tym samym sklepie, jeśli tak?
Nick Dima,

79

(Uwaga: użyłem składni ES6 przy użyciu opcji JSX Harmony.)

W ramach ćwiczenia napisałem przykładową aplikację Flux, która umożliwia przeglądanie Github usersi repozytorium.
Opiera się na odpowiedzi fisherwebdev, ale odzwierciedla również podejście, które stosuję do normalizowania odpowiedzi API.

Zrobiłem to, aby udokumentować kilka podejść, które wypróbowałem podczas nauki Flux.
Starałem się, aby był jak najbardziej zbliżony do prawdziwego świata (paginacja, brak fałszywych API localStorage).

Jest tu kilka bitów, które mnie szczególnie zainteresowały:

  • Używa architektury Flux i routera React ;
  • Może wyświetlać stronę użytkownika z częściowymi znanymi informacjami i ładować szczegóły w podróży;
  • Obsługuje paginację zarówno dla użytkowników, jak i repozytoriów;
  • Analizuje zagnieżdżone odpowiedzi JSON Githuba za pomocą normalizr ;
  • Sklepy z zawartością nie muszą zawierać giganta switchz akcjami ;
  • „Wstecz” jest natychmiastowy (ponieważ wszystkie dane są w Sklepach).

Jak klasyfikuję sklepy

Próbowałem uniknąć niektórych powielania, które widziałem w innych przykładach Flux, szczególnie w sklepach. Uznałem, że warto logicznie podzielić Sklepy na trzy kategorie:

Magazyny treści przechowują wszystkie jednostki aplikacji. Wszystko, co ma identyfikator, potrzebuje własnego magazynu treści. Komponenty, które renderują poszczególne elementy, proszą magazyny zawartości o świeże dane.

Magazyny zawartości zbierają swoje obiekty ze wszystkich działań na serwerze. Na przykład UserStore sprawdza,action.response.entities.users czy istnieje, niezależnie od uruchomionej akcji. Nie ma potrzeby posiadania pliku switch. Normalizr ułatwia spłaszczanie wszelkich odpowiedzi API do tego formatu.

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

Magazyny z listami śledzą identyfikatory podmiotów, które pojawiają się na jakiejś globalnej liście (np. „Kanał”, „Twoje powiadomienia”). W tym projekcie nie mam takich Sklepów, ale pomyślałem, że i tak o nich wspomnę. Obsługują paginację.

Normalnie reagują oni do zaledwie kilku czynności (np REQUEST_FEED, REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

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

Magazyny z listami indeksowanymi są podobne do magazynów z listami, ale definiują relację jeden do wielu. Na przykład „subskrybenci użytkownika”, „obserwatorzy repozytorium”, „repozytoria użytkownika”. Obsługują również paginację.

Także zwykle reagują oni do zaledwie kilku czynności (np REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

W większości aplikacji społecznościowych będziesz mieć ich dużo i chcesz mieć możliwość szybkiego utworzenia jeszcze jednej.

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

Uwaga: to nie są rzeczywiste klasy czy coś takiego; właśnie tak lubię myśleć o Sklepach. Zrobiłem jednak kilku pomocników.

StoreUtils

createStore

Ta metoda zapewnia najbardziej podstawowy sklep:

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

Używam go do tworzenia wszystkich Sklepów.

isInBag, mergeIntoBag

Mali pomocnicy przydatni w magazynach zawartości.

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

Przechowuje stan stronicowania i wymusza pewne potwierdzenia (nie można pobrać strony podczas pobierania itp.).

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

Sprawia, że ​​tworzenie magazynów z listami indeksowanymi jest tak proste, jak to tylko możliwe, zapewniając standardowe metody i obsługę akcji:

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

Mixin, który umożliwia komponentom dostrojenie się do Sklepów, którymi są zainteresowane, np 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
Biorąc pod uwagę fakt, że napisałeś Stampsy'ego, gdybyś przepisał całą aplikację po stronie klienta, czy użyłbyś FLUX i tego samego podejścia, którego użyłeś do zbudowania tej przykładowej aplikacji?
eAbi,

2
eAbi: To jest podejście, którego obecnie używamy, gdy przepisujemy Stampsy in Flux (mamy nadzieję, że zostanie wydany w przyszłym miesiącu). Nie jest to idealne rozwiązanie, ale dla nas działa dobrze. Kiedy / jeśli wymyślimy lepsze sposoby na zrobienie tych rzeczy, udostępnimy je.
Dan Abramov,

1
eAbi: Jednak nie używamy już normalizr, ponieważ facet z naszego zespołu przepisał wszystkie nasze API, aby zwracały znormalizowane odpowiedzi. Było to przydatne, zanim to zostało zrobione.
Dan Abramov,

Dziękuję za informację. Sprawdziłem Twoje repozytorium na githubie i próbuję rozpocząć projekt (zbudowany w YUI3) z Twoim podejściem, ale mam pewne problemy z kompilacją kodu (jeśli możesz tak powiedzieć). Nie uruchamiam serwera w węźle, więc chciałem skopiować źródło do mojego katalogu statycznego, ale nadal muszę trochę popracować ... Jest to trochę kłopotliwe, a ponadto znalazłem pliki o innej składni JS. Zwłaszcza w plikach jsx.
eAbi

2
@Sean: W ogóle nie uważam tego za problem. Przepływ danych jest o zapisywanie danych, nie czytając go. Jasne, że najlepiej jest, gdy działania są niezależne od sklepów, ale myślę, że w celu optymalizacji żądań czytanie ze sklepów jest w porządku. W końcu komponenty czytają ze sklepów i uruchamiają te akcje. Możesz powtórzyć tę logikę w każdym elemencie, ale po to jest twórca akcji ...
Dan Abramov

27

Tak więc w Reflux usunięto pojęcie Dispatchera i wystarczy pomyśleć o przepływie danych przez akcje i magazyny. To znaczy

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

Każda strzałka modeluje sposób odsłuchiwania przepływu danych, co z kolei oznacza, że ​​dane płyną w przeciwnym kierunku. Rzeczywista liczba przepływu danych jest następująca:

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

W twoim przypadku, jeśli dobrze zrozumiałem, potrzebujemy openUserProfileakcji, która zainicjuje ładowanie profilu użytkownika i przełączanie strony, a także niektóre akcje ładowania postów, które będą ładować posty, gdy strona profilu użytkownika jest otwarta i podczas zdarzenia nieskończonego przewijania. Wyobrażam sobie więc, że w aplikacji mamy następujące magazyny danych:

  • Magazyn danych stron, który obsługuje przełączanie stron
  • Magazyn danych profili użytkownika, który ładuje profil użytkownika po otwarciu strony
  • Magazyn danych z listą postów, który ładuje i obsługuje widoczne posty

W Reflux skonfigurowałbyś to w ten sposób:

Akcje

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

Sklep stron

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

Magazyn profili użytkownika

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

Magazyn postów

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

Części

Zakładam, że masz komponent dla całego widoku strony, strony profilu użytkownika i listy postów. Należy podłączyć następujące elementy:

  • Przyciski, które otwierają profil użytkownika, muszą wywoływać Action.openUserProfilez poprawnym identyfikatorem podczas zdarzenia kliknięcia.
  • Komponent strony powinien nasłuchiwać elementu, currentPageStoreaby wiedział, na którą stronę się przełączyć.
  • Komponent strony profilu użytkownika musi nasłuchiwać, aby currentUserProfileStorewiedzieć, jakie dane profilu użytkownika mają być wyświetlane
  • Lista postów musi nasłuchiwać, currentPostsStoreaby otrzymać załadowane posty
  • Nieskończone zdarzenie przewijania musi wywołać Action.loadMorePosts.

I to powinno być prawie wszystko.


Dzięki za napisanie!
Dan Abramov

2
Może trochę za późno na imprezę, ale tutaj jest fajny artykuł wyjaśniający, dlaczego należy unikać wywoływania API bezpośrednio ze sklepów . Wciąż zastanawiam się, jakie są najlepsze praktyki, ale pomyślałem, że może to pomóc innym natknąć się na to. Istnieje wiele różnych podejść do sklepów.
Thijs Koerselman
Korzystając z naszej strony potwierdzasz, że przeczytałeś(-aś) i rozumiesz nasze zasady używania plików cookie i zasady ochrony prywatności.
Licensed under cc by-sa 3.0 with attribution required.