Fonctionnement de la détection de changement dans une application Angular

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

@dlohmar

La détection de changements est un sujet important car l’algorithme qui le gère est le même que celui qui met à jour le DOM et qui déclenche les callbacks du cycle de vie d’un composant (i.e. lifecycle hooks). Certains comportements d’Angular découlent directement de cet algorithme de détection.

Le but de cet article est de rentrer dans quelques détails de l’implémentation du mécanisme de détection de changements en s’appuyant sur le code d’Angular 9 sur GitHub. Le code concernant ce mécanisme a été beaucoup modifié entre les versions 7 et 8, les explications de cet article peuvent ne pas correspondre pour les versions précédant la version 9. Les mécanismes permettant de provoquer la détection de changements avec ChangeDetectorRef ne seront pas abordés dans cet article mais dans Personnaliser la détection de changements.

Pourquoi effectuer une détection de changements ?

Pour beaucoup de frameworks Javascript, il existe, au moins 2 versions des objets présentés sur la page web:

  • Une version maintenue complètement par le framework: il peut modifier ces objets ou leurs propriétés à sa guise. Cette version contient des propriétés plus ou moins spécifiques au framework. Dans le cas d’Angular, ces objets contiennent des références vers les objets équivalents dans le DOM.
  • Le DOM contenant des objets dont les propriétés sont utilisées pour présenter des éléments HTML sur l’interface.

Pour qu’une application puisse être fonctionnelle, il faut qu’elle puisse réagir aux différents évènements extérieurs correspondant à tous les évènements Javascript possibles. Ces évènements peuvent provoquer l’exécution d’un traitement dans l’application qui pourra éventuellement mettre à jour un élément graphique. Pour mettre à jour un élément graphique, Angular doit s’interfacer avec le DOM pour créer ou supprimer des objets ou en modifier des propriétés.

Ces accès multiples au DOM sont coûteux en performance. Ainsi, afin d’en limiter au maximum les accès et de modifier les propriétés au minimum, la solution d’Angular est de détecter automatiquement les changements nécessitant une modification dans le DOM. Cette détection s’exécute dans la version des objets maintenue par Angular. Si Angular détecte un changement, il répercute ce changement dans le DOM de façon à ce que les éléments graphiques correspondant puissent être modifiés.

Cette détection automatique présente de multiples avantages:

  • Les accès au DOM sont limités aux cas où un changement est nécessaire,
  • Graphiquement les changements permettent de modifier certains éléments sans recharger la page, ce qui limite les manipulations effectuées sur les éléments graphiques et améliore l’expérience utilisateur.
  • Enfin le développeur est moins préoccupé par la mise à jour des éléments graphiques d’un composant puisqu’Angular encapsule une grande partie de la complexité des bindings entre des objets du code et les éléments graphiques correspondant.

Zone.js

De façon à éventuellement mettre à jour des éléments graphiques, la détection de changements doit s’exécuter quand un évènement se déclenche dans le browser. Ce déclenchement est possible gràce à la bibliothèque Zone.js. Zone.js a la particularité de pouvoir intercepter les évènements asynchrones comme setInterval() et de fournir à Angular un contexte d’exécution de cet évènement. Il notifie ensuite Angular qui déclenche l’algorithme de détection de changements.

Les évènements extérieurs à Angular qui peuvent subvenir sont:

  • 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().

Tous les évènements notifiés par Zone.js ne nécessitent pas forcément une modification d’un élément graphique. C’est le mécanisme de détection de changements qui va permettre de filtrer les évènements pour ne garder que les évènements pertinents qui affectent réellement un objet de l’interface de façon . Pour ne pas trop affecter les performances et le temps d’exécution, cette détection de changements ne doit pas être trop coûteuse.

Cycle de vie des composants

L’algorithme de détection de changements effectue les mises à jour des éléments graphiques suivant un ordre précis. Tout au long de ces mises à jour et suivant les éléments qui sont mis à jour, il va aussi exécuter les callbacks du cycle de vie des composants. La détection de changements est donc très liée au cycle de vie des composants.

Ainsi, lors de la création et du rendu de la vue d’un composant, les callbacks peuvent être déclenchées de façon successive dans la classe du composant dans le cas où elles sont implémentées. Dans la documentation Angular ces callbacks sont appelées “lifecycle hooks”.

Le but de ces callbacks est de pouvoir interagir finiment avec le framework durant les différentes phases de la création d’un composant et de sa vue dans un premier temps ou lors de la détection d’un changement dans un 2e temps. La succession de ces différentes phases permet d’intervenir:

  • Dans le cas de l’initialisation d’un composant: avant ou après l’initialisation d’un élément particulier de ce composant (paramètres d’entrée, contenu projeté, requête sur la vue etc…)
  • Dans le cas de la détection d’un changement: avant ou après la vérification d’un changement sur un élément particulier du composant.

Ainsi, à l’initialisation d’un composant, les callbacks de ce cycle de vie (i.e. lifecycle hooks) sont, dans l’ordre de déclenchement:

  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, les callbacks déclanchées sont, dans l’ordre:

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

Lien entre la détection des changements et le cycle de vie des composants

