En la arquitectura Flux, ¿cómo gestiona el ciclo de vida de la tienda?

132

Estoy leyendo sobre Flux, pero el ejemplo de la aplicación Todo es demasiado simplista para entender algunos puntos clave.

Imagine una aplicación de una sola página como Facebook que tiene páginas de perfil de usuario . En cada página de perfil de usuario, queremos mostrar cierta información del usuario y sus últimas publicaciones, con desplazamiento infinito. Podemos navegar de un perfil de usuario a otro.

En la arquitectura Flux, ¿cómo correspondería esto a las tiendas y despachadores?

¿Usaríamos uno PostStorepor usuario, o tendríamos algún tipo de tienda global? ¿Qué pasa con los despachadores? ¿Creamos un nuevo despachador para cada “página de usuario” o usaríamos un singleton? Finalmente, ¿qué parte de la arquitectura es responsable de administrar el ciclo de vida de las tiendas "específicas de la página" en respuesta al cambio de ruta?

Además, una sola pseudopágina puede tener varias listas de datos del mismo tipo. Por ejemplo, en una página de perfil, quiero mostrar ambos Seguidores y Sigue . ¿Cómo puede funcionar un singleton UserStoreen este caso? Lo UserPageStoregestionaría followedBy: UserStorey follows: UserStore?

Dan Abramov
fuente

Respuestas:

124

En una aplicación Flux solo debe haber un Despachador. Todos los datos fluyen a través de este concentrador central. Tener un Dispatcher singleton le permite administrar todas las tiendas. Esto se vuelve importante cuando necesita la actualización de la Tienda n. ° 1, y luego hacer que la Tienda n. ° 2 se actualice en sí misma según la Acción y el estado de la Tienda n. ° 1. Flux asume que esta situación es una eventualidad en una aplicación grande. Idealmente, esta situación no debería ocurrir, y los desarrolladores deberían esforzarse por evitar esta complejidad, si es posible. Pero el Singleton Dispatcher está listo para manejarlo cuando llegue el momento.

Las tiendas también son singletons. Deben permanecer tan independientes y desacoplados como sea posible: un universo autónomo que se puede consultar desde una Vista de controlador. El único camino hacia la Tienda es a través de la devolución de llamada que registra con el Despachador. El único camino es a través de las funciones getter. Las tiendas también publican un evento cuando su estado ha cambiado, por lo que Controller-Views puede saber cuándo consultar el nuevo estado, utilizando los captadores.

En su aplicación de ejemplo, habría una sola PostStore. Esta misma tienda podría administrar las publicaciones en una "página" (pseudo-página) que es más como Newsfeed de FB, donde las publicaciones aparecen de diferentes usuarios. Su dominio lógico es la lista de publicaciones, y puede manejar cualquier lista de publicaciones. Cuando pasamos de pseudopágina a pseudopágina, queremos reinicializar el estado de la tienda para reflejar el nuevo estado. También es posible que deseemos almacenar en caché el estado anterior en localStorage como una optimización para moverse de un lado a otro entre pseudo páginas, pero mi inclinación sería configurar una PageStoreque espere a todas las demás tiendas, gestione la relación con localStorage para todas las tiendas en la pseudo-página, y luego actualiza su propio estado. Tenga en cuenta que esto PageStoreno almacenaría nada sobre las publicaciones, ese es el dominio de laPostStore. Simplemente sabría si una pseudo-página en particular ha sido almacenada en caché o no, porque las pseudo-páginas son su dominio.

El PostStoretendría un initialize()método. Este método siempre borraría el estado anterior, incluso si esta es la primera inicialización, y luego crearía el estado basado en los datos que recibió a través de la Acción, a través del Despachador. Pasar de una pseudopágina a otra probablemente implicaría una PAGE_UPDATEacción, que desencadenaría la invocación de initialize(). Hay detalles para resolver la recuperación de datos de la memoria caché local, la recuperación de datos del servidor, la representación optimista y los estados de error XHR, pero esta es la idea general.

Si una pseudopágina en particular no necesita todas las tiendas en la aplicación, no estoy completamente seguro de que haya alguna razón para destruir las que no se utilizan, aparte de las restricciones de memoria. Pero las tiendas no suelen consumir una gran cantidad de memoria. Solo debe asegurarse de eliminar los oyentes de eventos en las Vistas del controlador que está destruyendo. Esto se hace en el componentWillUnmount()método React .

