javascript - framework - En la arquitectura Flux, ¿cómo gestiona el ciclo de vida de la tienda?
redux angular (3)
(Nota: he utilizado la sintaxis ES6 utilizando la opción JSX Harmony).
Como ejercicio, escribí una aplicación Flux de muestra que permite navegar por los Github users
y repositorios de Github users
.
Se basa en la respuesta de fisherwebdev pero también refleja un enfoque que uso para normalizar las respuestas de la API.
Lo hice para documentar algunos enfoques que he probado mientras aprendía Flux.
Traté de mantenerlo cerca del mundo real (paginación, sin API local de almacenamiento falsas).
Aquí hay algunas partes que me interesan especialmente:
- Utiliza la arquitectura Flux y react-router ;
- Puede mostrar la página del usuario con información parcial conocida y cargar detalles sobre la marcha;
- Es compatible con la paginación tanto para usuarios como para repositorios;
- Analiza las respuestas JSON anidadas de Github con normalizr ;
- Las Tiendas de contenido no necesitan contener un
switch
gigante con acciones ; - "Atrás" es inmediato (porque todos los datos están en Tiendas).
Cómo clasifico las tiendas
Traté de evitar parte de la duplicación que he visto en otros ejemplos de Flux, específicamente en Tiendas. Me pareció útil dividir lógicamente las tiendas en tres categorías:
Las tiendas de contenido tienen todas las entidades de la aplicación. Todo lo que tiene una identificación necesita su propia tienda de contenidos. Los componentes que procesan elementos individuales solicitan a los Almacenes de contenido los datos nuevos.
Las tiendas de contenido recogen sus objetos de todas las acciones del servidor. Por ejemplo, UserStore
examina action.response.entities.users
si existe independientemente de qué acción se action.response.entities.users
. No hay necesidad de un switch
. normalizr facilita aplanar las respuestas de la 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é que las mencionaría de todos modos. Ellos manejan la paginación.
Normalmente responden solo a unas pocas acciones (por ejemplo, REQUEST_FEED_SUCCESS
, REQUEST_FEED_ERROR
, REQUEST_FEED_ERROR
).
// Paginated Stores keep their data like this
[7, 10, 5, ...]
Las tiendas de listas indexadas son como las tiendas de listas pero definen una relación de uno a muchos. Por ejemplo, "suscriptores del usuario", "observadores del repositorio", "repositorios del usuario". También manejan la paginación.
También suelen responder a algunas acciones (por ejemplo, REQUEST_USER_REPOS_SUCCESS
, REQUEST_USER_REPOS_ERROR
, REQUEST_USER_REPOS_ERROR
).
En la mayoría de las aplicaciones sociales, tendrá muchas de estas y desea poder crear una más de ellas rápidamente.
// 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 solo como me gusta pensar en Stores. Aunque hice algunos ayudantes.
StoreUtils
createStore
Este método te 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 tiendas de contenido.
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 e impone ciertas afirmaciones (no puede ir a la página mientras se va a buscar, 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 tiendas 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
Una mezcla que permite a los componentes sintonizar tiendas que les interesan, p. Ej 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;
}
Estoy leyendo sobre Flux pero la aplicación de ejemplo 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 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?
¿ PostStore
una PostStore
por 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 podríamos utilizar 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 tanto Seguidores como Seguimientos . ¿Cómo puede funcionar una UserStore
singleton en este caso? ¿ UserPageStore
administración de UserPageStore
followedBy: UserStore
y follows: UserStore
?
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í muestra 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 esta:
Actions --> Stores --> Components
^ | |
+----------+------------+
En su caso de uso, si entendí correctamente, necesitamos una acción openUserProfile
que inicie el perfil de usuario cargando y cambiando la página y también algunas publicaciones cargando acciones que cargarán publicaciones cuando se abra la página de perfil de usuario y durante el evento de desplazamiento infinito. Entonces me imagino que tenemos las siguientes tiendas 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 la lista de publicaciones que carga y maneja las publicaciones visibles
En Reflux, lo configuraste 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 debe estar conectado:
- Los botones que abren el perfil de usuario necesitan invocar
Action.openUserProfile
con la identificación correcta durante el evento de clic. - El componente de la página debe escuchar el
currentPageStore
para que sepa a qué página cambiar. - El componente de la página de perfil de usuario necesita escuchar
currentUserProfileStore
para que sepa 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 a
Action.loadMorePosts
.
Y eso debería ser más o menos eso.
En una aplicación Flux solo debería haber un Dispatcher. Todos los datos fluyen a través de este centro 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 la tienda n. ° 2 se actualiza en función tanto de la acción como del 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 tendría que suceder, y los desarrolladores deberían esforzarse por evitar esta complejidad, si es posible. Pero el Dispatcher singleton está listo para manejarlo cuando llegue el momento.
Las tiendas también son singletons. Deben permanecer lo más independientes y desacoplados posible: un universo autónomo que se puede consultar desde una Controladora. El único camino hacia la Tienda es a través de la devolución de llamada que registra con el Dispatcher. La única salida es a través de 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 getters.
En su aplicación de ejemplo, habría una sola PostStore
. Esta misma tienda podría gestionar las publicaciones en una "página" (pseudo-página) que se parece más al 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 pseudo-página a pseudo-pá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 avanzar y retroceder entre pseudo-páginas, pero mi inclinación sería configurar una PageStore
que 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 esta PageStore
no almacenaría nada sobre las publicaciones, ese es el dominio de la PostStore
. Simplemente sabría si una pseudopágina particular ha sido almacenada en caché o no, porque las pseudopáginas son su dominio.
PostStore
tendría un método initialize()
. Este método siempre borrará el estado anterior, incluso si esta es la primera inicialización, y luego creará el estado basado en los datos que recibió a través de la Acción, a través del Dispatcher. Pasar de una PAGE_UPDATE
a otra implicaría probablemente una acción PAGE_UPDATE
, 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 pseudo-página particular no necesita todas las tiendas en la aplicación, no estoy del todo seguro de que haya alguna razón para destruir las que no se utilizan, aparte de las limitaciones de memoria. Pero las tiendas no suelen consumir una gran cantidad de memoria. Solo necesita asegurarse de eliminar los detectores de eventos en las Vistas de Controlador que está destruyendo. Esto se hace en el método componentWillUnmount()
React.