Dans le cadre de la détection des changements, le cycle de vie des composants permet à Angular d’indiquer des étapes des vérifications qu’il effectue afin de détecter des éventuels changements, par exemple:

  • Après la création d’un contenu projeté, les callbacks ngAfterContentInit() et ngAfterContentChecked() seront déclenchées de façon à indiquer que le contenu a été vérifié et que les changements dans le contenu projeté ne seront plus vérifiés après cette phase.
  • De même après avoir terminé la création de la vue, lorsque les callbacks ngAfterViewInit() et ngAfterViewChecked() seront déclenchées, il n’y aura plus de vérification de changements.

Ainsi, si des modifications sont effectuées sur des éléments interagissant avec des vues après les détections de changements, les éléments correspondants sur les vues ne seront pas mis à jour. Il est donc important d’effectuer ces modifications au bon moment lors de l’exécution des callbacks du cycle de vie.

Erreur “ExpressionChangedAfterItHasBeenCheckedError”

L’erreur "ExpressionChangedAfterItHasBeenCheckedError" survient lors de l’exécution en mode développement pour indiquer qu’une modification a été effectuée trop tardivement par rapport à la détection de changements. Les conséquences de ces modifications ne seront probablement pas répercutées sur des éléments des vues.

L’erreur "ExpressionChangedAfterItHasBeenCheckedError" est déclenchée seulement en développement

L’erreur "ExpressionChangedAfterItHasBeenCheckedError" est déclenchée en développement pour indiquer qu’une erreur de conception risque de compromettre la détection de changement et empêcher la mise à jour correcte de la vue. En production, Angular n’effectue pas de vérifications pouvant amener aux déclenchements de cette erreur.

Par exemple, si considère un composant pour lequel on implémente:

  • Les callbacks ngAfterContentInit(), ngAfterContentChecked(), ngAfterViewInit() et ngAfterViewChecked(),
  • Une interpolation dans le template vers une propriété de la classe du composant.

L’implémentation est:

Template
{{updatedValue}} 
Classe du composant
import { Component, ngAfterViewInit, ngAfterViewChecked, 
    ngAfterContentInit, ngAfterContentChecked } from '@angular/core'; 

@Component({ 
    templateUrl: './example.component.html' 
})    
export class ExampleComponent implements ngAfterViewInit, ngAfterViewChecked, 
    ngAfterContentInit, ngAfterContentChecked { 
    updatedValue = 'Not updated'; 

    ngAfterContentInit(): void {} 

    ngAfterContentChecked(): void { 
        this.updatedValue = 'Updated'; 
    } 

    ngAfterViewInit(): void {} 

    ngAfterViewChecked(): void {} 
}

Si on exécute en mode développement en lançant la commande suivante:

ng serve 

Le texte Updated sera correctement affiché. Si on modifie le code pour mettre à jour la valeur dans ngAfterViewInit() ou dans ngAfterViewChecked():

ngAfterContentChecked(): void { 
    //this.updatedValue = 'Updated'; 
} 

ngAfterViewInit(): void { 
    this.updatedValue = 'Updated'; 
} 

On constate l’erreur suivante dans la console du browser:

ExpressionChangedAfterItHasBeenCheckedError: Expression has changed after it was checked.  
Previous value: 'Not updated'. Current value: 'Updated'. It seems like 
the view has been created after its parent and its children have been dirty checked.  
Has it been created in a change detection hook?

Par suite, le texte contient la même valeur qu’à l’initialisation: Not updated.

Si on exécute en mode production en lançant la commande:

ng serve --prod 

L’erreur n’est pas affichée dans la console, toutefois le texte n’est pas mis à jour et contient toujours sa valeur initiale: Not updated.

La valeur n’est pas mise à jour dans le template car la modification de la propriété updatedValue est effectuée après la dernière détection de changements. En effet quand la callback ngAfterViewInit() est déclenchée, la vue est créée et Angular considère que des modifications ne doivent plus être effectuées à ce stade.

Dans la suite de cet article, on va expliquer plus en détails l’origine de ce problème et pourquoi Angular affiche cette erreur en mode développement.

Ivy

Depuis Angular 9, l’implémentation d’Angular a été complêtement modifiée pour que le view engine (appelé view Engine “render 2”) soit remplacé par Ivy (appelé view engine “render 3”). Ivy est un compilateur et un moteur de rendu dont les fonctionnalités et les performances sont meilleures que le view engine utilisé dans les versions précédentes notamment avec un rendu plus rapide avec la compilation AOT, la fonctionnalité Tree-Shaking et un runtime qui permet de mettre à jour le DOM de façon plus performante.

Compilation Ahead-of-Time (AOT)

La compilation AOT est le mode de compilation par défaut dans Angular à partir de la version 9 en opposition à la compilation Just-In-Time (JIT) utilisée dans les versions précédentes. La différence entre les 2 modes de compilation est que l’AOT effectue une compilation du code qui va permettre de modifier le DOM pendant la compilation de l’application (build time) et non lors de son exécution (runtime).

