Avantages / inconvénients de l’utilisation de redux-saga avec les générateurs ES6 vs redux-thunk avec ES2017 async / waiting

On parle beaucoup du dernier enfant de la ville de Redux , redux-saga / redux-saga . Il utilise des fonctions de générateur pour écouter / dissortingbuer des actions.

Avant de me pencher sur le sujet, j’aimerais connaître les avantages et les inconvénients de l’utilisation de redux-saga au lieu de l’approche ci-dessous où j’utilise redux-thunk avec async / waiting.

Un composant peut ressembler à ceci, envoyer des actions comme d’habitude.

 import { login } from 'redux/auth'; class LoginForm extends Component { onClick(e) { e.preventDefault(); const { user, pass } = this.refs; this.props.dispatch(login(user.value, pass.value)); } render() { return (
); } } export default connect((state) => ({}))(LoginForm);

Ensuite, mes actions ressemblent à ceci:

 // auth.js import request from 'axios'; import { loadUserData } from './user'; // define constants // define initial state // export default reducer export const login = (user, pass) => async (dispatch) => { try { dispatch({ type: LOGIN_REQUEST }); let { data } = await request.post('/login', { user, pass }); await dispatch(loadUserData(data.uid)); dispatch({ type: LOGIN_SUCCESS, data }); } catch(error) { dispatch({ type: LOGIN_ERROR, error }); } } // more actions... 

 // user.js import request from 'axios'; // define constants // define initial state // export default reducer export const loadUserData = (uid) => async (dispatch) => { try { dispatch({ type: USERDATA_REQUEST }); let { data } = await request.get(`/users/${uid}`); dispatch({ type: USERDATA_SUCCESS, data }); } catch(error) { dispatch({ type: USERDATA_ERROR, error }); } } // more actions... 

En redux-saga, l’équivalent de l’exemple ci-dessus serait

 export function* loginSaga() { while(true) { const { user, pass } = yield take(LOGIN_REQUEST) try { let { data } = yield call(request.post, '/login', { user, pass }); yield fork(loadUserData, data.uid); yield put({ type: LOGIN_SUCCESS, data }); } catch(error) { yield put({ type: LOGIN_ERROR, error }); } } } export function* loadUserData(uid) { try { yield put({ type: USERDATA_REQUEST }); let { data } = yield call(request.get, `/users/${uid}`); yield put({ type: USERDATA_SUCCESS, data }); } catch(error) { yield put({ type: USERDATA_ERROR, error }); } } 

La première chose à noter est que nous appelons les fonctions api en utilisant l’ yield call(func, ...args) formulaire yield call(func, ...args) . call n’exécute pas l’effet, il crée simplement un object simple comme {type: 'CALL', func, args} . L’exécution est déléguée au middleware redux-saga qui se charge de l’exécution de la fonction et de la reprise du générateur avec son résultat.

L’avantage principal est que vous pouvez tester le générateur en dehors de Redux en utilisant des contrôles d’égalité simples

 const iterator = loginSaga() assert.deepEqual(iterator.next().value, take(LOGIN_REQUEST)) // resume the generator with some dummy action const mockAction = {user: '...', pass: '...'} assert.deepEqual( iterator.next(mockAction).value, call(request.post, '/login', mockAction) ) // simulate an error result const mockError = 'invalid user/password' assert.deepEqual( iterator.throw(mockError).value, put({ type: LOGIN_ERROR, error: mockError }) ) 

Notez que nous moquons le résultat de l’appel api en injectant simplement les données simulées dans la méthode next de l’iterator. Les données moqueuses sont beaucoup plus simples que les fonctions moqueuses.

La deuxième chose à noter est l’appel à yield take(ACTION) . Les Thunks sont appelés par le créateur de l’action à chaque nouvelle action (par exemple, LOGIN_REQUEST ). c’est-à-dire que les actions sont continuellement poussées vers les thunks et que les thunks n’ont aucun contrôle sur le moment où elles doivent cesser de gérer ces actions.

