Il framework Angular è rinomato per la sua architettura basata sui componenti, e come ogni framework moderno, il suo compito principale è mostrare i dati all'utente e aggiornare la vista quando tali dati cambiano. Questo processo di aggiornamento è gestito dal meccanismo di Change Detection.
Per capire la Change Detection, è utile dividerla in due aspetti: il quando e il come. Il quando si riferisce alla schedulazione del rilevamento dei cambiamenti, ovvero i fattori che ne innescano l'esecuzione. Il come si concentra sulle meccaniche di esecuzione, inclusa la traversata dell'albero dei componenti e il processo di verifica delle modifiche.
Angular ha bisogno di un meccanismo per sapere quando i dati nel nostro componente sono cambiati e, di conseguenza, quando è necessario aggiornare la vista.
Consideriamo un semplice componente con una proprietà name
e un metodo changeName
1@Component({
2 selector: 'app-user-card',
3 template: `
4 <div>
5 <h1>{{ user.name }}</h1>
6 <p>{{ user.email }}</p>
7 <button (click)="changeName()">Change name</button>
8 </div>`
9})
10export class UserCardComponent {
11 user: User;
12
13 changeName(): void {
14 this.user.name = 'Mario';
15 }
16}
Quando clicchiamo il bottone, il metodo changeName
viene chiamato e il name
cambia. In un ipotetico scenario senza "magie", Angular esegue un runChangeDetection()
subito dopo la chiamata al metodo, aggiornando la vista. Questo funziona perfettamente per le operazioni sincrone.
1component.changeName();
2angular.runChangeDetection();
Il problema nasce con il codice asincrono, che è la norma nelle applicazioni reali (es. richieste HTTP, setTimeout, eventi del browser).
Se il name
cambiasse dopo un secondo tramite setTimeout
un ipotetico angular.runChangeDetection()
verrebbe eseguito immediatamente dopo la chiamata a changeName
, ma prima che il setTimeout
completi la sua callback e cambi il nome. Il risultato? La vista non si aggiornerebbe e l'applicazione risulterebbe "rotta".
1@Component({
2 selector: 'app-user-card',
3 template: `
4 <div>
5 <h1>{{ user.name }}</h1>
6 <p>{{ user.email }}</p>
7 <button (click)="changeName()">Change name</button>
8 </div>`
9})
10export class UserCardComponent {
11 user: User;
12
13 changeName(): void {
14 setTimeout(() => {
15 this.user.name = 'Mario';
16 }, 1000);
17 }
18}
Zone.js è una libreria che, come una “lente d’ingrandimento”, tiene traccia di tutte le operazioni asincrone (come setTimeout, Promise, EventListener, fetch, ecc.) che avvengono all’interno di una specifica “zona” di esecuzione del codice. In questo modo Angular può eseguire del codice prima e dopo l’esecuzione di eventi asincroni.
1const zone = Zone.current.fork({
2 onInvokeTask: (delegate, current, target, task, applyThis, applyArgs) => {
3 console.log('Before setTimeout');
4 delegate.invokeTask(target, task, applyThis, applyArgs);
5 console.log('After setTimeout');
6 }
7});
8
9zone.run(() => {
10 setTimeout(() => {
11 console.log('Hello world');
12 }, 1000);
13});
14
15// Output:
16// Before setTimeout
17// Hello world
18// After setTimeout
Angular carica zone.js di default in ogni applicazione e crea una "zona" chiamata NgZone.
NgZone include un Observable chiamato onMicrotaskEmpty
. Questo Observable emette un valore quando non ci sono più microtasks nella coda. Angular utilizza ciò per sapere quando tutto il codice asincrono è terminato e può eseguire la Change Detection. Quando onMicrotaskEmpty
emette un valore, Angular chiama il metodo applicationRef.tick()
. Il metodo tick() è l'equivalente del nostro runChangeDetection()
, il quale esegue la Change Detection per l'intero albero dei componenti in modo sincrono.
1this._subscription = this.zone.onMicrotaskEmpty.subscribe(
2 this.zone.run(() => this.applicationRef.tick())
3);
Un’altra operazione che Angular fa per ottimizzare il meccanismo è contrassegnare il componente come "dirty" quando qualcosa al suo interno è cambiato. Questa operazione avviene in tre casi:
markForCheck()
.async
pipe.@defer
.La funzione markViewDirty
non solo marca il componente corrente come dirty, ma marca anche tutti i suoi genitori (parent) fino alla radice dell'albero dei componenti. Questo assicura che il componente modificato venga sempre raggiunto dal processo di Change Detection. Dopo che un componente è stato marcato come dirty (e anche i suoi genitori), l'event listener notifica Zone.js. Quando Zone.js rileva che non ci sono più microtask pendenti, onMicrotaskEmpty
viene emesso, dicendo ad Angular che è il momento di eseguire tick()
e quindi la Change Detection.
Una volta schedulata la Change Detection, Angular esamina ogni componente dall'alto verso il basso. Vengono controllati i binding e se un binding è cambiato, la vista viene aggiornata. È importante notare che Angular non "ricrea" l'intero template DOM del componente. È abbastanza intelligente da aggiornare solo i nodi DOM (o anche solo singoli attributi) che effettivamente necessitano di modifiche.
Angular offre due strategie per la Change Detection: Default e OnPush.
La strategia Default è quella descritta sopra: l'intero albero dei componenti viene percorso e controllato per modifiche ad ogni ciclo di Change Detection. Anche se efficiente nel rilevare i cambiamenti, può portare a un sovraccarico in applicazioni complesse con alberi di componenti molto grandi, in quanto tutti i componenti vengono controllati, anche quelli che non hanno subito modifiche.
La strategia OnPush è un'ottimizzazione che permette di saltare parti dell'albero dei componenti durante la Change Detection, portando a migliori performance. Quando un componente utilizza la strategia OnPush, non verrà sempre controllato durante la Change Detection, ma solo se è stato marcato come "dirty". Se un componente OnPush non è marcato come “dirty”, Angular salterà la sua sotto-struttura durante la Change Detection, non controllando i suoi componenti figli.
1 selector: 'app-user-card',
2 template: `
3 <div>
4 <h1>{{ user.name }}</h1>
5 <p>{{ user.email }}</p>
6 <button (click)="changeName()">Change name</button>
7 </div>`,
8 changeDetection: ChangeDetectionStrategy.OnPush
9})
10export class UserCardComponent {
11 user: User;
12
13 changeName(): void {
14 setTimeout(() => {
15 this.user.name = 'Mario';
16 }, 1000);
17 }
18}
Quando lavoriamo con Angular, gli Observable sono il nostro strumento principale per gestire i dati e le modifiche di stato. Per gli Observable, Angular fornisce l’AsyncPipe, la quale sottoscrive un Observable e restituisce il valore più recente. Per segnalare ad Angular che il valore è cambiato, chiama il metodo markForCheck
che si occupa di contrassegnare la view come dirty.
È fondamentale distinguere tra markForCheck
e detectChanges
In definitiva, si dovrebbe preferire l'uso di markForCheck
rispetto a detectChanges
per la sua natura efficiente.
L’introduzione dei Signals ha comportato, non solo un miglioramento nel recupero dei dati asincroni evitando la sottoscrizione mediante l’utilizzo dell’AsyncPipe, ma ha introdotto miglioramenti anche per quanto riguarda il meccanismo di Change Detection. Questa nuova modalità introduce due nuovi flag:
HAS_CHILD_VIEWS_TO_REFRESH: impostato sui componenti genitori per indicare che hanno componenti figli da aggiornare.
Quando un signal cambia, Angular chiama la funzione markViewForRefresh
.
Questa funzione imposta il flag RefreshView
sul componente corrente e poi chiama markAncestorsForTraversal
, che marchia tutti i genitori con il flag HAS_CHILD_VIEWS_TO_REFRESH
. Anche in questa modalità, dipendiamo ancora da Zone.js per innescare la Change Detection. Quando Zone.js si attiva chiamerà appRef.tick()
Quando appRef.tick()
viene chiamato, la Change Detection avviene top-down con nuove regole:
HAS_CHILD_VIEWS_TO_REFRESH
, passa alla LocalModeIn LocalMode:
RefreshView
.RefreshView
, la traversata dei figli continua in GlobalMode per assicurare che i componenti CheckAlways vengano aggiornati correttamente.
Se il cambiamento di un signal origina da un evento del template (es. click), si applicano le vecchie regole di Change Detection. L'evento marcherà comunque il componente e i suoi genitori come dirty, annullando il vantaggio della Local Change Detection. Per beneficiare appieno della Local Change Detection, il trigger del cambiamento del valore del signal non deve marcare il componente come dirty. Esempi includono:
Inoltre, affinché la Local Change Detection funzioni al meglio, è preferibile che tutti i componenti nell'albero utilizzino la strategia OnPush.
La Change Detection in Angular è un meccanismo sofisticato che si è evoluto significativamente nel tempo. Da un approccio basato su Zone.js che copre tutte le operazioni asincrone, siamo passati a strategie più granulari come OnPush e ora alla Local Change Detection guidata dai Signals.
Developer @Devmy, PUG Catania Organizer
Da sempre considera programmare più come una passione che come un lavoro. D'altra parte, questa è la ragione principale che spinge i programmatori a imparare nuovi linguaggi, nuove tecnologie e metodologie ogni giorno. Senza questa "fame" di sapere non è possibile raggiungere grandi conquiste in questa professione.
La sua passione lo ha portato allo sviluppo di applicazioni web usando tecnologie quali PHP, MySQL, Symfony, JavaScript, Node, Angular, tuttavia sperimenta volentieri anche con altri linguaggi e tecnologie come Vue, VB e Flutter. Grande sostenitore della metodologia Agile e dei principi presenti all'interno del manifesto che lo hanno fortemente ispirato nella creazione di una serie di libri che ben esprimono i vantaggi di seguire questi principi.
Precedentemente docente presso PED Academy.