
Aujourd'hui nous analyserons en détail une application angulaire réactive (
référentiel github ), entièrement écrite sur la stratégie
OnPush . Une autre application utilise des formulaires réactifs, ce qui est assez typique d'une application d'entreprise.
Nous n'utiliserons pas Flux, Redux, NgRx et profiterons plutôt des capacités déjà disponibles dans Typescript, Angular et RxJS. Le fait est que ces outils ne sont pas une solution miracle et peuvent ajouter une complexité inutile même aux applications simples. Nous en sommes honnêtement avertis par l'
un des auteurs de Flux , l'
auteur de Redux et l'
auteur de NgRx .
Mais ces outils donnent à nos applications de très belles fonctionnalités:
- Flux de données prévisible;
- Soutenez OnPush par conception;
- L'immutabilité des données, le manque d'effets secondaires accumulés et d'autres choses agréables.
Nous allons essayer d'obtenir les mêmes caractéristiques, mais sans introduire de complexité supplémentaire.
Comme vous le verrez à la fin de l'article, c'est une tâche assez simple - si vous supprimez les détails d'Angular et d'OnPush de l'article, il n'y a que quelques idées simples.
L'article n'offre pas un nouveau modèle universel, mais partage seulement avec le lecteur plusieurs idées qui, pour toute sa simplicité, pour une raison quelconque ne sont pas immédiatement venues à l'esprit. De plus, la solution développée ne contredit ni ne remplace Flux / Redux / NgRx. Ils peuvent être connectés, si cela est
vraiment nécessaire .
Pour une lecture confortable de l'article, une compréhension des termes composants intelligents, de présentation et de conteneur est requise.Plan d'action
La logique de la candidature, ainsi que la séquence de présentation du matériel, peuvent être décrites sous la forme des étapes suivantes:
- Données séparées pour la lecture (GET) et l'écriture (PUT / POST)
- Charger l'état sous forme de flux dans le composant conteneur
- Distribuer l'état à une hiérarchie de composants OnPush
- Avertissez Angular des changements de composants
- Édition de données encapsulées
Pour implémenter OnPush, nous devons analyser toutes les façons d'exécuter la détection de changement dans Angular. Il n'existe que quatre méthodes de ce type et nous les examinerons successivement tout au long de l'article.
Alors allons-y.
Partager des données pour la lecture et l'écriture
En règle générale, les applications frontend et backend utilisent des contrats typés (sinon pourquoi dactylographié?).
Le projet de démonstration que nous envisageons n'a pas de véritable backend, mais il contient un fichier de description pré-préparé
swagger.json . Sur cette base, les contrats dactylographiés sont générés par l'utilitaire
sw2dts .
Les contrats générés ont deux propriétés importantes.
Premièrement, la lecture et l'écriture sont effectuées à l'aide de différents contrats. Nous utilisons une petite convention et nous nous référons à la lecture des contrats avec le suffixe «State» et à la rédaction des contrats avec le suffixe «Model».
En séparant les contrats de cette manière, nous partageons le flux de données dans l'application. De haut en bas, un état en lecture seule est propagé dans la hiérarchie des composants. Pour modifier les données, un modèle est créé qui est initialement rempli avec des données d'état, mais existe en tant qu'objet distinct. À la fin de l'édition, le modèle est envoyé au backend sous forme de commande.
Le deuxième point important est que tous les champs d'état sont marqués avec un modificateur en lecture seule. Nous obtenons donc un support d'immunité au niveau du texte. Désormais, nous ne pourrons plus modifier accidentellement l'état du code ni le lier à l'aide de [(ngModel)] - lors de la compilation de l'application en mode AOT, nous obtiendrons une erreur.
Charger l'état sous forme de flux dans le composant conteneur
Pour charger et initialiser l'état, nous utiliserons des services angulaires ordinaires. Ils seront responsables des scénarios suivants:
- Un exemple classique est le chargement via HttpClient en utilisant le paramètre id obtenu par le composant du routeur.
- Initialisation d'un état vide lors de la création d'une nouvelle entité. Par exemple, si les champs ont des valeurs par défaut ou pour initialiser, vous devez demander des données supplémentaires au backend.
- Redémarrage d'un état déjà chargé après que l'utilisateur a effectué une opération qui modifie les données vers le backend.
- Redémarrage de l'état par notification push, par exemple lors de la co-édition de données. Dans ce cas, le service fusionne l'état local et l'état obtenu à partir du backend.
Dans l'application de démonstration, nous considérerons les deux premiers scénarios comme les plus typiques. De plus, ces scénarios sont simples et permettent au service d'être implémenté comme de simples objets sans état et de ne pas être distrait par la complexité, ce qui n'est pas le sujet de cet article particulier.
Un exemple de service se trouve dans le fichier
some-entity.service.ts .
Il reste à obtenir le service via DI dans le composant conteneur et l'état de chargement. Cela se fait généralement comme ceci:
route.params .pipe( pluck('id'), filter((id: any) => { return !!id; }), switchMap((id: string) => { return myFormService.get(id); }) ) .subscribe(state => { this.state = state; });
Mais avec cette approche, deux problèmes se posent:
- Vous devez vous désabonner manuellement de l'abonnement créé, sinon une fuite de mémoire se produira.
- Si vous basculez le composant vers la stratégie OnPush, il cessera de répondre au chargement des données.
Le tuyau asynchrone vient à la rescousse. Il écoute directement l'Observable et se désabonne de lui si nécessaire. De plus, lorsque vous utilisez un canal asynchrone, Angular déclenche automatiquement la détection de changement chaque fois que l'Observable publie une nouvelle valeur.
Un exemple d'utilisation du canal asynchrone peut être trouvé dans le modèle du
composant some-entity.component .
Et dans le code du composant, nous avons supprimé la logique répétée dans les opérateurs RxJS personnalisés, ajouté le script pour créer un état vide, fusionnant les deux sources d'état en un seul flux avec l'opérateur de fusion et créant un formulaire pour l'édition, dont nous discuterons plus tard:
this.state$ = merge( route.params.pipe( switchIfNotEmpty("id", (requestId: string) => requestService.get(requestId) ) ), route.params.pipe( switchIfEmpty("id", () => requestService.getEmptyState()) ) ).pipe( tap(state => { this.form = new SomeEntityFormGroup(state); }) );
C'est tout ce qui devait être fait dans le composant conteneur. Et nous avons mis dans la tirelire la première façon d'appeler la détection de changement dans le composant OnPush - le tuyau asynchrone. Il nous sera utile plus d'une fois.
Distribuer l'état à une hiérarchie de composants OnPush
Lorsque vous devez afficher un état complexe, nous créons une hiérarchie de petits composants - c'est ainsi que nous gérons la complexité.
En règle générale, les composants sont divisés en une hiérarchie similaire à la hiérarchie des données, et chaque composant reçoit sa propre donnée via les paramètres d'entrée pour les afficher dans le modèle.
Puisque nous allons implémenter tous les composants en tant que OnPush, nous allons nous éloigner un instant et discuter de ce que c'est et comment Angular fonctionne avec les composants OnPush. Si vous connaissez déjà ce matériel - n'hésitez pas à faire défiler jusqu'à la fin de la section.
Lors de la compilation de l'application, Angular génère un détecteur de changement de classe spécial pour chaque composant, qui «se souvient» de toutes les liaisons utilisées dans le modèle de composant. Au moment de l'exécution, la classe générée commence à vérifier les expressions stockées à chaque boucle de détection de changement. Si la vérification a montré que le résultat d'une expression a changé, Angular redessine le composant.
Par défaut, Angular ne sait rien de nos composants et ne peut pas déterminer quels composants il affectera, par exemple, le setTimeout qui vient d'être déclenché ou une requête AJAX qui s'est terminée. Par conséquent, il est obligé de vérifier l'application entière littéralement pour chaque événement à l'intérieur de l'application - même un simple défilement de fenêtre déclenche à plusieurs reprises la détection de changement pour la hiérarchie entière des composants de l'application.
Ici se trouve une source potentielle de problèmes de performances - plus les modèles de composants sont complexes, plus les vérifications du détecteur de changement sont difficiles. Et s'il y a beaucoup de composants et que les contrôles sont exécutés souvent, la détection des modifications commence alors à prendre un temps considérable.
Que faire?
Si le composant ne dépend d'aucun effet global (en passant, il est préférable de concevoir les composants de cette manière), alors son état interne est déterminé par:
- Paramètres d'entrée ( @Input );
- Événements qui se sont produits dans le composant lui-même ( @Output ).
Nous allons reporter le deuxième point pour l'instant et supposons que l'état de notre composant ne dépend que des paramètres d'entrée.
Si tous les paramètres d'entrée du composant sont des objets immuables, nous pouvons marquer le composant comme OnPush. Ensuite, avant d'exécuter la détection des modifications, Angular vérifiera si les liens vers les paramètres d'entrée du composant ont changé depuis la vérification précédente. Et, s'ils n'ont pas changé, Angular ignorera la détection de changement pour le composant lui-même et tous ses composants enfants.
Ainsi, si nous construisons l'intégralité de notre application selon la stratégie OnPush, nous éliminerons toute une classe de problèmes de performances dès le début.
Étant donné que l'état dans notre application est déjà immuable, les objets immuables sont également transférés vers les paramètres d'entrée des composants enfants. Autrement dit, nous sommes prêts à activer OnPush pour les composants enfants et ils réagiront aux changements d'état.
Par exemple, il s'agit des
composants readonly -info.component et
nested-items.componentVoyons maintenant comment implémenter la modification de l'état des composants dans le paradigme OnPush.
Parlez à Angular de votre état
État de présentation - ce sont les paramètres qui sont responsables de l'apparence du composant: indicateurs de chargement, drapeaux de visibilité des éléments ou accessibilité à l'utilisateur de l'une ou l'autre action, collés de trois champs à une ligne, nom complet de l'utilisateur, etc.
Chaque fois que l'état de présentation d'un composant change, nous devons en informer Angular afin qu'il puisse afficher les modifications sur l'interface utilisateur.
Selon la source de l'état du composant, il existe plusieurs façons de notifier Angular.
État de présentation, calculé en fonction des paramètres d'entrée
C'est l'option la plus simple. Nous plaçons la logique de calcul de l'état de présentation dans le crochet ngOnChanges. La détection de changement commencera d'elle-même en changeant @ Input-parameters. Dans la démo, c'est
readonly-info.component .
export class ReadOnlyInfoComponent implements OnChanges { @Input() public state: Backend.SomeEntityState; public traits: ReadonlyInfoTraits; public ngOnChanges(changes: { state: SimpleChange }): void { this.traits = new ReadonlyInfoTraits(changes.state.currentValue); } }
Tout est extrêmement simple, mais il y a un point auquel il faut prêter attention.
Si l'état de présentation du composant est complexe, et surtout si certains de ses champs sont calculés sur la base d'autres, également calculés par les paramètres Input, mettez l'état du composant dans une classe distincte, rendez-le immuable et recréez ngOnChanges à chaque démarrage. Dans un projet de démonstration, un exemple est la classe
ReadonlyInfoComponentTraits . En utilisant cette approche, vous vous protégez de la nécessité de synchroniser les données dépendantes lorsqu'elles changent.
Dans le même temps, cela vaut la peine d'être considéré: peut-être que le composant a un état difficile en raison du fait qu'il contient trop de logique. Un exemple typique est une tentative dans un composant d'ajuster des représentations pour différents utilisateurs qui ont des manières très différentes de travailler avec le système.
Événements natifs des composants
Pour la communication entre les composants d'application, nous utilisons des événements de sortie. C'est également la troisième façon d'exécuter la détection des modifications. Angular suppose raisonnablement que si un composant génère un événement, alors quelque chose pourrait avoir changé dans son état. Par conséquent, Angular écoute tous les événements de sortie de composant et les déclencheurs modifient la détection lorsqu'ils se produisent.
Dans le projet de démonstration, il est entièrement synthétique, mais un exemple est le composant
submit-button.component , qui génère un événement
formSaved . Le composant conteneur s'abonne à cet événement et affiche une alerte avec une notification.
Utilisez les événements de sortie pour leur objectif, c'est-à-dire, créez-les pour la communication avec les composants parents, et non pour déclencher la détection des modifications. Sinon, il est probable, après des mois et des années, de ne pas se rappeler pourquoi cet événement est inutile pour quiconque ici, et de le supprimer, de tout casser.
Changements dans les composants intelligents
Parfois, l'état d'un composant est déterminé par une logique complexe: appel asynchrone du service, connexion à une socket Web, vérifie l'exécution de setInterval, mais on ne sait jamais quoi d'autre. Ces composants sont appelés composants intelligents.
En général, moins les composants intelligents de l'application ne sont pas des composants de conteneur, plus il sera facile de vivre. Mais parfois, vous ne pouvez pas vous en passer.
Le moyen le plus simple d'associer l'état d'un composant intelligent à la détection de changement est de le transformer en observable et d'utiliser le
canal asynchrone déjà discuté ci-dessus. Par exemple, si la source des modifications est un appel de service ou un état de formulaire réactif, il s'agit d'un observable prêt à l'emploi. Si l'état est formé de quelque chose de plus complexe, vous pouvez utiliser
fromPromise ,
websocket ,
timer ,
interval from the composition of RxJS. Ou générez vous-même un flux à l'aide de
Subject .
Si aucune des options ne convient
Dans les cas où aucune des trois méthodes déjà étudiées ne convient, nous avons toujours une option pare-balles - en utilisant
ChangeDetectorRef directement. Nous parlons des méthodes detectChanges et markForCheck de cette classe.
Une documentation complète répond à toutes les questions, nous ne nous attarderons donc pas sur son travail. Mais notez que l'utilisation de
ChangeDetectorRef devrait être limitée aux cas où vous comprenez clairement ce que vous faites, car il s'agit toujours de la cuisine angulaire interne.
Pour le moment, nous n'avons trouvé que quelques cas où cette méthode peut être nécessaire:
- Travail manuel avec détection de changement - utilisé dans la mise en œuvre de composants de bas niveau et c'est juste le cas «vous comprenez clairement ce que vous faites».
- Relations complexes entre les composants - par exemple, lorsque vous devez créer un lien vers un composant dans un modèle et le transmettre en tant que paramètre à un autre composant situé plus haut dans la hiérarchie ou même dans une autre branche de la hiérarchie des composants. Cela semble compliqué? Il en est ainsi. Et il est préférable de simplement refactoriser un tel code, car cela causera de la douleur non seulement avec la détection des modifications.
- Les spécificités du comportement d'Angular lui-même - par exemple, lors de l'implémentation d'un ControlValueAccessor personnalisé , vous pouvez rencontrer que la valeur de contrôle est modifiée par Angular de manière asynchrone et les modifications ne sont pas appliquées au cycle de détection de changement souhaité.
Comme exemples d'utilisation dans l'application de démonstration, il existe la classe de base
OnPushControlValueAccessor , qui résout le problème décrit dans le dernier paragraphe. Toujours dans le projet, il y a un héritier de cette classe -
radio-button.component personnalisé .
Nous avons maintenant discuté des quatre façons d'exécuter la détection des modifications et les options d'implémentation OnPush pour les trois types de composants: conteneur, intelligent, de présentation. Nous passons au point final - l'édition des données avec des formes réactives.
Édition de données encapsulées
Les formes réactives ont un certain nombre de limites, mais c'est quand même l'une des meilleures choses qui se soient produites dans l'écosystème angulaire.
Tout d'abord, ils résument bien le travail avec l'État et fournissent tous les outils nécessaires pour répondre aux changements de manière réactive.
En fait, la forme réactive est une sorte de mini-magasin qui encapsule le travail avec l'état: données et statuts désactivés / valides / en attente.
Il nous reste à supporter autant que possible cette encapsulation et à éviter de mélanger présentation-logique et logique de la forme.
Dans l'application de démonstration, vous pouvez voir
des classes de formulaire individuelles qui encapsulent les spécificités de leur travail: validation, création de groupes de formulaires enfants, utilisation de l'état désactivé des champs de saisie.
Nous créons le formulaire racine dans le composant conteneur au moment où l'état est chargé, et à chaque redémarrage de l'état, le formulaire est recréé. Ce n'est pas une condition préalable, mais de cette façon, nous pouvons être sûrs qu'il n'y a aucun effet accumulé dans la logique de formulaire qui reste de l'état chargé précédent.
À l'intérieur du formulaire lui-même, nous construisons les contrôles et «poussons» les données qui en découlent, en les convertissant du contrat d'État au contrat modèle. La structure des formes correspond autant que possible aux contrats des modèles. Par conséquent, la propriété value du formulaire nous donne un modèle prêt à l'emploi pour l'envoi au backend.
Si à l'avenir l'état ou la structure du modèle change, nous obtiendrons une erreur de compilation typographique exactement à l'endroit où nous devons ajouter / supprimer des champs, ce qui est très pratique.
De plus, si les objets state et model ont une structure absolument identique, le typage structurel utilisé dans le script dactylographié élimine la nécessité de créer un mappage sans signification les uns des autres.
Au total, la logique de forme est isolée de la logique de présentation dans les composants et vit «par elle-même», sans augmenter la complexité du flux de données de notre application dans son ensemble.
C’est presque tout. Il reste des cas limites lorsque nous ne pouvons pas isoler la logique du formulaire du reste de l'application:
- Changements de forme entraînant un changement d'état de présentation - par exemple, visibilité d'un bloc de données en fonction de la valeur entrée. Nous l'implémentons dans le composant en souscrivant aux événements de formulaire. Vous pouvez le faire à travers les traits immuables discutés précédemment.
- Si vous avez besoin d'un validateur asynchrone qui appelle le backend, nous construisons AsyncValidatorFn dans le composant et le transmettons au constructeur de formulaire, pas au service.
Ainsi, toute logique «limite» reste à la place la plus importante - dans les composants.
Conclusions
Résumons ce que nous avons obtenu et les autres points à étudier et à développer.
Tout d'abord, le développement de la stratégie OnPush nous oblige à concevoir soigneusement le flux de données de l'application, puisque maintenant nous dictons les règles du jeu à Angular, et non à lui.
Il y a deux conséquences à cette situation.
Tout d'abord, nous obtenons une agréable sensation de contrôle sur l'application. Il n'y a plus de magie qui «fonctionne d'une manière ou d'une autre». Vous êtes clairement conscient de ce qui se passe à tout moment dans votre candidature. L'intuition se développe progressivement, ce qui vous permet de comprendre la raison du bogue trouvé, avant même d'ouvrir le code.
Deuxièmement, nous devons maintenant consacrer plus de temps à la conception de l'application, mais le résultat sera toujours la solution la plus «directe», et donc la plus simple. Cela met sensiblement à zéro la probabilité d'une situation où, à mesure que l'application se développe, elle devient un monstre d'une énorme complexité, les développeurs ont perdu le contrôle de cette complexité et le développement ressemble désormais davantage à des rites mystiques.
La complexité contrôlée et l'absence de «magie» réduisent la probabilité que toute une classe de problèmes survienne, par exemple, à partir de mises à jour cycliques de données ou d'accumulations d'effets secondaires. Au lieu de cela, nous traitons des problèmes déjà perceptibles pendant le développement, lorsque l'application ne fonctionne tout simplement pas. Et forcément, vous devez faire fonctionner l'application simplement et clairement.
Nous avons également mentionné de bons effets sur les performances. Désormais, à l'aide d'outils très simples, tels que
profiler.timeChangeDetection , nous pouvons à tout moment vérifier que notre application est toujours en bon état.
C'est aussi un péché de ne pas essayer de
désactiver NgZone . Tout d'abord, il vous permettra de ne pas charger toute la bibliothèque au démarrage de l'application. Deuxièmement, cela supprimera une bonne quantité de magie de votre application.
C'est là que nous terminons notre histoire.
Nous serons en contact!