fisherwebdev
fuente
55
Ciertamente, hay algunos enfoques diferentes de lo que desea hacer, y creo que depende de lo que esté tratando de construir. Un enfoque sería un UserListStore, con todos los usuarios relevantes en él. Y cada usuario tendría un par de banderas booleanas que describen la relación con el perfil de usuario actual. Algo como { follower: true, followed: false }, por ejemplo. Los métodos getFolloweds()y getFollowers()recuperarían los diferentes conjuntos de usuarios que necesita para la interfaz de usuario.
fisherwebdev
44
Alternativamente, podría tener un FollowedUserListStore y un FollowerUserListStore que ambos heredan de un UserListStore abstracto.
fisherwebdev
Tengo una pequeña pregunta: ¿por qué no usar pub sub para emitir datos de las tiendas directamente en lugar de requerir que los suscriptores recuperen los datos?
sunwukung
2
@sunwukung Esto requeriría que las tiendas realicen un seguimiento de qué vistas de controlador necesitan qué datos. Es más limpio que las tiendas publiquen el hecho de que han cambiado de alguna manera, y luego permiten que las vistas de controlador interesadas recuperen qué partes de los datos necesitan.
fisherwebdev
¿Qué sucede si tengo una página de perfil donde muestro información sobre un usuario pero también una lista de sus amigos? Tanto el usuario como los amigos serían del mismo tipo. ¿Deberían quedarse en la misma tienda si es así?
Nick Dima
79

(Nota: he usado la sintaxis ES6 usando la opción JSX Harmony).

Como ejercicio, escribí una aplicación Flux de muestra que permite navegar Github usersy reposicionar.
Se basa en la respuesta de fisherwebdev, pero también refleja un enfoque que uso para normalizar las respuestas API.

Logré documentar algunos enfoques que probé mientras aprendía Flux.
Traté de mantenerlo cerca del mundo real (paginación, sin API de almacenamiento local falsas).

Aquí hay algunos bits en los que estaba especialmente interesado:

Cómo clasifico las tiendas

Traté de evitar parte de la duplicación que he visto en otro ejemplo de Flux, específicamente en las tiendas. Me pareció útil dividir lógicamente las tiendas en tres categorías:

Las tiendas de contenido contienen todas las entidades de la aplicación. Todo lo que tiene una ID necesita su propio Content Store. Los componentes que representan elementos individuales solicitan a los Almacenes de contenido los datos nuevos.

Los almacenes de contenido cosechan sus objetos de todas las acciones del servidor. Por ejemplo, UserStore analizaaction.response.entities.users si existe independientemente de la acción que se active. No hay necesidad de a switch. Normalizr facilita aplanar cualquier respuesta API a este formato.

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

Los almacenes de listas realizan un seguimiento de los ID de las entidades que aparecen en alguna lista global (por ejemplo, "feed", "sus notificaciones"). En este proyecto, no tengo tales tiendas, pero pensé en mencionarlas de todos modos. Manejan la paginación.

Normalmente responden a unas pocas acciones (p REQUEST_FEED. Ej . REQUEST_FEED_SUCCESS, REQUEST_FEED_ERROR).

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

Los almacenes de listas indexadas son como los almacenes de listas, pero definen una relación de uno a muchos. Por ejemplo, "suscriptores del usuario", "observadores de estrellas del repositorio", "repositorios del usuario". También manejan la paginación.

También responden normalmente a unas pocas acciones (por ejemplo REQUEST_USER_REPOS, REQUEST_USER_REPOS_SUCCESS, REQUEST_USER_REPOS_ERROR).

En la mayoría de las aplicaciones sociales, tendrá muchas de estas y desea poder crear rápidamente una más.

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

Nota: estas no son clases reales o algo así; es justo como me gusta pensar en las tiendas. Aunque hice algunos ayudantes.

StoreUtils

createStore

Este método le brinda la Tienda más básica:

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 para crear todas las tiendas.

isInBag, mergeIntoBag

Pequeños ayudantes útiles para 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