Dans le cadre de la compilation JIT (i.e. Just-In-Time), la compilation des templates est effectuée à l’exécution de façon à s’adapter au contexte d’exécution. Le compilateur se trouve, donc, dans le bundle Javascript chargé par le browser au moment d’exécuter l’application. Ce bundle comprend aussi les fichiers HTML correspondant aux templates, le compilateur utilise les fichiers HTML pour les convertir en code exécutable qui sera capable de mettre à jour le DOM. Les gros inconvénients de ce mode de compilation sont que:

  • Le bundle chargé à l’exécution est plus lourd à cause des fichiers HTML et surtout du compilateur,
  • Certaines erreurs surviennent lors de l’exécution et lors de la compilation au runtime.
  • L’application est plus lente puisque la compilation est nécessaire avant le rendu des vues.

A l’opposé, le compilation AOT (i.e. Ahead-of-Time) effectue la compilation des templates au moment de la compilation de l’application en produisant un code qui n’est plus dépendant du contexte d’exécution. Ainsi, le bundle chargé au moment de l’exécution ne contient ni le compilateur, ni les fichiers HTML correspondant aux templates. Le code des templates est déjà compilé et permet de mettre à jour le DOM efficacement sans compilation préalable. Les inconvénients de la compilation JIT sont levés avec la compilation AOT:

  • Le bundle est plus léger puisqu’il ne contient pas le compilateur et le code HTML,
  • Certaines erreurs liées aux vues peuvent être détectées au moment de la compilation,
  • L’exécution est plus rapide.

Tree-shaking

Le tree-shaking est une optimisation qui permet de ne pas inclure le code qui est inutile dans le bundle obtenu après compilation et chargé par le browser au runtime. L’intérêt de cette optimisation est de réduire la taille du bundle et s’avère particulièrement efficace car elle s’applique aussi au code du framework. Ivy permet de mettre en place cette optimisation lors de la compilation d’une application Angular.

Compilation d’un composant

Lors de la compilation avec Ivy, les metadatas se trouvant dans les décorateurs sont utilisées pour générer du code et des propriétés qui seront exécutées directement au runtime suivant les différentes mises à jour nécessaires des vues .

Ainsi quand on implémente un composant, on utilise le décorateur @Component() et on indique des metadatas dans ce décorateur. Le compilateur génère une factory permettant d’instancier le composant à l’exécution et de générer le code qui permettra de convertir ces metadatas en propriétés qui seront affectées à un objet qui sera la définition du composant.

Dans la définition de ce composant, on trouvera, par exemple un type, un template, des styles, des animations etc… Cette définition du composant sera compilée pour former un type qui pourra être instancié par la suite.

La compilation permet de générer le code qui sera exécuté au runtime pour mettre à jour le DOM. Par exemple, les méthodes les plus importantes permettant ces mises à jour sont:

  • template: le code de la vue contient du code statique avec des éléments HTML fixe et du code dynamique au travers des property bindings, event bindings ou interpolations.
  • contentQueries: ce code est exécuté pour mettre à jour des requêtes effectuées sur le contenu projeté des vues avec @ContentChild() ou @ContentChildren().
  • hostBindings: cette fonction est exécutée pour mettre à jour le binding entre une propriété d’un composant et une propriété d’un élément HTML hôte avec @HostBinding() ou @HostListener().
  • viewQuery: ce code est exécuté pour mettre à jour des requêtes effectuées sur le contenu des vues avec @ViewChild() ou @ViewChildren().

La compilation est effectuée par la méthode compileComponent().

Exemple du code généré pour un composant

Pour mieux comprendre la façon dont la définition du composant est compilée puis utilisée, on va montrer un exemple à partir d’un exemple simple. Si on considère le composant suivant:

Template
<p>Example component</p> 
{{updatedValue}}
Classe du composant
import { Component, OnInit } from '@angular/core'; 

@Component({ 
    selector: 'app-example', 
    templateUrl: './example.component.html' 
})    
export class ExampleComponent implements OnInit { 
    updatedValue: string; 
 
    ngOnInit(): void { 
        this.updatedValue = 'Updated value'; 
    } 
} 

Si on affiche ce composant et si on consulte le code source généré en Javascript (on peut trouver l’implémentation correspondant à la définition du composant dans le fichier main.js):

ExampleComponent.ɵfac = function ExampleComponent_Factory(t) { return new (t || ExampleComponent)(); }; 
ExampleComponent.ɵcmp = _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵdefineComponent"]({ 
type: ExampleComponent, 
selectors: [["app-example"]], decls: 3, vars: 1, 
template: function ExampleComponent_Template(rf, ctx) { if (rf & 1) { 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵelementStart"](0, "p"); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtext"](1, "Example component"); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵelementEnd"](); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtext"](2); 
    } if (rf & 2) { 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵadvance"](2); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtextInterpolate1"]("\n", ctx.updatedValue, "\n"); 
    } }, 
encapsulation: 2 }); 
/*@__PURE__*/ (function () { _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵsetClassMetadata"](ExampleComponent, [{ 
        type: _angular_core__WEBPACK_IMPORTED_MODULE_0__["Component"], 
        args: [{ 
                selector: 'app-example', 
                templateUrl: './example.component.html' 
            }] 
    }], function () { return []; }, null); })(); 

Ce code permet de définir dans ExampleComponent.ɵcmp la définition du composant Example contenant ses metadatas et son template.

