web-dev-qa-db-fra.com

Comment gérer les effets secondaires complexes dans Redux?

Je me bats depuis des heures pour trouver une solution à ce problème ...

Je développe un jeu avec un tableau de bord en ligne. Le joueur peut se connecter et se déconnecter à tout moment. Après avoir terminé une partie, le joueur verra le tableau de bord, et verra son propre classement, et le score sera soumis automatiquement.

Le tableau de bord indique le classement du joueur et le classement.

Screenshot

Le tableau de bord est utilisé à la fois lorsque l'utilisateur a fini de jouer (pour soumettre un score) et lorsque l'utilisateur souhaite simplement vérifier son classement.

C'est là que la logique devient très compliquée:

  • Si l'utilisateur est connecté, le score sera soumis en premier. Une fois le nouveau record enregistré, le tableau de bord sera chargé.

  • Sinon, le tableau d'affichage sera chargé immédiatement. Le joueur aura la possibilité de se connecter ou de s'inscrire. Après cela, le score sera soumis, puis le tableau de bord sera à nouveau actualisé.

  • Cependant, s'il n'y a pas de score à soumettre (il suffit de visualiser le tableau des meilleurs scores). Dans ce cas, l'enregistrement existant du lecteur est simplement téléchargé. Mais puisque cette action n'affecte pas le tableau de bord, le tableau de bord et le dossier du joueur doivent être téléchargés simultanément.

  • Il y a un nombre illimité de niveaux. Chaque niveau a un tableau de bord différent. Lorsque l'utilisateur affiche un tableau de bord, il "observe" ce tableau de bord. Lorsqu'il est fermé, l'utilisateur cesse de l'observer.

  • L'utilisateur peut se connecter et se déconnecter à tout moment. Si l'utilisateur se déconnecte, le classement de l'utilisateur doit disparaître et si l'utilisateur se connecte en tant que autre compte, les informations de classement de ce compte doivent être récupérées et affichées.

    ... mais cette récupération de ces informations ne devrait avoir lieu que pour le tableau de bord dont l'utilisateur observe actuellement.

  • Pour les opérations de visualisation, les résultats doivent être mis en cache en mémoire, de sorte que si l'utilisateur se réabonne au même tableau de bord, il n'y aura pas de récupération. Cependant, si un score est soumis, le cache ne doit pas être utilisé.

  • L'une de ces opérations réseau peut échouer et le joueur doit pouvoir les réessayer.

  • Ces opérations devraient être atomiques. Tous les états doivent être mis à jour en une seule fois (pas d'états intermédiaires).

Actuellement, je suis en mesure de résoudre ce problème en utilisant Bacon.js (une bibliothèque de programmation réactive fonctionnelle), car il est livré avec un support de mise à jour atomique. Le code est assez concis, mais en ce moment, c'est un code spaghetti imprévisible et désordonné.

J'ai commencé à regarder Redux. J'ai donc essayé de structurer le magasin et j'ai trouvé quelque chose comme ça (dans la syntaxe YAMLish):

user: (user information)
record:
  level1:
    status: (loading / completed / error)
    data:   (record data)
    error:  (error / null)
scoreboard:
  level1:
    status: (loading / completed / error)
    data:
      - (record data)
      - (record data)
      - (record data)
    error:  (error / null)

Le problème devient: où dois-je mettre les effets secondaires.

Pour les actions sans effets secondaires, cela devient très facile. Par exemple, lors de l'action LOGOUT, le réducteur record pourrait simplement faire sauter tous les enregistrements.

Cependant, certaines actions ont un effet secondaire. Par exemple, si je ne suis pas connecté avant de soumettre le score, alors je me connecte avec succès, l'action SET_USER Enregistre l'utilisateur dans le magasin.

Mais parce que j'ai un score à soumettre, cette action SET_USER Doit également provoquer le déclenchement d'une demande AJAX, et en même temps, définir le record.levelN.status à loading.

La question est: comment puis-je signifier qu'un effet secondaire (soumission du score) doit avoir lieu lorsque je me connecte de manière atomique?

Dans l'architecture Elm, un programme de mise à jour peut également émettre des effets secondaires lors de l'utilisation de la forme de Action -> Model -> (Model, Effects Action), mais dans Redux, c'est juste (State, Action) -> State.

À partir des documents Actions asynchrones , ils recommandent de les placer dans un créateur d'action. Cela signifie-t-il que la logique de soumission du score devra également être mise dans le créateur de l'action pour une action de connexion réussie?

function login (options) {
  return (dispatch) => {
    service.login(options).then(user => dispatch(setUser(user)))
  }
}

function setUser (user) {
  return (dispatch, getState) => {
    dispatch({ type: 'SET_USER', user })
    let scoreboards = getObservedScoreboards(getState())
    for (let scoreboard of scoreboards) {
      service.loadUserRanking(scoreboard.level)
    }
  }
}

Je trouve cela un peu étrange, car le code responsable de cette réaction en chaîne existe désormais à 2 endroits:

  1. Dans le réducteur. Lorsque l'action SET_USER Est envoyée, le réducteur record doit également définir le statut des enregistrements appartenant aux tableaux de bord observés sur loading.
  2. Dans le créateur d'action, qui effectue l'effet secondaire réel de la récupération/soumission de la partition.

Il semble également que je doive suivre manuellement tous les observateurs actifs. Alors que dans la version Bacon.js, j'ai fait quelque chose comme ça:

Bacon.once() // When first observing the scoreboard
.merge(resubmit口) // When resubmitting because of network error
.merge(user川.changes().filter(user => !!user).first()) // When user logs in (but only once)
.flatMapLatest(submitOrGetRanking(data))

Le code Bacon réel est beaucoup plus long, en raison de toutes les règles complexes ci-dessus, qui rendaient la version Bacon à peine lisible.

Mais Bacon a gardé une trace de tous les abonnements actifs automatiquement. Cela m'a amené à commencer à me demander s'il ne valait pas la peine d'être changé, car la réécrire dans Redux nécessiterait beaucoup de manipulation manuelle. Quelqu'un peut-il suggérer un pointeur?

45
Thai

Lorsque vous souhaitez des dépendances asynchrones complexes, utilisez simplement Bacon, Rx, canaux, sagas ou une autre abstraction asynchrone. Vous pouvez les utiliser avec ou sans Redux. Exemple avec Redux:

observeSomething()
  .flatMap(someTransformation)
  .filter(someFilter)
  .map(createActionSomehow)
  .subscribe(store.dispatch);

Vous pouvez composer vos actions asynchrones comme vous le souhaitez - la seule partie importante est qu'elles finissent par se transformer en appels store.dispatch(action).

Redux Thunk est suffisant pour les applications simples, mais comme vos besoins asynchrones deviennent plus sophistiqués, vous devez utiliser une véritable abstraction de composition asynchrone, et Redux ne se soucie pas de celle que vous utilisez.


Mise à jour: Un certain temps s'est écoulé et quelques nouvelles solutions ont vu le jour. Je vous suggère de vérifier Redux Saga qui est devenu une solution assez populaire pour le flux de contrôle asynchrone dans Redux.

52
Dan Abramov

Edit : il y a maintenant un projet redux-saga inspiré de ces idées

Voici quelques belles ressources


Flux/Redux s'inspire du traitement des flux d'événements backend (quel que soit le nom: eventsourcing, CQRS, CEP, architecture lambda ...).

Nous pouvons comparer ActionCreators/Actions of Flux à Commands/Events (terminologie généralement utilisée dans les systèmes backend).

Dans ces architectures backend, nous utilisons un modèle qui est souvent appelé Saga , ou Process Manager. Fondamentalement, c'est un élément du système qui reçoit des événements, peut gérer son propre état, puis émettre de nouvelles commandes. Pour faire simple, c'est un peu comme implémenter IFTTT (If-This-Then-That).

Vous pouvez l'implémenter avec FRP et BaconJS, mais vous pouvez également l'implémenter au-dessus des réducteurs Redux.

function submitScoreAfterLoginSaga(action, state = {}) {  
  switch (action.type) {

    case SCORE_RECORDED:
      // We record the score for later use if USER_LOGGED_IN is fired
      state = Object.assign({}, state, {score: action.score}

    case USER_LOGGED_IN: 
      if ( state.score ) {
        // Trigger ActionCreator here to submit that score
        dispatch(sendScore(state.score))
      } 
    break;

  }
  return state;
}

Pour être clair: l'état calculé à partir des réducteurs pour conduire React les rendus doivent rester absolument purs! Mais tous les états de votre application n'ont pas pour but de déclencher des rendus , et c'est le cas ici où la nécessité est de synchroniser différentes parties découplées de votre application avec des règles complexes. L'état de cette "saga" ne doit pas déclencher un React rendu!

Je ne pense pas que Redux fournisse quoi que ce soit pour soutenir ce modèle, mais vous pouvez probablement l'implémenter vous-même facilement.

Je l'ai fait dans notre framework de démarrage et ce modèle fonctionne très bien. Cela nous permet de gérer des choses IFTTT comme:

  • Lorsque l'intégration de l'utilisateur est active et que l'utilisateur ferme Popup1, ouvrez Popup2 et affichez une info-bulle de conseil.

  • Lorsque l'utilisateur utilise un site Web mobile et ouvre Menu2, puis fermez Menu1

[~ # ~] important [~ # ~] : si vous utilisez les fonctionnalités annuler/refaire/rejouer de certains frameworks comme Redux, il est important que lors de la relecture d'un journal d'événements, toutes ces sagas ne sont pas câblées, car vous ne voulez pas que de nouveaux événements soient déclenchés pendant la relecture!

17
Sebastien Lorber