En redux-saga, les générateurs tirent la prochaine action. c’est-à-dire qu’ils contrôlent quand écouter certaines actions et quand ne pas le faire. Dans l’exemple ci-dessus, les instructions de stream sont placées à l’intérieur d’une boucle while(true) , de sorte qu’elles écoutent chaque action entrante, ce qui imite quelque peu le comportement de push de thunk.

L’approche pull permet de mettre en œuvre des stream de contrôle complexes. Supposons par exemple que nous voulons append les exigences suivantes

  • Manipuler l’action utilisateur LOGOUT

  • Lors de la première connexion réussie, le serveur renvoie un jeton qui expire dans un certain délai stocké dans un champ expires_in . Nous devrons rafraîchir l’autorisation en arrière-plan à chaque expires_in millisecondes

  • Tenez compte du fait que lorsque vous attendez le résultat d’appels API (connexion initiale ou actualisation), l’utilisateur peut se déconnecter.

Comment allez-vous implémenter cela avec les thunks; tout en fournissant une couverture de test complète pour tout le stream? Voici comment cela peut ressembler aux Sagas:

 function* authorize(credentials) { const token = yield call(api.authorize, credentials) yield put( login.success(token) ) return token } function* authAndRefreshTokenOnExpiry(name, password) { let token = yield call(authorize, {name, password}) while(true) { yield call(delay, token.expires_in) token = yield call(authorize, {token}) } } function* watchAuth() { while(true) { try { const {name, password} = yield take(LOGIN_REQUEST) yield race([ take(LOGOUT), call(authAndRefreshTokenOnExpiry, name, password) ]) // user logged out, next while iteration will wait for the // next LOGIN_REQUEST action } catch(error) { yield put( login.error(error) ) } } } 

Dans l’exemple ci-dessus, nous exprimons notre exigence de concurrence en utilisant la race . Si take(LOGOUT) gagne la course (c.-à-d. L’utilisateur a cliqué sur un bouton de déconnexion). La course annulera automatiquement la tâche d’arrière-plan authAndRefreshTokenOnExpiry . Et si l’ authAndRefreshTokenOnExpiry était bloqué au milieu d’un call(authorize, {token}) appel sera également annulé. L’annulation se propage automatiquement vers le bas.

Vous pouvez trouver une démo exécutable du stream ci-dessus

J’appendai mon expérience d’utilisation de la saga dans le système de production en plus de la réponse plutôt approfondie de l’auteur de la bibliothèque.

Pro (en utilisant la saga):

  • Testabilité Il est très facile de tester sagas car call () renvoie un object pur. Tester les thunks nécessite normalement d’inclure un mockStore dans votre test.

  • redux-saga est livré avec de nombreuses fonctions utiles sur les tâches. Il me semble que le concept de saga est de créer une sorte de travailleur / thread d’arrière-plan pour votre application, qui fait défaut dans une architecture de réaction redux (actionCreators et les réducteurs doivent être des fonctions pures).

  • Les Sagas offrent un lieu indépendant pour gérer tous les effets secondaires. Il est généralement plus facile de modifier et de gérer que des actions inutiles dans mon expérience.

Con:

  • Syntaxe du générateur

  • Beaucoup de concepts à apprendre

  • Stabilité de l’API. Il semble que redux-saga continue d’append des fonctionnalités (par exemple, les canaux?) Et que la communauté n’est pas aussi grande. Il y a un problème si la bibliothèque effectue une mise à jour non rétro-compatible un jour.

Je voudrais juste append quelques commentaires de mon expérience personnelle (en utilisant à la fois sagas et thunk):

Les Sagas sont super pour tester:

  • Vous n’avez pas besoin de vous moquer de fonctions enveloppées d’effets
  • Les tests sont donc propres, lisibles et faciles à écrire
  • Lors de l’utilisation de sagas, les créateurs d’action retournent principalement des littéraux d’object simple. Il est également plus facile de tester et d’affirmer contrairement aux promesses de thunk.

Les Sagas sont plus puissants. Tout ce que vous pouvez faire dans le créateur de l’action d’un thunk, vous pouvez également le faire dans une saga, mais pas l’inverse (ou du moins pas facilement). Par exemple:

  • attendre qu’une action / des actions soient envoyées ( take )
  • annuler la routine existante ( cancel , takeLatest , race )
  • plusieurs routines peuvent écouter la même action ( take , takeEvery ,…)

Sagas offre également d’autres fonctionnalités utiles, qui permettent de généraliser certains modèles d’application courants:

  • channels pour écouter sur des sources d’événements externes (par exemple, des websockets)
  • modèle de fourche ( fork , spawn )
  • étrangler

Les Sagas sont un outil formidable et puissant. Cependant, avec le pouvoir vient la responsabilité. Lorsque votre application grandit, vous pouvez facilement vous perdre en déterminant qui attend que l’action soit envoyée ou ce qui se passe quand une action est envoyée. Par contre, le thunk est plus simple et plus facile à raisonner. Choisir l’un ou l’autre dépend de nombreux aspects tels que le type et la taille du projet, les types d’effets secondaires que votre projet doit gérer ou la préférence de l’équipe de développement. Dans tous les cas, gardez votre application simple et prévisible.

Après avoir examiné quelques différents projets React / Redux à grande échelle, les Sagas offrent aux développeurs une méthode plus structurée d’écriture de code beaucoup plus facile à tester et plus difficile à corriger.

Oui, c’est un peu bizarre de commencer, mais la plupart des développeurs en comprennent assez en un jour. Je dis toujours aux gens de ne pas s’inquiéter de ce que le yield commence à faire et qu’une fois que vous aurez écrit quelques tests, il vous reviendra.

J’ai vu quelques projets où les thunks ont été traités comme s’ils étaient des contrôleurs de la patte MVC et cela devient rapidement un désordre immuable.

Mon conseil est d’utiliser des Sagas où vous avez besoin d’un élément de type B lié à un seul événement. Pour tout ce qui peut franchir plusieurs actions, je trouve plus simple d’écrire le middleware client et d’utiliser la propriété meta d’une action FSA pour le déclencher.

Voici un projet qui combine les meilleures parties (avantages) des deux redux-saga et redux-thunk : vous pouvez gérer tous les effets secondaires sur les sagas tout en obtenant une promesse en dispatching l’action correspondante: https://github.com/diegohaz/ redux-saga-thunk

 class MyComponent extends React.Component { componentWillMount() { // `doSomething` dispatches an action which is handled by some saga this.props.doSomething().then((detail) => { console.log('Yaay!', detail) }).catch((error) => { console.log('Oops!', error) }) } } 

Un moyen plus simple consiste à utiliser redux-auto .

de la documantasion

redux-auto a corrigé ce problème asynchrone en vous permettant simplement de créer une fonction “action” qui renvoie une promesse. Pour accompagner votre logique d’action de la fonction “par défaut”.

  1. Pas besoin d’autres middleware asynchrones Redux. par exemple thunk, middleware promis, saga
  2. Vous permet facilement de passer une promesse en redux et de la faire gérer pour vous
  3. Vous permet de co-localiser des appels de service externes avec l’endroit où ils seront transformés
  4. Le nom du fichier “init.js” l’appellera une fois au démarrage de l’application. C’est bon pour charger des données du serveur au démarrage

L’idée est d’avoir chaque action dans un fichier spécifique . co-localiser l’appel du serveur dans le fichier avec des fonctions de réduction pour “en attente”, “satisfait” et “rejeté”. Cela rend les promesses de manipulation très faciles.

Il associe également automatiquement un object d’assistance (appelé “async”) au prototype de votre état, vous permettant de suivre vos transitions dans votre interface utilisateur.

Une note rapide Les générateurs sont annulables, asynchrones / attendus – non. Donc, pour un exemple de la question, cela n’a pas vraiment de sens de choisir. Mais pour des stream plus compliqués, il n’y a parfois pas de meilleure solution que d’utiliser des générateurs.

Donc, une autre idée pourrait être d’utiliser des générateurs avec un amortisseur, mais pour moi, cela ressemble à essayer d’inventer un vélo à roulettes carrées.

Et bien sûr, les générateurs sont plus faciles à tester.