Creation Mode

Avec Ivy, de façon à augmenter les performances, les fonctions utilisées pour mettre à jour le DOM sont divisées en 2 parties:

  • Une partie permettant de modifier le DOM avec le contenu statique d’une vue: la partie statique correspond aux éléments HTML statiques d’un template. Ces éléments ne seront pas modifiés dans le cas d’un changement d’un binding.
  • Une partie permettant de mettre à jour le DOM avec le contenu dynamique d’une vue: cette partie est modifiée si un élément dynamique est mise à jour comme un binding.

Ces 2 parties des fonctions ne sont pas exécutées au même moment. Elles sont exécutées suivant la valeur de l’enum RenderFlags qui matérialise l’étendu du rendu souhaité par Ivy:

export const enum RenderFlags { 
    /* Whether to run the creation block (e.g. create elements and directives) */ 
    Create = 0b01, 
    /* Whether to run the update block (e.g. refresh bindings) */ 
    Update = 0b10 
} 

Ainsi:

  • RenderFlags.Create: ce niveau de rendu permet d’exécuter la partie statique du code des fonctions. Il est exécuté à l’initialisation d’un composant au moment du rendu de la vue.
  • RenderFlags.Update: ce niveau de rendu exécute la partie dynamique du code des fonctions. Il est exécuté à l’initialisation d’un composant et quand la détection de changements a détecté un changement nécessitant la mise à jour du DOM.

De façon à optimiser les performances, l’utlisation de ces 2 niveaux de rendu permet d’eviter d’exécuter du code inutile lors de la mise à jour du contenu dynamique d’une vue. Ainsi quand un changement est détecté et que le contenu dynamique doit être mise à jour, il est inutile d’exécuter le code permettant de mettre à jour la partie statique de la vue.

Le niveau de rendu souhaité est piloté par l’activation du mode creation (i.e. creation mode) suivant les différentes étapes d’exécution:

  • A l’initialisation d’un composant:
  • Lors de la détection de changements:

Au niveau du code Angular, les 2 niveaux de rendu sont effectués par des méthodes différentes:

  • Création contenu statique: la méthode renderView() permet d’exécuter ce niveau. Par exemple, si on se limite au cas de l’appel à la fonction template, l’appel dans renderView() est du type:
    executeTemplate(tView, lView, templateFn, RenderFlags.Create, context); 
    

    On peut voir que la fonction template templateFn est appelée avec RenderFlags.Create.

  • Mise à jour contenu dynamique: ce niveau est exécuté par la méthode refreshView(). Dans le cas de l’appel à la fonction template, on peut voir l’appel dans refreshView():
    executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
    

    La fonction template templateFn est appelée avec RenderFlags.Update.

Si on prend le code de la fonction template résultant de la compilation par Ivy du code du template du composant ExampleComponent présenté plus haut, on peut voir les 2 parties correspondant au 2 modes:

function ExampleComponent_Template(rf, ctx) { if (rf & 1) { 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵelementStart"](0, "p"); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtext"](1, "Example component"); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵelementEnd"](); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtext"](2); 
    } if (rf & 2) {
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵadvance"](2); 
        _angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtextInterpolate1"]("\n", ctx.updatedValue, "\n"); 
    } } 

Ainsi:

  • if (rf & 1) permet d’exécuter le code si le mode est creation (i.e. si la valeur de rf est RenderFlags.Create).
  • if (rf & 2) permet d’exécuter le code si le mode est update (i.e. si la valeur de rf est RenderFlags.Update).

Interfaçage avec le DOM

Comme on a pu le dire précédemment, Ivy permet à Angular de s’interfacer avec le DOM au moyen de méthodes qui sont compilées en même temps que la compilation de l’application. Le code généré permet de créer des nœuds dans le DOM, d’insérer de nouveaux nœuds, de mettre à jour des propriétés d’éléments se trouvant dans le DOM etc…

Partie statique

Si on prend l’exemple de la fonction template du composant ExampleComponent. Le template de ce composant contient le code:

<p>Example component</p> 
{{updatedValue}} 

Le code statique de ce template se trouve dans la fonction template dans la partie correspondant au mode creation:

_angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵelementStart"](0, "p"); 
_angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtext"](1, "Example component"); 
_angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵelementEnd"](); 
_angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtext"](2); 

Si on regarde la 1ère ligne, elle permet d’exécuter la méthode ɵɵelementStart pour ajouter l’élément HTML p dans le DOM. Le code de cette méthode est:

function ɵɵelementStart(index, name, attrsIndex, localRefsIndex) { 
    const lView = getLView(); 
    const tView = getTView(); 
    // [...]     
    const native = lView[adjustedIndex] = elementCreate(name, renderer, getNamespace()); 
    // [...]     
    appendChild(tView, lView, native, tNode); 
    // [...]     
} 

La méthode ɵɵelementStart fait partie des méthodes utilisées pour s’interfacer avec le DOM. D’autres méthodes effectuent des opérations similaires permettant le rendu de la vue comme ɵɵtext pour ajouter du texte.

