Cet article fait partie de la série d’articles Angular from Scratch.
Le but de cet article est d’expliquer quelques caractéristiques de la détection automatique de changements dans une application Angular. Ensuite, on indiquera comment on peut personnaliser cette détection pour améliorer les performances ou pour la solliciter de façon explicite.
Un autre article permet de rentrer dans les détails du fonctionnement de cette détection de changements: Fonctionnement de la détection de changement.
Détection des changements et cycle de vie des composants
Erreur “ExpressionChangedAfterItHasBeenCheckedError”
Liens entre un composant parent et un composant enfant
Ordre d’exécution de la détection de changements dans l’arbre des composants
Contourner la détection de changements
Mettre à jour la vue directement
Déclencher la détection de changements dans toute l’application
Personnaliser la détection de changements
ChangeDetectionStrategy
ChangeDetectorRef
Désactiver/Activer la détection de changements
Vérifier si des changements sont détectés
Une application Angular peut être impactée par des évènements provenant du browser comme:
- Les évènements Javascript comme les clicks,
keydown
,submit
,input
etc… (Référence des événements), - Les évènements XHR provenant de l’objet
XmlHttpRequest
, - Les évènements provenant de Timers comme
setTimeout()
ousetInterval()
.
Certains de ces évènements peuvent entraîner des modifications sur des éléments de l’application et nécessiter une mise à jour des vues de certains composant. La détection automatique des changements provoqués par ces évènements permet de mettre à jour les éléments dans le DOM quand cela est nécessaire.
Ainsi, les changements pouvant impacter la vue d’un composant peuvent provenir de:
- La mise à jour d’un binding dans un composant,
- La mise à jour d’une expression dans une interpolation d’un template.
D’autres changements peuvent impacter des bindings se trouvant dans la classe du composant comme:
- Les requêtes de vue avec
@ViewChild()
ou@ViewChildren()
, - Les requêtes sur du contenu projeté avec
@ContentChild()
ou@ContentChildren()
. - Des bindings avec un élément natif hôte concernant des propriétés
@HostBinding()
ou des évènements avec@HostListener()
.
Lorsqu’au moins une des sources impacte un élément d’une vue, la détection de changements est exécutée en comparant les références des anciens bindings et les nouveaux de façon à détecter un changement. Dans le cas où un changement est détecté, les éléments natifs correspondant dans le DOM sont modifiés (pour plus de détails, voir le fonctionnement de la détection de changements).
Par exemple, si on considère le composant suivant:
Template (example.component.html) |
|
Classe du composant (example.component.ts) |
|
Style CSS (example.component.css) |
|
Dans cet exemple, on utilise @HostListener()
pour s’abonner à l’évènement click de l’élément natif de la vue du composant de façon à effectuer un traitement pour chaque click sur cet élément.
A l’exécution, la vue de ce composant se présente de cette façon:
Si on clique sur la zone en bleu, le compteur s’incrémente. Pour que la propriété clickCount
soit incrémentée, l’évènement est propagé de cette façon dans Angular:
|
Détection des changements et cycle de vie des composants
La détection des changements est liée à l’exécution des callbacks du cycle de vie des composants (i.e. lifecycle hooks). Ainsi, la vérification des changements et la mise à jour éventuelle des éléments du DOM sont effectuées dans un certain ordre. Modifier un binding de façon trop tardive par rapport à la détection de changements dans le cycle de vie d’un composant peut ne pas entraîner de modifications dans la vue.
Le cycle de vie d’un composant est, dans l’ordre de déclenchement:
- A l’initialisation du composant:
ngOnChanges()
: cette callback est exécutée si le composant contient des propriétés en entrée (notamment avec le décorateur@Input()
). Si cette callback est implémentée sans paramètre, elle sera déclenchée autant de fois qu’il y a de propriétés en entrée du composant.Si la callback est implémentée avec un argument de type
SimpleChanges
:void ngOnChanges(changes: SimpleChanges): void {}
Elle sera déclenchée une seule fois.
ngOnInit()
: déclenchée après l’exécution du constructeur. Elle permet d’initialiser le composant avec le 1er affichage des données de la vue ayant un binding avec des propriétés de la classe du composant. Cette callback est déclenchée une seule fois à l’initialisation du composant même singOnChanges()
n’est pas déclenchée.ngDoCheck()
permet d’indiquer des changements si Angular ne les a pas détecté.ngAfterContentInit()
est déclenchée à l’initialisation après la projection de contenu. Elle est déclenchée même s’il n’y a pas de contenu à projeter.ngAfterContentChecked()
: déclenchée après la détection de changement dans le contenu projeté. Cette callback est déclenchée même s’il n’y a pas de projection de contenu.ngAfterViewInit()
: déclenchée après l’initialisation de la vue du composant et après l’initialisation de la vue des composants enfant.ngAfterViewChecked()
est déclenchée après détection d’un changement dans la vue du composant et dans la vue des composants enfant.ngOnDestroy()
est déclenchée avant la destruction du composant.
- A chaque détection de changements:
ngOnChanges()
si les paramètres en entrée du composant sont modifiés.ngDoCheck()
ngAfterContentChecked()
est déclenchée même s’il n’y a pas de contenu projeté.ngAfterViewChecked()
.
Lors de l’exécution de la détection de changements, la mise à jour des éléments est effectuée de la façon suivante par rapport aux callbacks du cycle de vie:
|
Erreur “ExpressionChangedAfterItHasBeenCheckedError”
Dans le cas où une modification est effectuée trop tardivement, une erreur du type ExpressionChangedAfterItHasBeenCheckedError
peut se produire en mode développement.
Pour davantage de détails, voir cdiese.fr/angular-change-detection.
Liens entre un composant parent et un composant enfant
Au cours de son exécution, l’algorithme de détection de changements ne s’applique pas qu’à un composant, il parcourt aussi toutes les directives enfant de ce composant. Un composant parent peut être lié aux directives enfants de multiples façons comme par exemple:
- Un binding entre une propriété et un paramètre d’entrée du composant enfant,
- Une requête sur un contenu projeté dans le composant enfant avec
@ContentChild()
, - Une requête sur le composant natif hôte du composant enfant avec
@HostListener()
. - Etc…
Toutes ces fontionnalités entre un composant parent et un composant enfant imposent à l’algorithme de détection de prendre en compte ce lien et surtout un ordre dans la mise à jour des objets dans le DOM.
Au niveau d’un composant, du point de vue de la détection de changements on pourrait résumer le lien avec son parent avec ce schéma (pour davantages de détails, voir le fonctionnement de la détection de changements):
Ce schéma permet de montrer que certains éléments d’un composant sont mis à jour de façon concomitante avec des traitements du composant parent.
Ordre d’exécution de la détection de changements dans l’arbre des composants
Etant donné les liens entre un composant parent et ses enfants, à l’échelle de plusieurs composants, l’ordre d’exécution des traitements de la détection de changements dans l’arbre des composants n’est pas forcément trivial.
Si on reprend le schéma plus haut permettant de schematiser l’exécution de l’algotihme de détection de changements entre un composant parent et un composant enfant, on peut le séparer en 2 parties: avant et après la boucle récursive de parcours des composants enfant:
Ces 2 parties amènent 2 comportements différents qui entraînent un parcours différents de l’arbre des composants:
- Avant la boucle récursive: les traitements et exécution des callbacks du cycle de vie sont exécutés dans l’arbre des composants parent vers les composants enfant:
Ainsi les callbacks
ngOnChanges()
,ngOnInit()
,ngDoCheck()
,ngAfterContentInit()
,ngAfterContentChecked()
et la fonction template permettant de mettre à jour le DOM sont exécutées dans cette partie. Dans le cadre de l’exemple, elles seront déclenchées dans cet ordre: d’abord les composants parent et ensuite les composants enfant. - Après la boucle récursive: les traitements et exécution des callbacks sont exécutés des composants enfant vers les composants parents en allant d’abord vers le composant le plus profond dans l’arbre:
Les callbacks
ngAfterViewInit()
etngAfterViewChecked()
sont exécutées dans cette partie. Dans le cadre de l’exemple, elles seront déclenchées dans cet ordre: d’abord les composants enfant et ensuite les composants parent.
Contourner la détection de changements
Dès qu’un évènement notifie Zone.js, il déclenche la détection de changements dans Angular. Si on souhaite exécuter du code sans que la détection de changements ne soit sollicitée, on peut utiliser l’objet NgZone
.
En injectant un objet de type NgZone
dans le constructeur d’un composant, on obtient un service permettant d’effectuer des traitements sur la zone parente de la vue du composant:
import { Component, NgZone } from '@angular/core';
@Component({
// ...
})
export class ExampleComponent {
constructor(private zone: NgZone) {}
}
Le service NgZone
permet d’exécuter des lambdas avec les fonctions:
runOutsideAngular()
pour exécuter du code en dehors de la détection de changements Angular.run()
exécute du code dans une zone Angular. La détection de changements sera exécutée.runTask()
permet d’exécuter du code dans une zone Angular de façon asynchrone. La détection de changements sera exécutée.
Par exemple, si on considère le composant suivant:
Template |
|
Classe du composant |
|
Au moyen de setInterval()
, on peut mettre à jour la propriété randomNumber
toutes les secondes avec un nombre aléatoire. Une interpolation dans le template du composant permet d’afficher la propriété randomNumber
dans la vue.
A l’exécution, on peut voir que le nombre est mis à jour toutes les secondes:
La propagation de l’évènement est effectuée de cette façon:
|
Si on modifie la méthode ngOnInit()
dans la classe du composant de cette façon:
ngOnInit(): void {
this.zone.runOutsideAngular(
setInterval(() => {
this.randomNumber = Math.random();
}, 1000);
);
}
Après cette modification, la mise à jour de la vue n’est plus effectuée. La propriété est bien mise à jour par setInterval()
toutefois la détection de changements n’est pas déclenchée:
Mettre à jour la vue directement
Quand on utilise NgZone.runOutsideAngular()
, il est possible de mettre à jour la vue sans utiliser la détection de changement en intervenant directement dans l’objet natif de la vue.
Par exemple, si on reprend le composant Example
introduit plus haut, on peut utiliser @ViewChild()
avec une variable référence dans le template pour effectuer une requête sur la vue de façon à pouvoir accéder à l’objet natif dans lequel on veux mettre à jour l’affichage. L’implémentation deviendrait:
Template |
|
Classe du composant |
|
Ainsi:
- Ce code permet d’effectuer une requête sur la vue du composant avec
@ViewChild()
pour récupérer un objet identifié avec la variable référence#random
dans le template. Cet objet permettra d’accéder à l’objet natif correspondant. - On utilise l’option
{ static: true }
dans@ViewChild()
de façon à ce que la requête soit exécutée lors de la construction des éléments statiques de la vue. - Avec la propriété
nativeElement
, on peut accéder à l’objet natif correspond à l’élémentp
de façon à modifier son contenu.
A l’exécution, on peut voir que le nombre est mise à jour chaque seconde dans la vue du composant.
Déclencher la détection de changements dans toute l’application
A l’opposé, on peut volontairement lancer l’exécution de la détection de changements de toute l’application en injectant l’objet ApplicationRef
et en appelant ApplicationRef.tick()
.
Par exemple, si on réutilise l’exemple précédent de ce composant:
Template |
|
Classe du composant |
|
Cet exemple ne permet pas de mettre à jour la vue avec la propriété randomNumber
car NgZone.runOutsideAngular()
ne déclenche pas la détection de changements.
Pour forcer la détection de changements à chaque exécution de la lambda dans setInternal()
, on peut rajouter ApplicationRef.tick()
. On modifie la méthode ngOnInit()
dans ce sens:
import { Component, NgZone, OnInit, ApplicationRef } from '@angular/core';
@Component({
selector: 'app-example',
templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
randomNumber: number;
constructor(private zone: NgZone, private ApplicationRef: ApplicationRef) {}
private updateRandomNumber(): void {
this.randomNumber = Math.random();
this.applicationRef.tick();
}
ngOnInit(): void {
this.zone.runOutsideAngular(
setInterval(() => {
this.updateRandomNumber();
}, 1000);
);
}
}
Dans ce code, this.applicationRef.tick()
permet de lancer l’exécution de la détection de changements à chaque exécution de la lambda dans setInterval()
. A l’exécution, on peut voir que la vue est correctement mise à jour: la valeur du nombre change toutes les secondes.
Personnaliser la détection de changements
2 méthodes permettent de mettre à jour une vue en cas de changements:
- Détecter les changements de façon automatique: l’algorithme de détection effectue les comparaisons nécessaires pour savoir quand un changement nécessite une mise à jour de propriétés dans le DOM. L’algorithme détecte le changement de valeur sans savoir d’où provient ce changement.
- Mettre à jour la vue à la demande: en cas de changements, on peut solliciter explicitement la détection de changements pour que la vue soit mise à jour à la demande. L’algorithme de détection de changements et la mise à jour des éléments natifs du DOM sont indissociables. On ne peut pas exécuter l’un sans l’autre. En revanche, il est possible de désactiver la détection automatique pour privilégier une exécution à la demande pour maitriser la mise à jour d’une vue ou pour améliorer les performances d’exécution.
Dans cette partie, on va expliciter des méthodes pour désactiver la détection automatique de changements ou pour lancer explicitement son exécution.
ChangeDetectionStrategy
Modifier le paramètre changeDetection
d’un composant (dans le décorateur @Component()
) permet de modifier le comportement de la détection:
ChangeDetectionStrategy.Default
: déclenchement automatique de la détection de changements, c’est le comportement par défaut. La détection est exécutée pour le composant et ses enfants de façon automatique.ChangeDetectionStrategy.OnPush
: désactive la détection automatique des changements pour le composant et pour ses enfants. Il faut déclencher la détection de la façon explicite avecChangeDetectorRef
ouApplicationRef
.
Modifier le paramètre changeDetection
permet de désactiver la détection pour toutes les instances du composant et pour toute sa durée de vie. Cette implémentation est moins flexible qu’en utilisant ChangeDetectorRef
(même si on exécute ChangeDetectorRef.reattach()
, la détection automatique restera désactivée).
Par exemple, si on modifie le paramètre changeDetection
dans le cas du composant Example
, l’implémentation devient:
@Component({
selector: 'app-example',
templateUrl: './example.component.html',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ExampleComponent implements OnInit {
// ...
}
ChangeDetectorRef
En injectant ChangeDetectorRef
dans un composant, on peut personnaliser la détection de changements pour:
- Désactiver son exécution automatique avec
ChangeDetectorRef.detach()
, - Ré-activer la détection automatique avec
ChangeDetectorRef.reattach()
, - Forcer l’exécution de la détection avec
ChangeDetectorRef.detectChanges()
, - Vérifier si des changements sont détectés avec
ChangeDetectorRef.checkNoChanges()
ou - Marquer la vue du composant pour que la détection de changements soit effectuée avec
ChangeDetectorRef.markForCheck()
.
On peut injecter un objet de type ChangeDetectorRef
en utilisant le moteur d’injection de dépendances Angular, par exemple:
Template |
|
Classe du composant |
|
L’instance injectée de l’objet de type ChangeDetectorRef
est spécifique à la vue du composant.
Désactiver/Activer la détection de changements
La détection de changements peut être désactivée ou activée par programmation de façon à optimiser les performances d’une vue. Lorsque la détection est désactivée pour un composant donné, la détection ne sera plus exécutée de façon automatique sur la vue du composant et sur les vues des composant enfant:
Pour exécuter la détection de changements à la demande, il faudra explicitement exécuter ChangeDetectorRef.detectChanges()
ou ChangeDetectorRef.markForCheck()
sur le composant pour lequel la vue doit être mise à jour. En cas d’exécution explicite de la détection pour un composant donné, la détection sera effectuée sur le composant et sur toutes ses directives enfants.
Par exemple, si on considère un composant pour lequel on place un Timer qui permet de mettre à jour périodiquement la valeur d’une propriété de type number
et qu’on affiche cette propriété sur la vue en utilisant une interpolation:
Template |
|
Classe du composant |
|
Si on exécute ce code, on pourra voir que l’affichage est mise à jour toutes les secondes.
On ajoute 2 boutons pour désactiver et activer la détection de changements:
Template |
|
Classe du composant |
|
A l’exécution, on peut voir que le nombre ne sera plus mis à jour dans la vue si on clique sur “Disable change detection”. La mise à jour est réativée si on clique sur “Enable change detection “.
Ainsi, quand on a exécuté ChangeDetectorRef.detach()
dans le composant Example
, la détection de changements est désactivée pour ce composant et pour les composants enfant:
Vérifier si des changements sont détectés
On peut se demander l’utilité de cette méthode en sachant qu’elle déclenche une erreur dans le cas où un changement est détecté. La raison est que cette détection a pour but d’éviter des erreurs d’implémentation en vérifiant que des changements ne sont pas effectués trop tardivement dans le cycle de vie d’un composant (cf. ExpressionChangedAfterItHasBeenCheckedError
).
La vérification peut être effectuée en exécutant:
ChangeDetectorRef.checkNoChanges();
En conclusion
Pour résumer, on peut personnaliser la détection de changements en:
- Contournant l’exécution de la détection: en injectant l’objet
NgZone
dans le composant. AvecNgZone.runOutsideAngular()
, on peut exécuter du code qui n’aboutira pas à la détection automatique de changements. - Forçant la mise à jour d’un élément d’une vue en modifiant directement l’objet natif, par exemple:
- En effectuant une requête sur la vue avec
@ViewChild()
pour obtenir l’objet natif:@ViewChild('elementName', { static: true }) element: ElementRef<HTMLParagraphElement>
- En affectant une valeur avec la propriété
nativeElement
:this.element.nativeElement.textContent = ...
- En effectuant une requête sur la vue avec
- Forçant l’exécution de la détection de changements en injectant l’objet
ApplicationRef
et exécutantApplicationRef.tick()
. - Désactivant définitivement la détection dans un composant avec le paramètre
changeDetection
dans le décorateur@Component()
:@Component({ selector: ..., templateUrl: ..., changeDetection: ChangeDetectionStrategy.OnPush })
- En injectant l’objet
ChangeDetectorRef
dans le constructeur pour:- Désactiver l’exécution automatique de la détection en exécutant
ChangeDetectorRef.detach()
, - Ré-activer la détection automatique en exécutant
ChangeDetectorRef.reattach()
, - Forcer l’exécution de la détection avec
ChangeDetectorRef.detectChanges()
, - Vérifier si des changements sont détectés en exécutant
ChangeDetectorRef.checkNoChanges()
ou - Marquer la vue du composant pour que la détection de changements soit effectuée en exécutant
ChangeDetectorRef.markForCheck()
.
- Désactiver l’exécution automatique de la détection en exécutant
- NgZone: https://angular.io/guide/zone
- Angular Performances Part 4 – Change detection strategies: https://blog.ninja-squad.com/2018/09/27/angular-performances-part-4/
- Angular Change Detection – How Does It Really Work?: https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/
- Understanding Change Detection Strategy in Angular: https://alligator.io/angular/change-detection-strategy/
- He who thinks change detection is depth-first and he who thinks it’s breadth-first are both usually right: http://indepth.dev/he-who-thinks-change-detection-is-depth-first-and-he-who-thinks-its-breadth-first-are-both-usually-right