35 votes

Actions de mise en file d'attente dans Redux

J'ai actuellement reçu une situation où j'ai besoin de Redux Actions à exécuter consécutivement. J'ai pris un coup d'oeil à différents middlewares, tel un redux-promesse, qui semblent être bien si vous savez ce que les actions successives sont à la pointe de la racine (par manque d'un meilleur terme) de l'action déclenchée.

Essentiellement, je tiens à maintenir une liste d'actions qui peuvent être ajoutés à tout moment. Chaque objet est une instance de cette file d'attente dans son état et dépendant des actions peuvent être mis en file d'attente, le traitement et l'retiré en conséquence. J'ai une mise en œuvre, mais en le faisant, je suis donc d'accéder à l'état de mon action créateurs, qui se sent comme un anti-modèle.

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

Cas D'Utilisation

Supposons que vous voulez créer plusieurs listes et de les conserver sur un serveur. Sur la création d'une liste, le serveur répond avec un id pour cette liste, qui est utilisé dans la suite de l'API fin des points ayant trait à 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 veut effectuer optimiste mises à jour sur ces API points, afin d'améliorer l'UX - personne n'aime regarder les filateurs. Ainsi, lorsque vous créez une liste, votre nouvelle liste s'affiche instantanément, avec une option à ajouter des éléments:

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

Supposons que quelqu'un tente d'ajouter un élément avant la réponse de la création initiale d'appel a fait de retour. Les éléments de l'API dépend de l'id, alors nous savons que nous ne pouvons l'appeler jusqu'à ce que nous ont données. Cependant, on peut vouloir optimiste montrer le nouvel élément et mettre en file d'attente un appel aux éléments de l'API afin qu'il déclenche une fois que créer l'appel est fait.

Une Solution Potentielle

La méthode que j'utilise pour contourner ce problème actuellement est en donnant à chaque liste une action de file d'attente, qui est, une liste de Redux actions qui seront déclenchées dans la succession.

Le réducteur de fonctionnalités pour la création d'une liste pourrait ressembler à quelque chose comme ceci:

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

Puis, dans une action créateur, nous aimerions mettre en file d'attente d'une action au lieu de déclenchement:

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

Par souci de concision, d'assumer la backendCreateListAction appels de fonction d'une extraction de l'API, qui distribue les messages à la file d'attente à partir de la liste sur la réussite/échec.

Le Problème

Ce qui m'inquiète ici est la mise en œuvre de la enqueueListAction méthode. C'est là que je suis accéder à l'état de regler la progression de la file d'attente. Il ressemble à quelque chose comme ceci (ignorer cette mise en correspondance sur le nom - ce qui utilise un clientId dans la 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, nous supposons que la mise en file d'attente méthode renvoie une simple action qui insère une action asynchrone dans les listes actionQueue.

Le tout sent un peu à contre-courant, mais je ne sais pas si il y a un autre chemin pour aller avec elle. En outre, depuis que j'ai besoin pour l'expédition dans ma asyncActions, j'ai besoin de passer à la méthode dispatch vers le bas.

Il y a un code similaire dans la méthode à retirer de la liste, ce qui déclenche l'action suivante existe:

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

Généralement, je peux vivre avec cela, mais je suis préoccupé par le fait que c'est un anti-modèle, et il serait peut-être plus concis, idiomatiques façon de le faire dans Redux.

Toute aide est appréciée.

2voto

EJ Mason Points 718

J'ai l'outil parfait pour ce que vous cherchez. Lorsque vous avez besoin de beaucoup de contrôle sur redux, (en particulier de tout ce asynchrone) et vous avez besoin redux actions pour arriver séquentiellement il n'y a pas de meilleur outil que Redux Sagas. Il est construit sur le haut de l'es6 générateurs de vous donner un beaucoup de contrôle, car vous pouvez, en un sens, mettre en pause votre code à certains points.

L' action de la file d'attente que vous décrivez est ce qu'on appelle une saga. Maintenant, étant donné qu'il est créé pour travailler avec redux ces récits peuvent être déclenchés à exécuter par l'envoi de vos composants.

Depuis Sagas utilisation de générateurs, vous pouvez également vous assurer avec certitude que vos envois se produire dans un ordre spécifique et ne se produire sous certaines conditions. Voici un exemple de leur documentation, et je vais vous guider à travers elle 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')
    }
  }
}

Bon, c'est un peu déroutant au premier abord, mais cette saga définit l'ordre exact d'une séquence de connexion qui doit arriver. La boucle infinie est autorisé en raison de la nature de générateurs. Lorsque votre code arrive à un rendement il s'arrête à la ligne et d'attendre. Il ne va pas continuer à la ligne suivante jusqu'à ce que vous lui dites. Regardez donc où il est dit yield take('LOGIN_REQUEST'). La saga de rendement ou à attendre, à ce point, jusqu'à ce que vous envoi 'LOGIN_REQUEST" après laquelle la saga qui fera appel à la méthode authorize, et aller jusqu'à la prochaine rendement. La méthode suivante est asynchrone yield call(Api.storeItem, {token}) afin de ne pas passer à la ligne suivante jusqu'à ce que le code résout.

