Cet article fait partie de la série d’articles Angular from Scratch.
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 ?
Cycle de vie des composants
Lien entre la détection des changements et le cycle de vie des composants
Erreur “ExpressionChangedAfterItHasBeenCheckedError”
Ivy
Compilation Ahead-of-Time (AOT)
Tree-shaking
Compilation d’un composant
Exemple du code généré pour un composant
Creation Mode
Interfaçage avec le DOM
Fonctionnement de la détection de changements
Comment détecter les changements ?
Déclenchement de la détection de changements
Comparaison des valeurs
Implémentation de la détection de changements
Création de contenu statique
Mise à jour du contenu dynamique
Pour résumer
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()
ousetInterval()
.
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:
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, les callbacks déclanchées sont, dans l’ordre:
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()
.
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()
etngAfterContentChecked()
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()
etngAfterViewChecked()
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.
"ExpressionChangedAfterItHasBeenCheckedError"
est déclenchée seulement en développementL’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()
etngAfterViewChecked()
, - Une interpolation dans le template vers une propriété de la classe du composant.
L’implémentation est:
Template |
|
Classe du composant |
|
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 |
|
Classe du composant |
|
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 fonctiontemplate
, l’appel dansrenderView()
est du type:executeTemplate(tView, lView, templateFn, RenderFlags.Create, context);
On peut voir que la fonction template
templateFn
est appelée avecRenderFlags.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 dansrefreshView()
:executeTemplate(tView, lView, templateFn, RenderFlags.Update, context);
La fonction template
templateFn
est appelée avecRenderFlags.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 derf
estRenderFlags.Create
).if (rf & 2)
permet d’exécuter le code si le mode est update (i.e. si la valeur derf
estRenderFlags.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:
- Attribut d’un élément
[attr.title]="value"
, - Interpolation,
- Propriété d’un élément (de type
@Input()
), - Propriété de style,
- Propriété d’un hôte natif (différent de
@Input()
), - Evaluation d’une expression.
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 |
|
Classe du composant |
|
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 typeLView
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:
renderView()
pour la création de contenu statique des vues etrefreshView()
pour la mise à jour du contenu dynamique.
Le but de cette partie est d’expliciter ces 2 méthodes pour comprendre davantage le lien entre ces différents mécanismes.
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:
|
|
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:
|
|
|
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.
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:
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.
- Angular Change Detection Explained: https://blog.thoughtram.io/angular/2016/02/22/angular-2-change-detection-explained.html
- Understanding Zones: https://blog.thoughtram.io/angular/2016/01/22/understanding-zones.html
- How Does Angular 2 Change Detection Really Work? : https://dzone.com/articles/how-does-angular-2-change-detection-really-work-1
- Ivy engine in Angular: first in-depth look at compilation, runtime and change detection: https://indepth.dev/ivy-engine-in-angular-first-in-depth-look-at-compilation-runtime-and-change-detection/
- These 5 articles will make you an Angular Change Detection expert: https://indepth.dev/these-5-articles-will-make-you-an-angular-change-detection-expert/
- A gentle introduction into change detection in Angular: https://indepth.dev/a-gentle-introduction-into-change-detection-in-angular/
- Everything you need to know about change detection in Angular: https://indepth.dev/everything-you-need-to-know-about-change-detection-in-angular/
- Angular Change Detection – How Does It Really Work?: https://blog.angular-university.io/how-does-angular-2-change-detection-really-work/
- He who thinks change detection is depth-first and he who thinks it’s breadth-first are both usually right: https://indepth.dev/he-who-thinks-change-detection-is-depth-first-and-he-who-thinks-its-breadth-first-are-both-usually-right/
- Exploring Angular DOM manipulation techniques using ViewContainerRef: https://indepth.dev/exploring-angular-dom-manipulation-techniques-using-viewcontainerref/
- The mechanics of DOM updates in Angular: https://hackernoon.com/the-mechanics-of-dom-updates-in-angular-3b2970d5c03d
- Dynamically Creating Components with Angular: https://netbasal.com/dynamically-creating-components-with-angular-a7346f4a982d
- Examiner les écouteurs d’évènements: https://developer.mozilla.org/fr/docs/Outils/Inspecteur/Comment/Examiner_les_%C3%A9couteurs_d_%C3%A9v%C3%A8nements
- Lifecycle Hooks: https://codecraft.tv/courses/angular/components/lifecycle-hooks/
- NgZone: https://angular.io/guide/zone
- The Last Guide For Angular Change Detection You’ll Ever Need: https://www.mokkapps.de/blog/the-last-guide-for-angular-change-detection-you-will-ever-need/
Ivy:
- Angular Ivy:https://angular.io/guide/ivy
- Ahead-of-time (AOT) compilation:https://angular.io/guide/aot-compiler
- Angular : JiT vs AoT:https://blogs.infinitesquare.com/posts/web/angular-jit-vs-aot
- What’s new in Angular 4?:https://blog.ninja-squad.com/2017/03/24/what-is-new-angular-4/
- A Modular View Engine Architecture For Angular 7:http://www.clipcode.net/content/modular-view-engine-architecture-for-angular.pdf
- Angular DI: Getting to know the Ivy NodeInjector:https://medium.com/angular-in-depth/angular-di-getting-to-know-the-ivy-nodeinjector-33b815642a8e
- A look at major features in the Angular Ivy version 9 release:https://indepth.dev/a-look-at-major-features-in-the-angular-ivy-version-9-release/
- Angular Ivy, Oui mais pas trop !:https://www.lamobilery.fr/blog/actualites/9942-angular-ivy-oui-mais-pas-trop/