Almacena el estado de paginación y aplica ciertas afirmaciones (no se puede recuperar la página mientras se recupera, 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

Hace que la creación de almacenes de listas indexadas sea lo más simple posible al proporcionar métodos repetitivos y manejo de acciones:

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 que permite que los componentes sintonicen las tiendas que les interesan, por ejemplo 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
fuente
1
Dado el hecho de que ha escrito Stampsy, si reescribiera toda la aplicación del lado del cliente, ¿utilizaría FLUX y la misma aplicación que utilizó para crear esta aplicación de ejemplo?
eAbi
2
eAbi: este es el enfoque que estamos utilizando actualmente, ya que estamos reescribiendo Stampsy en Flux (con la esperanza de lanzarlo el próximo mes). No es ideal, pero funciona bien para nosotros. Cuando / si descubrimos mejores formas de hacer esas cosas, las compartiremos.
Dan Abramov
1
eAbi: Sin embargo, ya no usamos normalizr porque un chico de nuestro equipo reescribió todo nuestras API para devolver respuestas normalizadas. Sin embargo, fue útil antes de que eso se hiciera.
Dan Abramov
Gracias por tu información. Revisé su repositorio de Github y estoy tratando de comenzar un proyecto (construido en YUI3) con su enfoque, pero tengo algunos problemas para compilar el código (si puede decirlo). No estoy ejecutando el servidor en el nodo, así que quería copiar la fuente en mi directorio estático, pero todavía tengo que hacer algo de trabajo ... Es un poco engorroso y, además, encontré algunos archivos con diferente sintaxis JS. Especialmente en archivos jsx.
eAbi
2
@Sean: No lo veo como un problema en absoluto. El flujo de datos se trata de escribir datos, no de leerlos. Claro que es mejor si las acciones son independientes de las tiendas, pero para optimizar las solicitudes, creo que está perfectamente bien leer en las tiendas. Después de todo, los componentes leen de las tiendas y disparan esas acciones. Podría repetir esta lógica en cada componente, pero para eso está el creador de acciones.
Dan Abramov
27

Entonces, en Reflux, el concepto de Dispatcher se elimina y solo necesita pensar en términos de flujo de datos a través de acciones y tiendas. Es decir

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

Cada flecha aquí modela cómo se escucha el flujo de datos, lo que a su vez significa que los datos fluyen en la dirección opuesta. La cifra real para el flujo de datos es la siguiente:

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

En su caso de uso, si entendí correctamente, necesitamos una openUserProfileacción que inicie la carga del perfil del usuario y cambie la página y también algunas acciones de carga de publicaciones que cargarán las publicaciones cuando se abra la página del perfil del usuario y durante el evento de desplazamiento infinito. Así que me imagino que tenemos los siguientes almacenes de datos en la aplicación:

  • Un almacén de datos de página que maneja el cambio de páginas.
  • Un almacén de datos de perfil de usuario que carga el perfil de usuario cuando se abre la página
  • Un almacén de datos de lista de publicaciones que carga y maneja las publicaciones visibles

En Reflux lo configuraría así:

Las acciones

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

La tienda de la página

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

La tienda de perfil de usuario

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

La tienda de publicaciones

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

Los componentes

Supongo que tiene un componente para la vista de página completa, la página de perfil de usuario y la lista de publicaciones. Lo siguiente necesita ser conectado:

  • Los botones que abren el perfil de usuario deben invocar el Action.openUserProfile con la identificación correcta durante su evento de clic.
  • El componente de la página debe estar escuchando currentPageStore que sepa a qué página cambiar.
  • El componente de la página de perfil de usuario debe escuchar el currentUserProfileStore saber qué datos de perfil de usuario mostrar
  • La lista de publicaciones necesita escuchar el currentPostsStore para recibir las publicaciones cargadas.
  • El evento de desplazamiento infinito necesita llamar al Action.loadMorePosts.

Y eso debería ser todo.

Spoike
fuente
Gracias por el reportaje!
Dan Abramov
2
Tal vez un poco tarde para la fiesta, pero aquí hay un buen artículo que explica por qué evitar llamar a su API directamente desde las tiendas . Todavía estoy descubriendo cuáles son las mejores prácticas, pero pensé que podría ayudar a otros tropezar con esto. Hay muchos enfoques diferentes flotando con respecto a las tiendas.
Thijs Koerselman