Change Detection: cosa c’è sotto il cofano?

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.

 

Il "Quando": come Angular sa che i dati sono cambiati?

Angular ha bisogno di un meccanismo per sapere quando i dati nel nostro componente sono cambiati e, di conseguenza, quando è necessario aggiornare la vista.

Codice Sincrono vs. Asincrono

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}

 

Ecco che entra in gioco: Zone.js

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);

 

unnamed.png

 

Marcatura dei componenti come "Dirty"

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:

  • Eventi: ogni volta che viene cliccato un pulsante a cui è agganciato un listener.
  • Input: quando viene passato un nuovo valore alle proprietà di input di un componente.
  • Output: ogni volta che viene emesso un valore tramite le proprietà di output.
  • ChangeDetectorRef: chiamata diretta al metodo markForCheck().
  • AsyncPipe: un valore consumato da una async pipe.
  • Defer: un cambiamento di stato di un blocco @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.

 

unnamed (1).png

unnamed (3).png

 

Il "Come": percorrere l'albero dei componenti

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.

 

Detection Strategy: Default vs. OnPush

Angular offre due strategie per la Change Detection: Default e OnPush.

 

Default Change Detection

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.

OnPush Change Detection

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}

unnamed (4).png

 

OnPush Change Detection + AsyncPipe

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.

markForCheck vs. detectChanges

È fondamentale distinguere tra markForCheck e detectChanges

  • markForCheck: marca il componente e tutti i suoi genitori come “dirty”, così da schedulare un ciclo di Change Detection da eseguire successivamente. È utile usarlo nella maggior parte degli scenari
  • detectChanges: esegue immediatamente la Change Detection per il componente corrente, in cui è stato richiamato il metodo, e i suoi componenti figli. È utile usarlo solo quando è necessario avere un aggiornamento immediato del DOM o quando si sta lavorando fuori da un NgZone

In definitiva, si dovrebbe preferire l'uso di markForCheck rispetto a detectChanges per la sua natura efficiente.

 

Local Change Detection (LocalMode)

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:

  • RefreshView: impostato sul componente la cui vista deve essere aggiornata.
  • 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:

  • NgZone innesca la Change Detection in GlobalMode: in questa modalità vengono controllati e aggiornati tutti i componenti CheckAlways (strategia Default) e i componenti OnPush marcati come “dirty”
  • Passaggio alla LocalMode: se, in GlobalMode, Angular incontra un componente OnPush che NON è “dirty”, ma ha il flag HAS_CHILD_VIEWS_TO_REFRESH, passa alla LocalMode

In LocalMode:

  • Vengono aggiornate solo le viste che hanno il flag RefreshView.
  • NON vengono aggiornate le viste CheckAlways o le viste con il flag dirty.
  • Se viene raggiunta una vista con il flag RefreshView, la traversata dei figli continua in GlobalMode per assicurare che i componenti CheckAlways vengano aggiornati correttamente.

 

unnamed (5).png

 

Note sulla Local Change Detection

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:

  • setInterval, setTimeout
  • Observable.subscribe, toSignal(Observable)
  • RxJS fromEvent, Renderer2.listen

Inoltre, affinché la Local Change Detection funzioni al meglio, è preferibile che tutti i componenti nell'albero utilizzino la strategia OnPush.

 

Conclusione

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.

 

Credits

Change Detection

Contattaci

Autore

Mirko Rapisarda

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.

Devmy su linkedin

Ultimi Articoli