
Parfois, la mise en œuvre la plus simple de fonctionnalités crée en fin de compte plus de problèmes que de bien, ne faisant qu’accroître la complexité ailleurs. Le résultat final est une architecture zaguée que personne ne veut toucher.
Notes du traducteurL'article a été écrit en 2017, mais est toujours d'actualité. Il s'adresse aux personnes expérimentées en RxJS et Ngrx, ou qui souhaitent essayer Redux en Angular.
Les extraits de code ont été mis à jour en fonction de la syntaxe RxJS actuelle et légèrement modifiés pour améliorer la lisibilité et la facilité de compréhension.
Ngrx / store est une bibliothèque angulaire qui aide à contenir la complexité des fonctions individuelles. L'une des raisons est que ngrx / store embrasse la programmation fonctionnelle, ce qui limite ce qui peut être fait à l'intérieur d'une fonction pour obtenir un caractère plus raisonnable en dehors d'elle. Dans ngrx / store, des choses comme les réducteurs (ci-après dénommés réducteurs), les sélecteurs (ci-après dénommés sélecteurs) et les opérateurs RxJS sont de simples fonctions.
Les fonctions pures sont plus faciles à tester, déboguer, analyser, paralléliser et combiner. Une fonction est propre si:
- avec la même entrée, il renvoie toujours la même sortie;
- pas d'effets secondaires.
Les effets secondaires ne peuvent pas être évités, mais ils sont isolés dans ngrx / store, donc le reste de l'application peut être constitué de fonctions pures.
Effets secondaires
Lorsque l'utilisateur soumet le formulaire, nous devons apporter des modifications sur le serveur. Changer le serveur et répondre au client est un effet secondaire. Cela peut être géré dans le composant:
this.store.dispatch({ type: 'SAVE_DATA', payload: data, }); this.saveData(data)
Ce serait bien si nous pouvions simplement répartir l'action (ci-après l'action) à l'intérieur du composant lorsque l'utilisateur soumet le formulaire et gérer l'effet secondaire ailleurs.
Ngrx / effects est un middleware pour gérer les effets secondaires dans ngrx / store. Il écoute les actions soumises dans le thread observable, effectue des effets secondaires et renvoie de nouvelles actions immédiatement ou de manière asynchrone. Les actions retournées sont transmises au réducteur.
La possibilité de gérer les effets secondaires de la manière RxJS rend le code plus propre. Après avoir envoyé l'action initiale SAVE_DATA
partir du composant, vous créez une classe d'effet pour gérer le reste:
@Effect() saveData$ = this.actions$.pipe( ofType('SAVE_DATA'), pluck('payload'), switchMap(data => this.saveData(data)), map(res => ({ type: 'DATA_SAVED' })), );
Cela simplifie le fonctionnement du composant uniquement avant d'envoyer des actions et de s'abonner à observable.
Ngrx / effets faciles à utiliser
Ngrx / effects est une solution très puissante, il est donc facile d'en abuser. Voici quelques anti-patterns ngrx / store courants que Ngrx / effects simplifie:
1. État en double
Supposons que vous travaillez sur une sorte d'application multimédia et que vous disposez des propriétés suivantes dans l'arborescence d'état:
export interface State { mediaPlaying: boolean; audioPlaying: boolean; videoPlaying: boolean; }
Étant donné que l'audio est un type de média, chaque fois que la audioPlaying
est vraie, la mediaPlaying
doit également être vraie. Voici donc la question: "Comment puis-je m'assurer que mediaPlaying est mis à jour lorsque audioPlaying est mis à jour?"
Réponse invalide : utilisez Ngrx / effects!
@Effect() playMediaWithAudio$ = this.actions$.pipe( ofType('PLAY_AUDIO'), map(() => ({ type: 'PLAY_MEDIA' })), );
La bonne réponse est : si l'état de mediaPlaying
complètement prédit par une autre partie de l'arbre d'état, alors ce n'est pas un vrai état. Il s'agit d'un état dérivé. Il appartient au sélecteur, pas au magasin.
audioPlaying$ = this.store.select('audioPlaying'); videoPlaying$ = this.store.select('videoPlaying'); mediaPlaying$ = combineLatest(this.audioPlaying$, this.videoPlaying$).pipe( map(([audioPlaying, videoPlaying]) => audioPlaying || videoPlaying), );
Maintenant, notre condition peut rester propre et normalisée , et nous n'utilisons pas Ngrx / effects pour quelque chose qui n'est pas un effet secondaire.
2. Chaînage des actions avec réducteur
Imaginez que vous ayez ces propriétés dans votre arbre d'état:
export interface State { items: { [index: number]: Item }; favoriteItems: number[]; }
Ensuite, l'utilisateur supprime l'élément. Lorsque la demande de suppression est renvoyée, l'action DELETE_ITEM_SUCCESS
envoyée pour mettre à jour l'état de notre application. Dans le réducteur d' items
, un Item
individuel est supprimé de l'objet items
. Mais si cet identifiant d'élément était dans le tableau favoriteItems
, l'élément auquel il fait référence sera absent. La question est donc de savoir comment puis-je m'assurer que l'identifiant est supprimé de favoriteItems
lors de l'envoi de l'action DELETE_ITEM_SUCCESS
?
Réponse invalide : utilisez Ngrx / effects!
@Effect() removeFavoriteItemId$ = this.actions$.pipe( ofType('DELETE_ITEM_SUCCESS'), map(() => ({ type: 'REMOVE_FAVORITE_ITEM_ID' })), );
Donc, maintenant nous aurons deux actions envoyées l'une après l'autre, et deux réducteurs renvoyant de nouveaux états l'un après l'autre.
Bonne réponse : DELETE_ITEM_SUCCESS
peut être traité à la fois par le réducteur d' items
et le réducteur favoriteItems
.
export function favoriteItemsReducer(state = initialState, action: Action) { switch (action.type) { case 'REMOVE_FAVORITE_ITEM': case 'DELETE_ITEM_SUCCESS': const itemId = action.payload; return state.filter(id => id !== itemId); default: return state; } }
Le but de l'action est de séparer ce qui s'est passé de la façon dont l'État devrait changer. Ce qui s'est passé était DELETE_ITEM_SUCCESS
. La tâche des réducteurs est de provoquer un changement d'état correspondant.
La suppression d'un identifiant de favoriteItems
pas un effet secondaire de la suppression d'un Item
. L'ensemble du processus est entièrement synchronisé et peut être traité par des réducteurs. Ngrx / effets n'est pas nécessaire.
3. Demander des données pour le composant
Votre composant a besoin de données du magasin, mais vous devez d'abord les obtenir du serveur. La question est, comment pouvons-nous mettre les données dans le magasin afin que le composant puisse les recevoir?
Manière douloureuse : utilisez Ngrx / effets!
Dans le composant, nous initions la demande en envoyant une action:
ngOnInit() { this.store.dispatch({ type: 'GET_USERS' }); }
Dans la classe d'effets, nous écoutons GET_USERS
:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), switchMap(() => this.getUsers()), map(users => ({ type: 'RECEIVE_USERS', users })), );
Supposons maintenant que l'utilisateur décide qu'un certain itinéraire prend trop de temps à charger, il passera donc de l'un à l'autre. Afin d'être efficace et de ne pas charger de données inutiles, nous souhaitons annuler cette demande. Lorsque le composant est détruit, nous nous désinscrirons de la demande en envoyant l'action:
ngOnDestroy() { this.store.dispatch({ type: 'CANCEL_GET_USERS' }); }
Maintenant, dans la classe d'effets, nous écoutons les deux actions:
@Effect getUsers$ = this.actions$.pipe( ofType('GET_USERS', 'CANCEL_GET_USERS'), withLatestFrom(this.userSelectors.needUsers$), filter(([action, needUsers]) => needUsers), map(([action, needUsers]) => action), switchMap( action => action.type === 'CANCEL_GET_USERS' ? of() : this.getUsers().pipe(map(users => ({ type: 'RECEIVE_USERS', users }))), ), );
Bon. Maintenant, un autre développeur ajoute un composant qui nécessite la même requête HTTP (nous ne ferons aucune hypothèse sur les autres composants). Le composant envoie les mêmes actions aux mêmes endroits. Si les deux composants sont actifs en même temps, le premier composant lance une requête HTTP pour l'initialiser. Lorsque le deuxième composant est initialisé, rien de plus ne se produira, car needUsers
sera false
. Super!
Ensuite, lorsque le premier composant est détruit, il enverra CANCEL_GET_USERS
. Mais le deuxième composant a toujours besoin de ces données. Comment empêcher l'annulation d'une demande? Peut-être que nous allons démarrer le compteur de tous les abonnés? Je ne vais pas mettre cela en œuvre, mais je suppose que vous avez compris le point. Nous commençons à soupçonner qu'il existe une meilleure façon de gérer ces dépendances de données.
Supposons maintenant qu'un autre composant apparaisse et qu'il dépend de données qui ne peuvent pas être récupérées jusqu'à ce que users
données des users
apparaissent dans le magasin. Cela peut être une connexion à une prise Web pour le chat, des informations supplémentaires sur certains utilisateurs ou autre chose. Nous ne savons pas si ce composant sera initialisé avant ou après la souscription de deux autres composants aux users
.
La meilleure aide que j'ai trouvée pour ce scénario particulier est ce super article . Dans son exemple, callApiY
nécessite que callApiX
déjà terminé. J'ai supprimé les commentaires pour le rendre moins intimidant, mais n'hésitez pas à lire le post original pour en savoir plus:
@Effect() actionX$ = this.actions$.pipe( ofType('ACTION_X'), map(toPayload), switchMap(payload => this.api.callApiX(payload).pipe( map(data => ({ type: 'ACTION_X_SUCCESS', payload: data })), catchError(err => of({ type: 'ACTION_X_FAIL', payload: err })), ), ), ); @Effect() actionY$ = this.actions$.pipe( ofType('ACTION_Y'), map(toPayload), withLatestFrom(this.store.select(state => state.someBoolean)), switchMap(([payload, someBoolean]) => { const callHttpY = v => { return this.api.callApiY(v).pipe( map(data => ({ type: 'ACTION_Y_SUCCESS', payload: data, })), catchError(err => of({ type: 'ACTION_Y_FAIL', payload: err, }), ), ); }; if (someBoolean) { return callHttpY(payload); } return of({ type: 'ACTION_X', payload }).merge( this.actions$.pipe( ofType('ACTION_X_SUCCESS', 'ACTION_X_FAIL'), first(), switchMap(action => { if (action.type === 'ACTION_X_FAIL') { return of({ type: 'ACTION_Y_FAIL', payload: 'Because ACTION_X failed.', }); } return callHttpY(payload); }), ), ); }), );
Ajoutez maintenant l'exigence selon laquelle les requêtes HTTP doivent être annulées lorsque les composants n'en ont plus besoin, et cela deviendra encore plus complexe.
. . .
Alors, pourquoi y a-t-il tant de problèmes avec la gestion de la dépendance des données alors que RxJS devrait le rendre vraiment facile?
Bien que les données provenant du serveur soient techniquement un effet secondaire, il ne me semble pas que Ngrx / effects soit le meilleur moyen de gérer cela.
Les composants sont des interfaces d'entrée / sortie utilisateur. Ils affichent des données et envoient des actions effectuées par lui. Lorsqu'un composant se charge, il n'envoie aucune action effectuée par cet utilisateur. Il veut montrer les données. Cela ressemble plus à un abonnement qu'à un effet secondaire.
Très souvent, vous pouvez voir des applications qui utilisent des actions pour lancer une demande de données. Ces applications implémentent une interface spéciale pour les effets secondaires observables. Et, comme nous l'avons vu, cette interface peut devenir très gênante et encombrante. S'abonner à, se désinscrire et se connecter observables eux-mêmes est beaucoup plus facile.
. . .
Manière moins douloureuse : le composant enregistrera son intérêt pour les données en s'y abonnant via observable.
Nous allons créer des observables qui contiennent les requêtes HTTP nécessaires. Nous verrons combien il est plus facile de gérer plusieurs abonnements et chaînes de requêtes qui dépendent les uns des autres à l'aide de RxJS pur, plutôt que de le faire via des effets.
Créez ces observables dans le service:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), finalize(() => this.store.dispatch({ type: 'CANCEL_GET_USERS' })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), );
L'abonnement aux users$
sera transmis à la fois à requireUsers$
et à this.store.pipe(select(selectUsers))
, mais les données ne seront reçues que de this.store.pipe(select(selectUsers))
( muteFirst
implémentation muteFirst
et fixe muteFirst
avec son test .)
Dans le composant:
ngOnInit() { this.users$ = this.userService.users$; }
Étant donné que cette dépendance aux données est désormais simple à observer, nous pouvons nous abonner et se désinscrire dans le modèle à l'aide d'un canal async
, et nous n'avons plus besoin d'envoyer d'actions. Si l'application quitte la route du dernier composant signé pour les données, la demande HTTP est annulée ou le socket Web est fermé.
La chaîne de dépendance des données peut être traitée comme suit:
requireUsers$ = this.store.pipe( select(selectNeedUser), filter(needUsers => needUsers), tap(() => this.store.dispatch({ type: 'GET_USERS' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS', users })), share(), ); users$ = muteFirst( this.requireUsers$.pipe(startWith(null)), this.store.pipe(select(selectUsers)), ); requireUsersExtraData$ = this.users$.pipe( withLatestFrom(this.store.pipe(select(selectNeedUsersExtraData))), filter(([users, needData]) => Boolean(users.length) && needData), tap(() => this.store.dispatch({ type: 'GET_USERS_EXTRA_DATA' })), switchMap(() => this.getUsers()), tap(users => this.store.dispatch({ type: 'RECEIVE_USERS_EXTRA_DATA', users, }), ), share(), ); public usersExtraData$ = muteFirst( this.requireUsersExtraData$.pipe(startWith(null)), this.store.pipe(select(selectUsersExtraData)), );
Voici une comparaison parallèle de la méthode ci-dessus avec cette méthode:

L'utilisation de l'observable pur nécessite moins de lignes de code et est automatiquement désabonnée des dépendances de données tout au long de la chaîne. (J'ai ignoré les instructions de finalize
qui étaient initialement incluses pour rendre la comparaison plus compréhensible, mais même sans elles, les requêtes seront toujours annulées en conséquence.)

Conclusion
Ngrx / effects est un excellent outil! Mais considérez ces questions avant de l'utiliser:
- Est-ce vraiment un effet secondaire?
- Ngrx / effects est-il la meilleure façon de procéder?