Le point commun de ces méthodes est de:

  • créer un élément natif avec la méthode elementCreate():
    function elementCreate(name, renderer, namespace) { 
        if (isProceduralRenderer(renderer)) { 
            return renderer.createElement(name, namespace); 
        } 
        else { 
            return namespace === null ? renderer.createElement(name) : 
                renderer.createElementNS(namespace, name); 
        } 
    } 
    
  • rajouter un nœud au DOM en utilisant la méthode appendChild() qui après d’autres appels va permettre de rajouter un élément au DOM:
    function nativeAppendChild(renderer: Renderer3, parent: RElement, child: RNode): void { 
        ngDevMode && ngDevMode.rendererAppendChild++; 
        ngDevMode && assertDefined(parent, 'parent node must be defined'); 
    
        if (isProceduralRenderer(renderer)) { 
            renderer.appendChild(parent, child);
        } else { 
            parent.appendChild(child); 
        } 
    }
    

Partie dynamique

Dans le cadre de la partie dynamique de la fonction template, on peut voir que le code permet de mettre à jour des propriétés dans le DOM. Par exemple en prenant la partie de la fonction template correspondant au mode update:

_angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵadvance"](2); 
_angular_core__WEBPACK_IMPORTED_MODULE_0__["ɵɵtextInterpolate1"]("\n", ctx.updatedValue, "\n");

On peut voir que cette partie permet de mettre à jour le texte interpolé correspondant au code:

{{updatedValue}} 

La 2e ligne permet de faire appel à la méthode ɵɵtextInterpolate1 qui, par suite, permettra d’affecter la valeur dans l’élément du DOM correspondant:

export function textBindingInternal(lView: LView, index: number, value: string): void { 
    // [...] 
    const renderer = lView[RENDERER]; 

    isProceduralRenderer(renderer) ? renderer.setValue(element, value) : element.textContent = value; 
} 

D’autres méthodes permettent d’effectuer des traitements similaires comme par exemple:

Fonctionnement de la détection de changements

Dans cette partie, on va détailler le fonctionnement d’Angular de façon à expliquer quelques subtilités de la détection de changements telle qu’elle fonctionne avec Ivy.

Comment détecter les changements ?

On pourrait se demander comment détecter les changements dans le contenu dynamique d’un composant. Intuitivement, 2 méthodes nous paraissent possibles:

  • 1ère méthode: dès qu’on effectue un changement sur une valeur, on indique explicitement que sa valeur a changé. L’inconvénient de cette méthode est qu’elle nécessite d’indiquer explicitement les changements et qu’il est plus difficile de l’automatiser.
  • 2e méthode: à chaque modification d’une valeur, on compare la valeur actuelle avec la valeur précédente pour savoir si la valeur a changé. Si la valeur est la même, il n’y a pas eu de changement; si la valeur est différente alors un changement est détectée. L’avantage de cette méthode sur la 1ère est qu’elle est facilement automatisable, il n’est pas nécessaire d’indiquer explicitement le changement de valeur. Son inconvénient majeur est qu’elle est plus complexe à mettre en œuvre.

Avec Angular, il est possible d’utiliser les 2 méthodes. On peut exécuter la détection de changements explicitement dans le cas où on désactive volontairement la détection automatique de changements ou si la détection automatique n’est pas suffisante. Une méthode pour solliciter explicitement la détection de changements est de faire appel à l’objet ChangeDetectorRef (le détail de cette méthode se trouve dans Personnaliser la détection de changements).

La 2e méthode est celle adoptée par Angular pour détecter les changements de façon automatique. A chaque fois qu’un évènement survient, Angular déclenche une méthode qui effectuera la détection de changements sur tous les éléments dynamiques des vues. Le changement est détecté en comparant les valeurs actuelles avec les valeurs précédentes, si les valeurs sont différentes alors des mises à jour des éléments correspondant dans le DOM sont effectuées.

Pour optimiser le traitement et éviter d’effectuer des mises à jour inutiles, seules les valeurs ayant été modifiées entraînent une mise à jour d’une propriété dans le DOM.

Ainsi pour résumer, la détection de changement repose sur 3 mécanismes:

  • Le déclenchement de la détection de changements,
  • La comparaison des valeurs pour savoir si la valeur a changé,
  • La mise à jour du DOM dans le cas où la valeur a changé.

Déclenchement de la détection de changements

Comme on l’a indiqué plus haut, les évènements qui permettent de déclencher la détection de changements sont exécutés par Zone.js.

Par exemple, si on considère le composant suivant:

Template
<p>Click example</p>
Classe du composant
import { Component, HostListener } from '@angular/core'; 

@Component({ 
    selector: 'app-example', 
    templateUrl: './example.component.html' 
}) 
 export class ExampleComponent { 
    @HostListener('click') onHostClicked(triggeredEvent) : void {  
        console.log(triggeredEvent); 
    } 
} 

Si on place un point d’arrêt dans la méthode ApplicationRef.tick() se trouvant dans le fichier core.js, on peut voir que son exécution est délenchée à chaque fois qu’on clique sur le texte Click example.

La méthode tick() est appelée pour chaque évènement provenant de Zone.js. En suivant la pile d’appels tick()tickRootContext()renderComponentOrTemplate(), on peut voir que cette méthode appelle renderView() et refreshView() qui sont les méthodes permettant la détection de changements:

