web-dev-qa-db-fra.com

Actions en attente dans Redux

J'ai actuellement une situation dans laquelle j'ai besoin que les Actions Redux soient exécutées de manière consécutive. J'ai jeté un coup d'œil à divers middlewares, comme Redux-Promise, qui semblent aller très bien si vous connaissez les actions successives qui sont à la base (faute d'un meilleur terme), l'action est déclenchée .

Pour l’essentiel, je souhaite conserver une file d’actions pouvant être ajoutées à tout moment. Chaque objet a une instance de cette file d'attente dans son état et les actions dépendantes peuvent être mises en file d'attente, traitées et mises en file d'attente en conséquence. J'ai une implémentation, mais ce faisant, j'accède à l'état dans mes créateurs d'action, ce qui semble être un anti-motif.

Je vais essayer de donner un contexte sur le cas d'utilisation et la mise en œuvre.

Cas d'utilisation

Supposons que vous souhaitiez créer des listes et les conserver sur un serveur. Lors de la création de la liste, le serveur répond avec un identifiant pour cette liste, qui est utilisé dans les points de terminaison d'API subséquents appartenant à la liste:

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

Imaginez que le client souhaite effectuer des mises à jour optimistes sur ces points d'API, afin d'améliorer l'UX - personne n'aime regarder les fileurs. Ainsi, lorsque vous créez une liste, votre nouvelle liste apparaît instantanément, avec une option permettant d'ajouter des éléments:

+-------------+----------+
|  List Name  | Actions  |
+-------------+----------+
| My New List | Add Item |
+-------------+----------+

Supposons que quelqu'un tente d'ajouter un élément avant que la réponse de l'appel de création initial ne l'ait renvoyée. L'API des éléments dépend de l'id. Nous savons donc que nous ne pouvons pas l'appeler tant que nous n'aurons pas ces données. Cependant, nous pouvons vouloir afficher le nouvel élément avec optimisme et mettre en file d'attente un appel à l'API des éléments afin qu'il se déclenche une fois l'appel de création terminé.

Une solution potentielle

La méthode que j'utilise actuellement pour contourner ce problème consiste à attribuer à chaque liste une file d'attente d'actions, c'est-à-dire une liste d'actions Redux qui seront déclenchées successivement.

La fonctionnalité de réduction pour la création d'une liste pourrait ressembler à ceci:

case ADD_LIST:
  return {
    id: undefined, // To be filled on server response
    name: action.payload.name,
    actionQueue: []
  }

Ensuite, dans un créateur d'action, nous mettions en file d'attente une action au lieu de la déclencher directement:

export const createListItem = (name) => {
    return (dispatch) => {
        dispatch(addList(name));  // Optimistic action
        dispatch(enqueueListAction(name, backendCreateListAction(name));
    }
}

Par souci de brièveté, supposons que la fonction backendCreateListAction appelle une API d'extraction, qui distribue les messages à partir de la liste en cas de succès/échec.

Le problème

Ce qui m'inquiète ici, c'est l'implémentation de la méthode enqueueListAction. C’est là que j’accède à l’état pour régir l’avancement de la file d’attente. Cela ressemble à quelque chose comme ceci (ignorer cette correspondance sur le nom - cela utilise en réalité un clientId en réalité, mais j'essaie de garder l'exemple 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);
        } 
    }
}

Ici, supposons que la méthode de mise en file d'attente renvoie une action en clair qui insère une action asynchrone dans les listes actionQueue. 

Tout cela semble aller un peu à contre-courant, mais je ne suis pas sûr qu'il y ait une autre façon de procéder. De plus, comme je dois envoyer mes actions asynchrones, je dois leur transmettre la méthode d'envoi.

Il existe un code similaire dans la méthode pour sortir de la liste de la file d'attente, ce qui déclenche l'action suivante, le cas échéant:

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

De manière générale, je peux vivre avec cela, mais je crains que ce ne soit une tendance anti-modèle et qu'il y ait peut-être une manière plus concise et idiomatique de procéder de la sorte dans Redux.

Toute aide est appréciée.

31
MrHutch

J'ai l'outil idéal pour ce que vous recherchez. Lorsque vous avez besoin de beaucoup de contrôle sur redux (en particulier tout ce qui est asynchrone) et que les actions de redux doivent se dérouler de manière séquentielle, il n'y a pas de meilleur outil que Redux Sagas . Il est construit sur le dessus de générateurs es6 vous donnant beaucoup de contrôle car vous pouvez, en un sens, suspendre votre code à certains moments. 

La file d'attente action que vous décrivez s'appelle une saga. Maintenant, comme il est créé pour fonctionner avec redux, ces sagas peuvent être déclenchées par la distribution de vos composants.

Puisque les Sagas utilisent des générateurs, vous pouvez également vous assurer que vos envois se déroulent dans un ordre spécifique et dans certaines conditions seulement. Voici un exemple de leur documentation et je vous guiderai pour illustrer ce que je veux dire:

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

D'accord, cela semble un peu déroutant au début, mais cette saga définit l'ordre exact dans lequel une séquence de connexion doit se produire. La boucle infinie est autorisée en raison de la nature des générateurs. Lorsque votre code arrive à un yield, il s'arrête sur cette ligne et attend. Il ne continuera pas jusqu'à la ligne suivante tant que vous ne le lui aurez pas dit. Alors regardez où il est écrit yield take('LOGIN_REQUEST'). La saga cédera ou attendra à ce stade jusqu'à ce que vous envoyiez 'LOGIN_REQUEST' après quoi la saga appellera la méthode authorize et ira jusqu'au prochain rendement. La méthode suivante est une yield call(Api.storeItem, {token}) asynchrone, elle ne passera donc pas à la ligne suivante tant que ce code n'aura pas été résolu. 