Maintenant, c'est où la magie se produit. La saga s'arrêtera à nouveau à l' yield take('LOGOUT') jusqu'à l'expédition de DÉCONNEXION dans votre application. Cela est essentiel car si vous étiez à l'expédition LOGIN_REQUEST de nouveau avant de se DÉCONNECTER, le processus d'ouverture de session ne serait pas être invoquée. Maintenant, si vous envoi de DÉCONNEXION, il revient à la première de rendement et d'attendre pour l'application d'expédition LOGIN_REQUEST de nouveau.

Redux Sagas sont, de loin, l'un de mes outils préférés pour une utilisation avec Redux. Il vous donne donc beaucoup de contrôle sur votre application et la personne la lecture de votre code vous remercier car tout se lit maintenant une ligne à la fois.

1voto

Anthony De Smet Points 712

Jetez un oeil à ceci: https://github.com/gaearon/redux-thunk

Le seul id ne doit pas passer à travers le réducteur. Dans votre action créateur (paf), de récupérer l'id de la liste de la première, et puis() effectuer un deuxième appel pour ajouter l'élément à la liste. Après cela, vous pouvez expédier des actions différentes en fonction de la possibilité ou non l'addition a été un succès.

Vous pouvez envoyer plusieurs actions tout en faisant cela, de rapport lorsque le serveur d'interaction a commencé et terminé. Cela vous permettra d'afficher un message ou un spinner, dans le cas où l'opération est lourde et peut prendre un certain temps.

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

Tout le crédit à Dan Abramov

0voto

Pcriulan Points 249

Vous n'avez pas à traiter avec les files d'attente des actions. Il permet de masquer le flux de données et il fera de votre application plus fastidieux à déboguer.

Je vous suggère d'utiliser certains temporaire id lors de la création d'une liste ou d'un élément, puis mise à jour de ces identifiants lorsque vous recevez réellement le vrai du magasin.

Quelque chose comme ceci peut-être ? (ne pas testés, mais vous obtenez l'id) :

EDIT : je n'ai pas compris tout d'abord que les articles doivent être enregistrées automatiquement lorsque la liste est enregistrée. J'ai édité createList action créateur.

/* 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 */

0voto

Sebastien Lorber Points 9682

C'est comment je pourrais résoudre ce problème:

Assurez-vous que chaque liste locale ont un identifiant unique. Je ne parle pas le backend id ici. Le nom est probablement pas suffisante pour identifier une liste? Une "optimiste" liste pas encore persisté doit être clairement identifiable, et l'utilisateur peut essayer de créer 2 listes avec le même nom, même si c'est un cas limite.

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

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

Sur le point d'ajouter, essayez d'obtenir le backend id de Redux magasin. Si elle n'existe pas, alors essayez de l'obtenir à partir d' CreatedListIdCache. Le retour de la pièce d'identité doit être asynchrone, car CreatedListIdCache retourne 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));
  }
}

L'utilisation de cette méthode dans votre addItem, de sorte que votre addItem sera retardée automatiquement jusqu'à ce que le backend id 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())
);

Vous pouvez nettoyer le CreatedListIdPromiseCache, mais ce n'est probablement pas très important pour la plupart des applications, sauf si vous avez très stricts besoins d'utilisation de mémoire.


Une autre option serait que le backend id est calculée sur le frontend, avec quelque chose comme UUID. Votre backend juste besoin de vérifier l'unicité de cette pièce d'identité. Ainsi, vous auriez toujours un moteur valable id pour tous avec optimisme créé des listes, même si backend n'ai pas de réponse encore.

0voto

roboli Points 695

J'ai été confronté à un problème similaire au vôtre. J'avais besoin d'une file d'attente afin de garantir que les optimistes actions ont été commis ou éventuellement engagés (dans le cas de problèmes de réseau) à un serveur distant dans le même ordre qu'ils ont été créés, ou rollback si pas possible. J'ai trouvé que, avec Redux seulement, de collines court pour ce, essentiellement parce que je crois qu'il n'a pas été conçu pour cela et de le faire avec des promesses seul peut être vraiment un problème difficile de la raisonner, outre le fait que vous avez besoin pour gérer votre file d'attente de l'état en quelque sorte... à mon humble avis.

Je pense que @Pcriulan suggestion sur l'utilisation de redux-saga était un bon. À première vue, redux-saga ne fournit pas quelque chose pour vous aider avec jusqu'à ce que vous obtenez de canaux. Cela vous ouvre une porte pour traiter la simultanéité dans d'autres façons, d'autres langues, CSP, en particulier (voir disparaître ou Clojure asynchrone par exemple), grâce à JS générateurs. Il y a même des questions sur pourquoi est nommé d'après la Saga et non CSP haha... de toute façon.

Donc, ici, est de savoir comment une saga pourrait vous aider dans 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 la plus intéressante ici est de savoir comment le canal agit comme un tampon (une file d'attente) qui garde "à l'écoute" pour la réception des actions, mais ne procédera pas à l'avenir des actions jusqu'à ce qu'il en finir avec l'actuel. Vous pourriez avoir besoin pour aller au-dessus de leur documentation afin d'en saisir le code mieux, mais je pense que ça en vaut la peine. La remise à zéro du canal de la partie peut être ou ne pas fonctionner pour répondre à vos besoins :de la pensée:

Espérons que cela aide!

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X