export function renderComponentOrTemplate<T>( 
    tView: TView, lView: LView, templateFn: ComponentTemplate<{}>|null, context: T) { 
    // [...] 

    if (creationModeIsActive) { 
        renderView(tView, lView, context);
    } 
    refreshView(tView, lView, templateFn, context);

    // [...] 
} 

Comme on l’a indiqué précédemment, renderView() et refreshView() permettent d’exécuter la création et la mise à jour de la vue. Le booléen creationModeIsActive matérialise l’activation du mode Creation permettant la création des éléments statiques de la vue par renderView().

Dans le cadre d’une exécution en mode update, refreshView() peut aussi être appelé par detectChanges()detectChangesInternal():

export function detectChangesInternal<T>(tView: TView, lView: LView, context: T) { 
    const rendererFactory = lView[RENDERER_FACTORY]; 
    if (rendererFactory.begin) rendererFactory.begin(); 
    try { 
        refreshView(tView, lView, tView.template, context);
    } catch (error) { 
        handleError(lView, error); 
        throw error; 
    } finally { 
        if (rendererFactory.end) rendererFactory.end(); 
    } 
} 

Comparaison des valeurs

Dans le code d’Angular, on peut voir régulièrement des objets de type LView et TView. Ces 2 objets contiennent toutes les données relatives aux vues:

  • LView contient toutes les données nécessaires à l’exécution des traitements concernant un template. Il existe une instance de type LView pour chaque vue d’un composant et pour chaque vue intégrée (i.e. embedded view).
  • TView contient les données statiques partagées par tous les templates correspondant à un type donné. Le code permet de voir les fonctions qui sont compilées et générées par Ivy et qui sont exécutées lors du rendu d’une vue ou de la mise à jour des éléments dynamiques d’une vue:
    export interface TView { 
        // [...] 
        type: TViewType; 
        readonly id: number; 
    
        // [...] 
        template: ComponentTemplate<{}>|null; 
        viewQuery: ViewQueriesFunction<{}>|null; 
        node: TViewNode|TElementNode|null; 
    
        // [...] 
        expandoInstructions: ExpandoInstructions|null; 
        staticViewQueries: boolean; 
        queries: TQueries|null; 
        contentQueries: number[]|null; 
    
        // [...] 
        preOrderHooks: HookData|null; 
        preOrderCheckHooks: HookData|null; 
        contentHooks: HookData|null; 
        contentCheckHooks: HookData|null; 
        viewHooks: HookData|null; 
        viewCheckHooks: HookData|null; 
        destroyHooks: DestroyHookData|null; 
    
        // [...] 
        components: number[]|null; 
    }
    

Les comparaisons des valeurs sont indirectement effectuées par refreshView(). Sans rentrer dans le détail d’implémentation de cette méthode, elle permet de déclencher l’exécution de toutes les fonctions permettant de mettre à jour des éléments dynamiques d’une vue susceptible d’en modifier le contenu comme par exemple:

  • Un property binding,
  • Un attribute binding,
  • Une interpolation,
  • Le changement d’un paramètre d’entrée d’un composant (de type Input),
  • Une propriété d’un élément hôte natif (différent de Input),
  • L’évaluation d’une expression,
  • Etc…

Par suite, toutes ces méthodes déclenchent les méthodes générées par Ivy correspondant à la mise à jour des éléments du template en mode update. Lors de cette mise à jour, la fonction bindingUpdated() est déclenchée pour vérifier si les bindings sont mis à jour ou non:

export function bindingUpdated(lView: LView, bindingIndex: number, value: any): boolean { 
    ngDevMode && assertNotSame(value, NO_CHANGE, 'Incoming value should never be NO_CHANGE.'); 
    ngDevMode && 
    assertLessThan(bindingIndex, lView.length, `Slot should have been initialized to NO_CHANGE`); 

    const oldValue = lView[bindingIndex]; 
    if (Object.is(oldValue, value)) { 
        return false; 
    } else { 
        // [...] 
        lView[bindingIndex] = value; 
        return true; 
  } 
}

On peut voir que cette fonction récupère l’ancienne valeur avec:

const oldValue = lView[bindingIndex]; 

Elle effectue une comparaison entre la valeur actuelle et l’ancienne valeur pour détecter le changement:

Object.is(oldValue, value) 

Enfin en cas de changement, la valeur sauvegardée est remplacée par la nouvelle valeur:

lView[bindingIndex] = value; 

La valeur de retour de la fonction est true si une mise à jour du binding a été détectée. Cette fonction n’effectue pas elle-même la modification des propriétés dans le DOM. Ces modifications sont effectuées par les méthodes générées par Ivy seulement dans le cas où un changement est détecté.

Implémentation de la détection de changements

Comme le suggère l’implémentation qu’on a pu décrire plus haut, le mécanisme de détection de changements est lié à l’interfaçage avec le DOM lors de l’initialisation d’un composant ou lors de sa mise à jour. Il n’est pas possible de s’interfacer avec le DOM sans exécuter la détection de changements.