Maintenant, c'est là que la magie se produit. La saga s’arrêtera à yield take('LOGOUT') jusqu’à ce que vous envoyiez LOGOUT dans votre application. Ceci est crucial car si vous expédiez à nouveau LOGIN_REQUEST avant LOGOUT, le processus de connexion ne sera pas appelé. Maintenant, si vous envoyez LOGOUT, il sera renvoyé au premier rendement et attendra que l'application envoie LOGIN_REQUEST encore.

Les Sagas Redux sont, de loin, l’un de mes outils préférés à utiliser avec Redux. Cela vous donne tellement de contrôle sur votre application et tous ceux qui liront votre code vous en remercieront puisque tout se lit désormais ligne par ligne.

2
EJ Mason

Regardez ceci: https://github.com/gaearon/redux-thunk

L'identifiant seul ne devrait pas passer par le réducteur. Dans votre créateur d'action (thunk), récupérez d'abord l'identifiant de la liste et then () effectuez un second appel pour ajouter l'élément à la liste. Après cela, vous pouvez envoyer différentes actions en fonction de la réussite de l’ajout. 

Ce faisant, vous pouvez envoyer plusieurs actions pour signaler le début et la fin de l’interaction du serveur. Cela vous permettra d'afficher un message ou un compteur, au cas où l'opération serait lourde et prendrait un certain temps.

Une analyse plus approfondie peut être trouvée ici: http://redux.js.org/docs/advanced/AsyncActions.html

Tout crédit à Dan Abramov

1
Anthony De Smet

Je faisais face à un problème similaire au vôtre. J'avais besoin d'une file d'attente pour garantir que les actions optimistes étaient validées ou éventuellement validées (en cas de problèmes de réseau) sur le serveur distant dans le même ordre séquentiel de création, ou de restauration si ce n'était pas possible. J'ai trouvé qu'avec Redux seulement, c'est un raccourci pour ceci, essentiellement parce que je crois que ce n'était pas conçu pour cela et que le faire uniquement avec des promesses peut être un problème difficile à raisonner, outre le fait que vous ayez besoin de gérer votre file d'attente. .. A MON HUMBLE AVIS. 

Je pense que la suggestion de @ Pcriulan sur l'utilisation de redux-saga était bonne. À première vue, redux-saga ne fournit rien pour vous aider tant que vous n’avez pas accès à channels . Cela vous ouvre la porte à la gestion de la concurrence des autres langages, notamment CSP (voir Async de Go ou Clojure par exemple), grâce aux générateurs JS. Il y a même des questions sur pourquoi est nommé d'après le modèle Saga et non pas CSP haha ​​... de toute façon.

Voici comment une saga pourrait vous aider avec votre file d'attente:

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

    }
  }
}

Donc, la partie intéressante ici est la façon dont le canal agit comme un tampon (une file) qui continue à "écouter" les actions entrantes mais ne procédera pas aux actions futures tant qu'il n'aura pas fini avec l'action en cours. Vous devrez peut-être revoir leur documentation afin de mieux comprendre le code, mais je pense que cela en vaut la peine. La réinitialisation de la partie de canal peut ou non répondre à vos besoins. Réfléchissez:

J'espère que ça aide!

0
roboli

Vous n'avez pas à faire face à des actions en file d'attente. Cela masquerait le flux de données et rendrait votre application plus fastidieuse à déboguer.

Je vous suggère d'utiliser des identifiants temporaires lors de la création d'une liste ou d'un élément, puis de les mettre à jour lorsque vous recevez les identifiants réels du magasin.

Quelque chose comme ça peut-être? (ne pas testé mais vous obtenez l'identifiant):

EDIT: Je n'avais pas compris au début que les éléments devaient être automatiquement enregistrés lors de l'enregistrement de la liste. J'ai édité le créateur de l'action 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 */
0
Pierre Criulanscy

Voici comment je voudrais aborder ce problème:

Assurez-vous que chaque liste locale a un identifiant unique. Je ne parle pas de l'identifiant du backend ici. Le nom n'est probablement pas suffisant pour identifier une liste? Une liste "optimiste" non encore persistante doit être identifiable de manière unique, et l'utilisateur peut essayer de créer 2 listes portant le même nom, même s'il s'agit d'un cas Edge.

Lors de la création de la liste, ajoutez une promesse d’identifiant backend à un cache

CreatedListIdPromiseCache[localListId] = createBackendList({...}).then(list => list.id);

Sur l'article ajouté, essayez d'obtenir l'id du backend du magasin Redux. S'il n'existe pas, essayez de l'obtenir à partir de CreatedListIdCache. L'ID renvoyé doit être asynchrone car CreatedListIdCache renvoie une promesse.

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

Utilisez cette méthode dans votre addItem, de sorte que votre addItem soit automatiquement retardé jusqu'à ce que l'identifiant du moteur soit 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())
);

Vous pouvez vouloir nettoyer CreatedListIdPromiseCache, mais ce n'est probablement pas très important pour la plupart des applications, sauf si vous avez des exigences très strictes en matière d'utilisation de la mémoire.


Une autre option serait que l'identifiant du backend soit calculé sur le frontal, avec quelque chose comme UUID. Votre back-end doit juste vérifier l'unicité de cet identifiant. Ainsi, vous auriez toujours un identifiant de backend valide pour toutes les listes créées avec optimisme, même si le backend ne répondait pas encore.

0
Sebastien Lorber