Personnaliser la détection de changements dans une application Angular

Cet article fait partie de la série d’articles Angular from Scratch.

@raychelsnr

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.

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() ou setInterval().

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)
<p>Example composant</p>
<p>Click count: {{clickCount}}</p>
Classe du composant
(example.component.ts)
import { Component, HostListener } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html',
    styleUrls: ['./example.component.css']
})
export class ExampleComponent {
    clickCount = 0;

    @HostListener('click') onHostClicked(triggeredEvent): void {
        this.clickCount++;
    }
}
Style CSS
(example.component.css)
:host {
    width: 300px;
    height: 150px;
    background-color: lightblue;
    display: block;
    padding: 10px;
}

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:

Exemple de détection de changements avec click

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:

  1. Un click sur un élément natif déclenche un évènement click.
  2. Cet évènement se propage dans Zone.js.
  3. Zone.js déclenche une callback correspondant à du code Angular qui lancera la méthode tick().
  4. La méthode tick() permet de déclencher la détection de changements dans l’application en commençant par le composant Root.
  5. La détection de changements est déclenchée dans le composant Example ce qui va entraîner la mise à jour du binding initié par @HostListener(). Cette mise à jour met à jour la propriété clickCount.
  6. En parallèle, le template est mis à jour et l’expression d’interpolation {{clickCount}} est évaluée ce qui modifie la valeur (car la propriété clickCount a changé de valeur).
  7. Une comparaison entre la nouvelle valeur de l’expression d’interpolation et l’ancienne permet d’indiquer qu’un changement a été détecté.
  8. Le changement permet d’effectuer la mise à jour de l’objet natif dans le DOM.

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:
    1. 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.

    2. 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 si ngOnChanges() n’est pas déclenchée.
    3. ngDoCheck() permet d’indiquer des changements si Angular ne les a pas détecté.
    4. 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.
    5. 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.
    6. ngAfterViewInit(): déclenchée après l’initialisation de la vue du composant et après l’initialisation de la vue des composants enfant.
    7. ngAfterViewChecked() est déclenchée après détection d’un changement dans la vue du composant et dans la vue des composants enfant.
    8. ngOnDestroy() est déclenchée avant la destruction du composant.
  • A chaque détection de changements:
    1. ngOnChanges() si les paramètres en entrée du composant sont modifiés.
    2. ngDoCheck()
    3. ngAfterContentChecked() est déclenchée même s’il n’y a pas de contenu projeté.
    4. 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:

  1. Exécution des callbacks ngOnChanges(), ngOnInit() et ngDoCheck(). La callback ngOnInit() est exécutée seulement à l’initialisation du composant.
  2. Exécution de la fonction contentQueries: cette fonction permet de mettre à jour les requêtes effectuées sur le contenu projeté de la vue avec @ContentChild() ou @ContentChildren().
  3. Exécution des callbacks ngAfterContentInit() et ngAfterContentChecked(): l’exécution de ces callbacks vient clôturer la mise à jour des requêtes effectuées sur le contenu projeté. La callback ngAfterContentInit() est exécutée seulement à l’initialisation du composant.
  4. Exécution de la fonction hostBindings: cette fonction permet d’affecter les bindings provenant de l’élement HTML hôte avec @HostBinding().
  5. Exécution de la fonction template: elle servira à mettre à jour le DOM avec tous les éléments dynamiques de la vue.
  6. Exécution de la fonction viewQuery: cette étape permet de mettre à jour les requêtes effectuées sur la vue du composant avec @ViewChild() et @ViewChildren().
  7. Exécution des callbacks ngAfterViewInit() et ngAfterContentChecked(), la callback ngAfterViewInit() est exécutée seulement à l’initialisation du composant.

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.

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() et ngAfterViewChecked() 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
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
Classe du composant
import { Component, NgZone, OnInit } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private zone: NgZone) {}

    ngOnInit(): void {
        setInterval(() => {
            this.randomNumber = Math.random();
        }, 1000);
    }
}

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:

Exemple de détection de changements avec setInterval()

La propagation de l’évènement est effectuée de cette façon:

  1. La callback de la méthode setInterval() est déclenchée toutes les secondes.
  2. L’exécution de la callback permet de mettre à jour la propriété randomNumber.
  3. Cet évènement se propage dans Zone.js.
  4. La suite de la détection de changements s’exécute le long de l’arbre des composants.

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
<p>Example composant</p>
<p #random></p>
Classe du composant
import { Component, NgZone, OnInit, ViewChild, ElementRef } 
  from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    @ViewChild('random', { static: true }) 
        random: ElementRef<HTMLParagraphElement>

    constructor(private zone: NgZone) {}

    ngOnInit(): void {
        this.zone.runOutsideAngular(
            setInterval(() => {
                this.randomNumber = Math.random();
                this.random.nativeElement.textContent = 
                    `Random number: ${this.randomNumber}`;
            }, 1000);
        );
    }
}

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ément p 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
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
Classe du composant
import { Component, NgZone, OnInit } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private zone: NgZone) {}

    private updateRandomNumber(): void {
        this.randomNumber = Math.random();
    }

    ngOnInit(): void {
        this.zone.runOutsideAngular(
            setInterval(() => {
                this.updateRandomNumber();
            }, 1000);
        );
    }
}

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 avec ChangeDetectorRef ou ApplicationRef.

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
<p>Example composant</p>
Classe du composant
import { Component, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent {
    constructor(private changeDetectorRef: ChangeDetectorRef) {}
}

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
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
Classe du composant
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private changeDetectorRef: ChangeDetectorRef) {}

    ngOnInit(): void {
        setInterval(() => {
            this.randomNumber = Math.random();
        }, 1000);
    }
}

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
<p>Example composant</p>
<p>Random number: {{randomNumber}}</p>
<p><button (click)='disableChangeDetection()'>Disable change detection</button></p>
<p><button (click)='enableChangeDetection()'>Enable change detection</button></p>
Classe du composant
import { Component, OnInit, ChangeDetectorRef } from '@angular/core';

@Component({
    selector: 'app-example',
    templateUrl: './example.component.html'
})
export class ExampleComponent implements OnInit {
    randomNumber: number;

    constructor(private changeDetectorRef: ChangeDetectorRef) {}

    ngOnInit(): void {
        setInterval(() => {
            this.randomNumber = Math.random();
        }, 1000);
    }

    disableChangeDetection(): void {
        this.changeDetectorRef.detach();
    }

    enableChangeDetection(): void {
        this.changeDetectorRef.reattach();
    }
}

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. Avec NgZone.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 = ... 
      
  • Forçant l’exécution de la détection de changements en injectant l’objet ApplicationRef et exécutant ApplicationRef.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().
Références

Leave a Reply