Même si la compréhension de la détection de changements n’est pas nécessaire pour implémenter des composants avec Angular, elle permet de mieux appréhender le lien entre les différents mécanismes suivants:

  • Création des objets,
  • Déclenchement des callbacks du cycle de vie d’un composant,
  • Rendu d’un vue,
  • Mise à jour du contenu dynamique d’une vue,
  • Lien entre un composant et ses composants enfant.

Tous ces mécanismes sont implémentés dans les 2 méthodes:

Le but de cette partie est d’expliciter ces 2 méthodes pour comprendre davantage le lien entre ces différents mécanismes.

Composant parent et composant enfant

Expliciter le fonctionnement des méthodes renderView() et refreshView() n’est pas facile car ces méthodes sont appelées récursivement par les composants parent sur leur composant enfant. Pour un composant donné, la moitié des traitements est effectuée par des appels provenant de l’exécution de renderView() ou refreshView() de son composant parent.

L’imbrication d’un composant avec ses parents est valable pour tous les composants car même le composant root de l’application possède un parent.

Création de contenu statique

Le contenu statique est créé par la méthode renderView(). Elle n’est exécutée que lors de l’initialisation d’un composant. Comme le contenu statique d’une vue n’est pas modifié dans la suite de l’exécution, la méthode ne sera plus sollicitée.

Cette méthode peut prêter à confusion car certains appels sont effectués avec RenderFlags.Update.

Ainsi la création du contenu statique est effectuée en exécutant les étapes suivantes:

  1. Exécution de la fonction contentQueries avec RenderFlags.Create: cette fonction permet de créer d’autres fonctions qui permettront d’effectuer des bindings entre le composant et sa vue en effectuant des requêtes sur du contenu projeté par l’intermédiaire de @ContentChild() ou @ContentChildren().
    Cet appel n’est pas effectué dans le corps de la méthode renderView() du composant mais dans celui de son parent. Il est effectué lorsque le constructeur du composant parent est exécuté. Cet appel n’effectue pas de mise à jour du DOM, il ne fait que créer de nouveaux objets nécessaires aux traitements futurs.
  2. Exécution de la fonction viewQuery avec RenderFlags.Create: crée d’autres fonctions qui effectueront des bindings entre le composant et sa vue quand on utilise @ViewChild() ou @ViewChildren(). Cet appel n’effectue pas de création ou de mise à jour d’objets dans le DOM.
  3. Exécution de la fonction template avec RenderFlags.Create: cet appel est le plus important que c’est celui qui permettra de créer tous les objets correspondant au contenu statique de la vue en créant les éléments natifs dans le DOM.
  4. Exécution du constructeur du composant: lorsque la fonction template est exécutée et au premier appel d’une méthode qui crée un objet où le code du composant est nécessaire, le composant est instancié en exécutant son constructeur.
  5. Exécution de la fonction contentQueries avec RenderFlags.Update: cet appel peut prêter à confusion car il est effectué avec RenderFlags.Update. Il concerne les requêtes effectuées sur du contenu projeté se trouvant sur la vue du composant avec @ContentChild() ou @ContentChildren() dans le cas où ils utilisent l’option static: true. Cette option va imposer aux requêtes d’être effectuées au moment de la création de la vue.
    Cet appel est effectué avec RenderFlags.Update car les objets permettant d’effectuer la requête sont déjà créés (point 1). Cet appel met à jour les résultats des requêtes après création des éléments statiques de la vue.
  6. Exécution de la fonction viewQuery avec RenderFlags.Update: comme l’appel précédent, cet appel est effectué avec RenderFlags.Update car les objets permettant de requêter la vue existe déjà (point 2). L’appel concerne les requêtes effectuées sur la vue avec @ViewChild() ou @ViewChildren() s’ils utilisent l’option static: true.
Légende du schéma

Mise à jour du contenu dynamique

Le contenu dynamique d’une vue d’un composant est mis à jour lors de l’initialisation et à chaque exécution du mécanisme de détection de changements. La méthode qui effectue ce traitement est refreshView().

Cette méthode est plus complexe que renderView() car beaucoup de traitements effectués concernant le composant courant sont effectués lors de l’exécution de refreshView() dans le cadre son parent.

