javascript - estructura - Acciones de colas en Redux
queue javascript (5)
Actualmente tengo una situación en la que necesito que las Acciones Redux se ejecuten consecutivamente. He echado un vistazo a varios middlewares, como una nueva promesa, que parecen estar bien si sabes cuáles son las acciones sucesivas en el punto de la acción raíz (por falta de un término mejor) que se está activando .
Esencialmente, me gustaría mantener una cola de acciones que se pueden agregar en cualquier momento. Cada objeto tiene una instancia de esta cola en su estado y las acciones dependientes se pueden encolar, procesar y anular en consecuencia. Tengo una implementación, pero al hacerlo, estoy accediendo al estado en mis creadores de acciones, lo que parece un antipatrón.
Intentaré dar un poco de contexto sobre el caso de uso y la implementación.
Caso de uso
Supongamos que desea crear algunas listas y mantenerlas en un servidor. En la creación de la lista, el servidor responde con un ID para esa lista, que se utiliza en los puntos finales de API correspondientes a la lista:
http://my.api.com/v1.0/lists/ // POST returns some id
http://my.api.com/v1.0/lists/<id>/items // API end points include id
Imagine que el cliente desea realizar actualizaciones optimistas en estos puntos API, para mejorar la experiencia de usuario, a nadie le gusta mirar a los giradores. Entonces, cuando creas una lista, tu nueva lista aparece instantáneamente, con una opción en agregar elementos:
+-------------+----------+
| List Name | Actions |
+-------------+----------+
| My New List | Add Item |
+-------------+----------+
Supongamos que alguien intenta agregar un elemento antes de que la respuesta de la llamada de creación inicial haya regresado. La API de elementos depende de la ID, por lo que sabemos que no podemos llamarla hasta que tengamos esos datos. Sin embargo, es posible que queramos mostrar de manera optimista el nuevo elemento y poner en cola una llamada a la API de elementos para que se active una vez que se realice la creación de la llamada.
Una solución potencial
El método que estoy usando para solucionar esto actualmente es dar a cada lista una cola de acciones, es decir, una lista de acciones de Redux que se activarán en sucesión.
La funcionalidad del reductor para la creación de una lista podría tener este aspecto:
case ADD_LIST:
return {
id: undefined, // To be filled on server response
name: action.payload.name,
actionQueue: []
}
Luego, en un creador de acciones, pondríamos en cola una acción en lugar de desencadenarla directamente:
export const createListItem = (name) => {
return (dispatch) => {
dispatch(addList(name)); // Optimistic action
dispatch(enqueueListAction(name, backendCreateListAction(name));
}
}
Para abreviar, supongamos que la función backendCreateListAction llama a una API de recuperación, que envía mensajes a la cola de la lista en caso de éxito / fracaso.
El problema
Lo que me preocupa aquí es la implementación del método enqueueListAction. Aquí es donde accedo al estado para controlar el avance de la cola. Parece algo como esto (ignora esta coincidencia en el nombre; en realidad utiliza un ID de cliente en realidad, pero estoy tratando de mantener el ejemplo simple):
const enqueueListAction = (name, asyncAction) => {
return (dispatch, getState) => {
const state = getState();
dispatch(enqueue(name, asyncAction));{
const thisList = state.lists.find((l) => {
return l.name == name;
});
// If there''s nothing in the queue then process immediately
if (thisList.actionQueue.length === 0) {
asyncAction(dispatch);
}
}
}
Aquí, supongamos que el método de puesta en cola devuelve una acción simple que inserta una acción asíncrona en la lista actionQueue.
Todo se siente un poco en contra del grano, pero no estoy seguro de que haya otra forma de hacerlo. Además, como necesito enviar mis asyncActions, debo pasarles el método de envío.
Hay un código similar en el método para sacar de la lista, lo que desencadena la siguiente acción en caso de existir:
const dequeueListAction = (name) => {
return (dispatch, getState) => {
dispatch(dequeue(name));
const state = getState();
const thisList = state.lists.find((l) => {
return l.name === name;
});
// Process next action if exists.
if (thisList.actionQueue.length > 0) {
thisList.actionQueue[0].asyncAction(dispatch);
}
}
En general, puedo vivir con esto, pero me preocupa que sea un antipatrón y que pueda haber una forma más concisa e idiomática de hacer esto en Redux.
Cualquier ayuda es apreciada.
Así es como abordaría este problema:
Asegúrese de que cada lista local tenga un identificador único. No estoy hablando de la ID de backend aquí. ¿Probablemente el nombre no es suficiente para identificar una lista? Una lista "optimista" aún no persistente debe ser identificable de manera única, y el usuario puede intentar crear 2 listas con el mismo nombre, incluso si se trata de un caso de borde.
En la creación de la lista, agregue una promesa de ID de backend a un caché
CreatedListIdPromiseCache[localListId] = createBackendList({...}).then(list => list.id);
En el elemento Agregar, intente obtener el ID de backend de la tienda Redux. Si no existe, intente obtenerlo de CreatedListIdCache
. El id devuelto debe ser asíncrono porque CreatedListIdCache devuelve una promesa.
const getListIdPromise = (localListId,state) => {
// Get id from already created list
if ( state.lists[localListId] ) {
return Promise.resolve(state.lists[localListId].id)
}
// Get id from pending list creations
else if ( CreatedListIdPromiseCache[localListId] ) {
return CreatedListIdPromiseCache[localListId];
}
// Unexpected error
else {
return Promise.reject(new Error("Unable to find backend list id for list with local id = " + localListId));
}
}
Use este método en su addItem
, para que su addItem se retrase automáticamente hasta que la ID de back-end esté disponible
// Create item, but do not attempt creation until we are sure to get a backend id
const backendListItemPromise = getListIdPromise(localListId,reduxState).then(backendListId => {
return createBackendListItem(backendListId, itemData);
})
// Provide user optimistic feedback even if the item is not yet added to the list
dispatch(addListItemOptimistic());
backendListItemPromise.then(
backendListItem => dispatch(addListItemCommit()),
error => dispatch(addListItemRollback())
);
Es posible que desee limpiar CreatedListIdPromiseCache, pero probablemente no sea muy importante para la mayoría de las aplicaciones a menos que tenga requisitos de uso de memoria muy estrictos.
Otra opción sería que la ID de back-end se computa en el frontend, con algo como UUID. Tu backend solo necesita verificar la unicidad de este id. Por lo tanto, siempre tendría un ID de backend válido para todas las listas creadas de manera optimista, incluso si el backend no respondiera todavía.
Eche un vistazo a esto: https://github.com/gaearon/redux-thunk
La identificación sola no debe pasar por el reductor. En su creador de acciones (thunk), primero busque el ID de la lista y luego () realice una segunda llamada para agregar el elemento a la lista. Después de esto, puede enviar diferentes acciones según si la adición fue exitosa o no.
Puede enviar varias acciones mientras hace esto, para informar cuando la interacción del servidor ha comenzado y finalizado. Esto le permitirá mostrar un mensaje o una rueda giratoria, en caso de que la operación sea pesada y pueda tomar un tiempo.
Puede encontrar un análisis más detallado aquí: http://redux.js.org/docs/advanced/AsyncActions.html
Todo el crédito a Dan Abramov
Me enfrentaba a un problema similar al tuyo. Necesitaba una cola para garantizar que las acciones optimistas se comprometían o eventualmente se comprometían (en caso de problemas de red) al servidor remoto en el mismo orden secuencial en que se creaban, o si no era posible revertirlas. Descubrí que solo con Redux, esto no funciona, básicamente porque creo que no fue diseñado para esto y hacerlo solo con promesas puede ser realmente un problema difícil de razonar, además del hecho de que necesita administrar su estado de cola de alguna manera. .. EN MI HUMILDE OPINIÓN.
Creo que la sugerencia de @ Pcriulan sobre el uso de redux-saga fue buena. A primera vista, redux-saga no proporciona nada para ayudarte hasta que llegas a los channels . Esto le abre una puerta para lidiar con la concurrencia de otras maneras en que lo hacen otros idiomas, específicamente CSP (vea Go o Clojure''s async, por ejemplo), gracias a los generadores JS. Incluso hay questions sobre por qué lleva el nombre del patrón Saga y no CSP jaja ... de todos modos.
Así que aquí es cómo una saga podría ayudarte con tu cola:
export default function* watchRequests() {
while (true) {
// 1- Create a channel for request actions
const requestChan = yield actionChannel(''ASYNC_ACTION'');
let resetChannel = false;
while (!resetChannel) {
// 2- take from the channel
const action = yield take(requestChan);
// 3- Note that we''re using a blocking call
resetChannel = yield call(handleRequest, action);
}
}
}
function* handleRequest({ asyncAction, payload }) {
while (true) {
try {
// Perform action
yield call(asyncAction, payload);
return false;
} catch(e) {
if(e instanceof ConflictError) {
// Could be a rollback or syncing again with server?
yield put({ type: ''ROLLBACK'', payload });
// Store is out of consistency so
// don''t let waiting actions come through
return true;
} else if(e instanceof ConnectionError) {
// try again
yield call(delay, 2000);
}
}
}
}
Entonces, la parte interesante aquí es cómo el canal actúa como un búfer (una cola) que sigue "escuchando" las acciones entrantes pero no continuará con las acciones futuras hasta que termine con la actual. Es posible que deba revisar su documentación para comprender mejor el código, pero creo que vale la pena. La parte del canal de restablecimiento podría o no funcionar para sus necesidades: pensando:
¡Espero eso ayude!
No tienes que lidiar con las acciones de cola. Ocultará el flujo de datos y hará que su aplicación sea más tediosa para depurar.
Le sugiero que use algunos identificadores temporales cuando cree una lista o un elemento y luego actualice esos identificadores cuando reciba los reales de la tienda.
Algo como esto tal vez? (No lo hayas probado, pero obtienes el ID):
EDITAR : Al principio no entendía que los elementos debían guardarse automáticamente cuando se guardaba la lista. Edité el creador de acción createList
.
/* REDUCERS & ACTIONS */
// this "thunk" action creator is responsible for :
// - creating the temporary list item in the store with some
// generated unique id
// - dispatching the action to tell the store that a temporary list
// has been created (optimistic update)
// - triggering a POST request to save the list in the database
// - dispatching an action to tell the store the list is correctly
// saved
// - triggering a POST request for saving items related to the old
// list id and triggering the correspondant receiveCreatedItem
// action
const createList = (name) => {
const tempList = {
id: uniqueId(),
name
}
return (dispatch, getState) => {
dispatch(tempListCreated(tempList))
FakeListAPI
.post(tempList)
.then(list => {
dispatch(receiveCreatedList(tempList.id, list))
// when the list is saved we can now safely
// save the related items since the API
// certainly need a real list ID to correctly
// save an item
const itemsToSave = getState().items.filter(item => item.listId === tempList.id)
for (let tempItem of itemsToSave) {
FakeListItemAPI
.post(tempItem)
.then(item => dispatch(receiveCreatedItem(tempItem.id, item)))
}
)
}
}
const tempListCreated = (list) => ({
type: ''TEMP_LIST_CREATED'',
payload: {
list
}
})
const receiveCreatedList = (oldId, list) => ({
type: ''RECEIVE_CREATED_LIST'',
payload: {
list
},
meta: {
oldId
}
})
const createItem = (name, listId) => {
const tempItem = {
id: uniqueId(),
name,
listId
}
return (dispatch) => {
dispatch(tempItemCreated(tempItem))
}
}
const tempItemCreated = (item) => ({
type: ''TEMP_ITEM_CREATED'',
payload: {
item
}
})
const receiveCreatedItem = (oldId, item) => ({
type: ''RECEIVE_CREATED_ITEM'',
payload: {
item
},
meta: {
oldId
}
})
/* given this state shape :
state = {
lists: {
ids: [ ''list1ID'', ''list2ID'' ],
byId: {
''list1ID'': {
id: ''list1ID'',
name: ''list1''
},
''list2ID'': {
id: ''list2ID'',
name: ''list2''
},
}
...
},
items: {
ids: [ ''item1ID'',''item2ID'' ],
byId: {
''item1ID'': {
id: ''item1ID'',
name: ''item1'',
listID: ''list1ID''
},
''item2ID'': {
id: ''item2ID'',
name: ''item2'',
listID: ''list2ID''
}
}
}
}
*/
// Here i''m using a immediately invoked function just
// to isolate ids and byId variable to avoid duplicate
// declaration issue since we need them for both
// lists and items reducers
const lists = (() => {
const ids = (ids = [], action = {}) => ({
switch (action.type) {
// when receiving the temporary list
// we need to add the temporary id
// in the ids list
case ''TEMP_LIST_CREATED'':
return [...ids, action.payload.list.id]
// when receiving the real list
// we need to remove the old temporary id
// and add the real id instead
case ''RECEIVE_CREATED_LIST'':
return ids
.filter(id => id !== action.meta.oldId)
.concat([action.payload.list.id])
default:
return ids
}
})
const byId = (byId = {}, action = {}) => ({
switch (action.type) {
// same as above, when the the temp list
// gets created we store it indexed by
// its temp id
case ''TEMP_LIST_CREATED'':
return {
...byId,
[action.payload.list.id]: action.payload.list
}
// when we receive the real list we first
// need to remove the old one before
// adding the real list
case ''RECEIVE_CREATED_LIST'': {
const {
[action.meta.oldId]: oldList,
...otherLists
} = byId
return {
...otherLists,
[action.payload.list.id]: action.payload.list
}
}
}
})
return combineReducers({
ids,
byId
})
})()
const items = (() => {
const ids = (ids = [], action = {}) => ({
switch (action.type) {
case ''TEMP_ITEM_CREATED'':
return [...ids, action.payload.item.id]
case ''RECEIVE_CREATED_ITEM'':
return ids
.filter(id => id !== action.meta.oldId)
.concat([action.payload.item.id])
default:
return ids
}
})
const byId = (byId = {}, action = {}) => ({
switch (action.type) {
case ''TEMP_ITEM_CREATED'':
return {
...byId,
[action.payload.item.id]: action.payload.item
}
case ''RECEIVE_CREATED_ITEM'': {
const {
[action.meta.oldId]: oldList,
...otherItems
} = byId
return {
...otherItems,
[action.payload.item.id]: action.payload.item
}
}
// when we receive a real list
// we need to reappropriate all
// the items that are referring to
// the old listId to the new one
case ''RECEIVE_CREATED_LIST'': {
const oldListId = action.meta.oldId
const newListId = action.payload.list.id
const _byId = {}
for (let id of Object.keys(byId)) {
let item = byId[id]
_byId[id] = {
...item,
listId: item.listId === oldListId ? newListId : item.listId
}
}
return _byId
}
}
})
return combineReducers({
ids,
byId
})
})()
const reducer = combineReducers({
lists,
items
})
/* REDUCERS & ACTIONS */
Tengo la herramienta perfecta para lo que buscas. Cuando necesitas mucho control sobre el redux, (especialmente algo asíncrono) y necesitas que las acciones de redux se realicen secuencialmente, no hay mejor herramienta que Redux Sagas . Está construido sobre los generadores de es6 y le da mucho control, ya que, en cierto sentido, puede pausar su código en ciertos puntos.
La cola de acción que describe es lo que se llama una saga . Ahora que está creado para funcionar con redux, estas sagas se pueden activar para que se ejecuten mediante el envío de sus componentes.
Dado que Sagas utiliza generadores, también puede asegurarse con certeza de que sus despachos se realizan en un orden específico y solo ocurren bajo ciertas condiciones. Aquí hay un ejemplo de su documentación y lo guiaré para ilustrar lo que quiero decir:
function* loginFlow() {
while (true) {
const {user, password} = yield take(''LOGIN_REQUEST'')
const token = yield call(authorize, user, password)
if (token) {
yield call(Api.storeItem, {token})
yield take(''LOGOUT'')
yield call(Api.clearItem, ''token'')
}
}
}
Muy bien, parece un poco confuso al principio, pero esta saga define el orden exacto en que debe suceder una secuencia de inicio de sesión. El bucle infinito está permitido debido a la naturaleza de los generadores. Cuando su código llegue a un rendimiento , se detendrá en esa línea y esperará. No continuará hasta la siguiente línea hasta que se lo diga. Así que mire donde dice yield take(''LOGIN_REQUEST'')
. La saga cederá o esperará en este punto hasta que despaches ''LOGIN_REQUEST'', después de lo cual la saga llamará al método de autorización, y continuará hasta el siguiente rendimiento. El siguiente método es una yield call(Api.storeItem, {token})
asíncrono yield call(Api.storeItem, {token})
por lo que no pasará a la siguiente línea hasta que el código se resuelva.
Ahora, aquí es donde ocurre la magia. La saga se detendrá de nuevo en la yield take(''LOGOUT'')
hasta que envíe LOGOUT en su aplicación. Esto es crucial ya que si tuviera que enviar LOGIN_REQUEST nuevamente antes de LOGOUT, el proceso de inicio de sesión no se invocará. Ahora, si envía LOGOUT, volverá al primer rendimiento y esperará a que la aplicación envíe LOGIN_REQUEST nuevamente.
Las Sagas Redux son, por lejos, una de mis herramientas favoritas para usar con Redux. Te da tanto control sobre tu aplicación y cualquiera que lea tu código te lo agradecerá, ya que todo ahora lee una línea a la vez.