Les étapes principales de refreshView() sont:

  1. Exécution des callbacks ngOnChanges(), ngOnInit() et ngDoCheck(): ces callbacks sont exécutées lors de l’exécution de refreshView() du composant parent.
    La callback ngOnInit() est exécutée seulement à l’initialisation du composant.
  2. Exécution de la fonction contentQueries avec RenderFlags.Update: cette fonction permet de mettre à jour les requêtes effectuées sur le contenu projeté de la vue avec @ContentChild() ou @ContentChildren().
    L’appel de cette fonction est effectué lors de l’exécution de refreshView() du composant parent.
  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é. Ce contenu projeté provient du parent. La vue correspondante du parent a été rendu après exécution de la fonction template du parent. Au moment d’effectuer les requêtes sur le contenu projeté, la vue de son parent est donc à jour.
    La callback ngAfterContentInit() est exécutée seulement à l’initialisation du composant.
  4. Exécution de la fonction hostBindings avec RenderFlags.Update: cette fonction permet d’affecter les bindings provenant de l’élement HTML hôte avec @HostBinding(). Les éléments hôte font partie des éléments statiques et sont créés lors de l’exécution de la fonction template avec RenderFlags.Create donc à ce moment de l’exécution, ils ont déjà été créés.
  5. Exécution de la fonction template avec RenderFlags.Update: cette étape est la 1ère effectuée dans le corps de la méthode refreshView() du composant courant. Elle servira à mettre à jour le DOM avec tous les éléments dynamiques de la vue.
  6. Exécution des callbacks ngOnChanges(), ngOnInit() et ngDoCheck() dans les composants enfant.
  7. Exécution de refreshView() pour les vues intégrées (i.e. embedded views).
  8. Exécution de la fonction contentQueries() dans les directives enfant: cette étape permet de mettre à jour les requêtes sur le contenu projeté des directives enfant. Cette étape est possible car le contenu projeté a été rendu lors de l’exécution de la fonction template du composant.
  9. Exécution des callbacks ngAfterContentInit() et ngAfterContentChecked() des composants enfant.
  10. Exécution de la fonction hostBindings des directives enfant.
  11. Exécution de la méthode refreshView() des composants enfants.
Légende du schéma
  1. Exécution de la fonction viewQuery avec RenderFlags.Update: cette étape permet de mettre à jour les requêtes effectuées sur la vue du composant avec @ViewChild() et @ViewChildren. La vue du composant, les vues des composants enfant et les view containers des vues intégrées (i.e. embedded views) ont été rendus au début de l’exécution de cette fonction. Les requêtes sont donc effectuées sur des vues à jour.
  2. Exécution des callbacks ngAfterViewInit() et ngAfterContentChecked() des composants enfant: cette appel termine l’exécution de la méthode refreshView() du composant courant.
  3. Exécution des callbacks ngAfterViewInit() et ngAfterContentChecked(): cet appel est effectué dans le corps de la méthode refreshView() du composant parent.
    La callback ngAfterViewInit() est exécutée seulement à l’initialisation du composant.

Cette description permet de se rendre compte de l’imbrication d’un composant avec son parent. Beaucoup de traitements sont effectués par le parent en dehors du corps de la méthode refreshView() du composant courant.

“No Changes Mode”

Dans le code de la méthode refreshView(), on peut voir régulièrement des références au “No Changes Mode” par l’intérmédiaire d’un appel à la fonction getCheckNoChangesMode() pour récupérer un booléen indiquant l’activation ou non de ce mode.

A chaque détection de changements, la méthode refreshView() est exécuté 2 fois:

  • Une fois avec le mode “No Changes” désactivé: il s’agit de l’exécution normal de la méthode refreshView() pour effectuer la détection de changements et pour modifier le contenu dynamique de la vue.
  • Une 2e fois avec le mode “No changes” activé: cette exécution est effectuée seulement avec la configuration de développement. Elle permet d’exécuter la détection de changements une 2e fois sans modifier le contenu des vues en s’il y a de nouveaux changements détectés. Au cours d’une exécution idéale, il ne doit pas y avoir de changements détectés à la fin d’une exécution du mécanisme. Tous les changements doivent avoir été détectés. Cette 2e exécution vise à vérifier si tous les changements ont effectivement été détectés. Si de nouveaux changements sont détectés alors il y a une erreur d’implémentation car ces changements n’entraîneront pas une modification de la vue.

Dans le cas où un changement est détecté lors de la 2e exécution, une erreur de type “ExpressionChangedAfterItHasBeenCheckedError” est lancée.

L’appel est de ces 2 modes est effectué dans la méthode tick() appelée dans la callback exécutée par Zone.js:

tick(): void { 
    // [...] 
    for (let view of this._views) { 
        view.detectChanges(); 
    } 
    if (this._enforceNoNewChanges) { 
        for (let view of this._views) { 
            view.checkNoChanges();
        } 
    } 
    // [...] 
  } 

Dans ce code:

  • view.detectChanges() permet de lancer la 1ère exécution du changement de détection.
  • view.checkNoChanges() lance la 2e exécution en mode “No Changes”.

Pour résumer

Si on simplifie les schémas plus haut pour ne considérer que les étapes concernant le composant courant, on obtient le schéma suivant:

Légende du schéma

Ce schéma permet de se rendre compte de la succession des étapes de création et de mise à jour des éléments dans le DOM et l’exécution des callbacks du cycle de vie d’un composant. Ainsi, si un élément dyamique est modifié après sa mise à jour dans le DOM, on peut comprendre pourquoi il ne sera pas mis à jour dans la vue d’un composant.

Par exemple, si on modifie la valeur d’un binding lors de l’exécution des callbacks ngAfterViewInit() ou ngAfterViewChecked(), étant donné que la fonction template permettant de mettre à jour le DOM a été exécuté avant, il ne sera pas possible de mettre à jour la vue seulement avec le mécanisme de détection automatique de changements.

Il en est de même pour l’exécution de requête de vue et de contenu projeté qui intervient à des moments précis avant et après le rendu. Le résultat de ces requêtes peut être différent de celui attendu suivant si le rendu a été effectué ou non.

Références

Ivy:

Leave a Reply