Angular Change Detection: Cosa occorre sapere

A

Change Detection

La sfida che ogni framework frontend cerca di risolvere e’ quella di mantenere sincronizzati lo stato applicativo e la UI in maniera performante: come un utente interagisce con l’ applicazione web, i dati e quindi lo stato mutano e l’applicazione deve reagire di conseguenza. Se ci pensate e’ un problema particolarmente complesso e per nulla banale. Occorre innanzitutto:

  1. intercettare azioni/eventi che potenzialmente possono cambiare lo stato dell’ applicazione
  2. decidere se tali eventi comportano l’aggiornamento del DOM
  3. aggiornare il DOM

Il processo che usa Angular per rendere i propri componenti reattivi al cambiamento dei dati e stato e’ la Change Detection.

Uno dei motivi principali che ha reso Angular celebre e’ quello di essere totalmente indipendente nel risolvere il grande problema di rendere sincronizzata la UI allo stato dell’ applicazione: il developer si concentra  ad implementare le logiche che aggiornano lo stato dell’ applicazione ed Angular mediante la Change detection e’ responsabile di riflettere lo stato nelle viste dell’ applicazione in maniera totalmente autonoma e performante.  Questo permette al programmatore in pochissimo tempo di essere in grado di realizzare una applicazione reattiva senza troppe difficoltà’. Tutto fantastico e forse troppo facile…. e’ proprio questo il problema: l’errore più’ grande che un neofita Angular solitamente commette e’ quello di considerare la change detection come un qualcosa di magico che riflette ogni modifica dello stato sul DOM. Ma sappiamo tutti che di magico non esiste nulla in questo mondo! Nonostante Google abbia implementato in maniera eccelsa la Change Detection, e’ molto facile che con il crescere dell’ applicazione si commetta errori o sviste che comportano importanti rallentamenti della web application… spesso causati da un running  incontrollato della change detection.

Recentemente mi sono imbattuto in problemi prestazionali di una single page application e sono rimasto sbalordito dal fatto che Google nella propria documentazione non descriva in maniera dettagliata il processo di change detection e le best practices su come ottimizzare tale processo. Sono riuscito a capire come funziona in dettaglio la change detection ascoltando qualche talk e raccogliendo a destra e sinistra info da vari articoli ed infine controllando il codice sorgente di Angular. Nel fare questo ho scritto un po di appunti e ho deciso di formalizzarli in questo articolo sperando che possa tornare utile ad altri. Sara’ un articolo abbastanza teorico, spero di non annoiarvi… ma apprendere come funziona la change detection dovrebbe essere il primo task di ogni programmatore Angular ancor prima dell’ Hello World.

Iniziamo a descrivere il processo della CD ( abbreviazione di Change Detection da ora in poi utilizzata in questo articolo), partendo da uno dei problemi che essa risolve: intercettare gli eventi che cambiano lo stato dell’ applicazione.

Cosa causa il cambiamento

Quali sono gli eventi che possono causare il cambiamento di stato di un componente  e quindi di conseguenza un potenziale re-rendering ?

Il cambiamento di stato dell’applicazione può essere causato essenzialmente da tre elementi:

  • Eventi: click sul link, submit su un form, ecc…
  • XHR (XML Http Request): recupero di dati da un server remoto
  • Timers : per es. setTimeout( ), setInterval( )

Come è possibile notare, tutti gli elementi sopra indicati sono asincroni e ciò ci porta ad affermare che, in linea di massima, ogni operazione asincrona può comportare un cambiamento di stato. Qui arriva il momento di informare Angular di aggiornare la UI. Come percepisce Angular il cambiamento di stato indotto dagli eventi sopra descritti ?

ngzone

Per intercettare le modifiche, Angular si serve di Zone.js! Due parole su Zone per comprendere al meglio la Change Detection sono necessarie. Una Zone è un contesto di esecuzione che persiste in attività asincrone consentendo al creatore della Zone di osservare e controllare l’esecuzione del codice all’interno di essa. Riassumendo all’osso Angular registra una propria zona, chiamata NgZone, che wrappa le api asincrone del browser con la tecnica nota con il nome di monkey patching. Il concetto dietro a tale tecnica e’ molto semplice: essa consiste nel sovrascrivere, estendere o  sopprimere il comportamento di default di un segmento di codice senza cambiare il suo codice originale ( nel nostro caso quello delle api asincrone del browser come setInterval e le altre citate al paragrafo precedente). Wrappando con tale tecnica le api asincrone del browser, Angular e’ in grado di triggerare il processo di Change Detection utilizzando l API di core  ApplicationRef.tick().

Non abbiamo ancora spiegato in cosa consiste le azioni eseguite dalla CD per valutare un eventuale modifica del DOM ma abbiamo chiarito ‘innanzitutto:

  • come fa Angular a triggerare il processo che rende sincronizzato la UI allo stato dell’ applicazione.
  • quali sono gli eventi che Angular sfrutta con zone js per triggerare la CD

E’ scontato dire che un alta frequenza di eventi asincroni  comporta un alta frequenza di esecuzione del ciclo di CD. Puo’  ad es. capitare che la tua applicazione abbia  un carico costante di CPU perche’ di continuo esegue il ciclo di change detection. Quello che ho appena detto non e’ da sottovalutare: ad esempio in un progetto in cui ho lavorato uno dei motivi che rallentava la web app era quello che in un componente era presenta un timer che scattava ogni 200 ms il quale causava il triggeramento della CD ad ogni esecuzione del timer; come potete immaginare la CD ha un costo e quindi grossi impatti sulla performance se eseguita di continuo.  Per fortuna Angular mette a disposizione del programmatore un altra zone oltre a quella utilizzata di default: L’ External Zone, una zone in cui Angular ci consente di eseguire esplicitamente un determinato codice impedendo al framework di triggerare la CD. Quindi, in pratica gli handler dei nostri eventi verranno comunque eseguiti, ma Angular non verrà avvisato del fatto che un’operazione è stata eseguita e pertanto non verrà eseguito alcun rilevamento delle modifiche.  Ad esempio se voglio eseguire una funzione ogni 500ms senza che venga triggerata la CD ad ogni timer posso risolvere nel seguente modo.

initTimer() {
  this._ngZone.runOutsideAngular(() => {
    setInterval(() => callFunction(), 500);
  });
}

constructor(
  private _ngZone: NgZone,
) {}

unidirectional top-down flow

Nel paragrafo precedente abbiamo appreso che Angular  triggera la CD con l API   ApplicationRef.tick(). Che succede a tale invocazione ? Risponderemo a questa domanda con un esempio. Supponiamo di avere un applicazione Angular composta da una gerarchia di componenti, Supponiamo che un componente che sta in fondo alla gerarchia abbia all’interno del suo template un bottone il quale handler impatta dati legati alla sola parte di  UI di quel componente. Al momento del click sul bottone, dopo l’esecuzione dell handler Angular  inizia lo scan dell’ intera gerarchia dei componenti dalla root, e prosegue verso le foglie, sempre nella stessa direzione senza mai tornare indietro. La gif che allego sotto spiega in maniera limpida il flusso della change detection.

Possiamo notare che il flusso ottenuto e’ unidirezionale e quindi predicibile a differenza di un flusso ciclico. Dalla GIF si nota anche che la CD viene eseguita ricorsivamente dal padre al figlio: per ogni componente viene valutato se e’ necessario aggiornare la porzione del DOM che lo rappresenta con i nuovi valori anche se quel componente non ha dipendenze con il componente originale che ha triggerato la CD. Il processo di CD determina se e’ necessario ri-disegnare un componente valutando le espressioni presenti nel template e confrontato il risultato con i precedenti valori; se differenti viene comandato il redraw con i nuovi valori. Quindi se avete messo nel template una chiamata ad un metodo del componente, esso verrà’ invocato ad ogni ciclo di CD. Fate attenzione quindi a non inserire chiamate a funzioni onerose nel template e quando possibile utilizzate variabili pre-calcolcolate o pipe pure.

change detector performance

Quando ho scoperto questo fatto ho provato per un attimo un po di sconforto: controllare ad ogni ciclo di CD l’ intera gerarchia di componenti non causa lentezza ? In realtà’ anche se dovessimo controllare ogni singolo componente ogni volta che si verifica un evento (vedremo piu’ avanti come si puo’ skippare la CD per alcuni componenti e relativi figli), Angular è molto veloce. Può eseguire centinaia di migliaia di controlli entro un paio di millisecondi. Ciò è dovuto principalmente al fatto che Angular genera codice VM friendly code. Cosa significa? Ogni componente ha il proprio rilevatore di cambiamenti (change detector), il quale non e’ sviluppato tramite un componete generico che si occupa del rilevamento dei cambiamenti di un qualsiasi componente. E’ vero che il processo di change detection deve essere scritto dinamicamente in modo che possa controllare ogni componente indipendentemente dalla struttura del modello ma le VM non amano questo tipo di codice dinamico, perché non possono ottimizzarlo. Esso e’ infatti considerato polimorfico poiché la forma degli oggetti non è sempre la stessa. Angular risolve questo problema creando le classi dei change detector in fase di runtime per ciascun componente, ottenendo quindi un risultato mono-morfico e quindi performante per le VM perché sanno esattamente quale sia la forma e struttura del modello del componente. Come ho già’ anticipato ad inizio articolo, e’ fantastico che non dobbiamo preoccuparci troppo di questo processo, perché Angular lo fa automaticamente e in maniera prestante.

Descriviamo adesso gli step che vengono eseguiti sul singolo componente durante la CD.

VIEW E CHANGE DETECTION

Quando diciamo che una applicazione Angular e’ formata da una gerarchia di componenti non siamo molto precisi: a più’ basso livello Angular astrae il nostro componente con l’interfaccia di core View, Quindi anche la CD lavora con la View e non direttamente con il componente.

export interface ViewData {
  def: ViewDefinition;
  root: RootData;
  renderer: Renderer2;
  // index of component provider / anchor.
  parentNodeDef: NodeDef|null;
  parent: ViewData|null;
  viewContainerParent: ViewData|null;
  component: any;
  context: any;
  // Attention: Never loop over this, as this will
  // create a polymorphic usage site.
  // Instead: Always loop over ViewDefinition.nodes,
  // and call the right accessor (e.g. `elementData`) based on
  // the NodeType.
  nodes: {[key: number]: NodeData};
  state: ViewState;
  oldValues: any[];
  disposables: DisposableFn[]|null;
}

/**
 * Bitmask of states
 */
export const enum ViewState {
  FirstCheck = 1 << 0,
  ChecksEnabled = 1 << 1,
  Errored = 1 << 2,
  Destroyed = 1 << 3
}

Dal codice postato si nota subito che :

  • Una View tiene la referenza del componente a cui e’ associato. Quindi una View e’ associata ad un solo componente della gerarchia
  • la View ha uno stato. Lo stato ha un ruolo fondamentale nella CD. Sulla base del valore dello stato Angular decide di eseguire la CD per una vista e per i suoi figli o se saltare questo processo. Gli step eseguiti dalla CD vengono saltati se ChecksEnabled è falso o la view è nello stato Errored o Destroyed. Di Default tutte le viste vengono create con una strategia default  la quale inizializza  lo stato con ChecksEnabled a meno che non venga utilizzata la strategy OnPush che introduceremo a breve. Gli stati possono essere combinati: ad esempio, una vista può avere sia i flag FirstCheck che ChecksEnabled impostati.
  • La view tiene il valore dell’ultima valutazione delle espressioni del template nella variable oldValues. In questo modo si e’ in grado di verificare se durante una CD e’ necessario aggiornare il DOM: bastera’ confrontare oldValues con il valore delle espressioni presenti nel template al momento della CD.
  • La view ha i riferimenti ai propri child… vedremo presto perche’

 

riassumendo la cd…

I concetti che ho espresso in queste righe sono molti.. cerchiamo di non perderci 🙂 .

Abbiamo detto che:

  1. Ad ogni occorrenza di evento asincrono viene triggerata la change detection
  2. La change detection viene eseguita a partire dalla root verso il basso come flusso unidirectional top-down.
  3. Per ogni view della gerarchia si esegue il check della CD che comporta le seguenti operazioni:
    • si valuta se e’ necessario modificare il DOM del componente a causa dell evento accaduto al punto 1. Riducendo all’osso il concetto, per valutare le modifiche durante la CD di una view si valuta le espressioni presenti nel template e si confronta i precedenti valori con i nuovi al fine di capire se occorre ri-disegnare la view.
    • si controlla e aggiorna le input properties sui child se necessario
    • si invoca i metodi del lifecycle di un componente sul child e sul componente stesso: come ad es. onChanges su un child se almeno un input passato al figlio e’ variato
    • si aggiorna il DOM se e’ necessario
  4. Questa operazione di check si esegue sempre e ad ogni ciclo di CD se si fa uso della strategy default in quella view ( strategia di default utilizzata da Angular se nessuna strategy viene specificata dal programmatore nel componente). Cosa accade se si usa la strategy on Push ve lo dico a breve, ma per ora vi basti sapere che in determinate situazioni con la strategy on push e’ possibile skippare il processo di check.

Per chi vuol approfondire le singole operazioni eseguita durante la CD di una singola view consiglio la lettura del seguente articolo: LINK  dove step by step l’autore vi spiega le operazioni eseguite dalla funzione di core  che implementa le operazioni eseguite dalla CD: CheckAndUpdateView

on push strategy

Anche se le operazioni eseguite dai change detector sono veramente ottimizzate, per grosse applicazioni può’ risultare non performante controllare ogni componente ad ogni occorrenza di un evento asincrono. A maggior ragione se l’architettura dei componenti e’ ben progettata e il grosso dei componenti sono stateless e quindi dipendono solo dalle proprietà passate in input dall’esterno. In questi casi che senso ha rivalutare tutte le espressioni di un componente X e dei suoi child se le sue proprietà’ in input non sono cambiate ? A livello di componente e’ possibile specificare ad Angular di utilizzare la strategia onPush;

@Component({
  // ...
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class Comp1 {
  // ...
}

Questa direttiva imposta lo stato della view associata al componente a ChecksEnabled disabilitato. In questo modo Angular durante il ciclo della CD controlla se almeno una referenza delle variabili di input e’ cambiata e in caso negativo non esegue la CD sul componente e di conseguenza sui propri figli.

Strutturando l’applicazione per sfruttare al massimo la strategy on push si riduce il processo di rilevamento delle modifiche  al solo controllo di cosa esattamente è necessario per disegnare le modifiche . La GIF sotto illustra uno scenario di utilizzo della strategy on push
Approfondiamo l’esempio illustrato dalla gif
  • i due componenti figli della root sono configurati con la strategia on push.
  • il componente rappresentato dalla foglia in fondo a destra spara un evento asincrono: ad es. un click che modifica il valore di una proprietà’ di input propagando con un output emit verso il padre il nuovo valore.
  • Tale azione triggera la CD che parte come di consueto dalla root.
  • Essendo i due child della root configurati con strategy on push per ognuno di essi si controlla  se almeno una input reference e’ stata modificata. Non essendo stata modificata la variabile di input del figlio di sinistra su di esso non viene eseguita la CD e di conseguenza neanche sui suoi child. Al contrario la CD verra’ eseguita sulla parte destra dell’albero essendo stata modifica la variabile di input.

Il guadagno in termini di performance e’ immenso: si risparmia l’inutile esecuzione della CD sulla meta’ dei componenti della gerarchia dei componenti. Come si può’ ben capire per grandi gerarchie di componenti questo comporta grandi benefici sulle performance.

on push strategy -> input immutable

Fate attenzione che se dichiariamo un componente con strategy on push occorre lavorare con variabili input immutable. Questo perche’ Angular controlla le referenze e non il valore. Quindi tipi primitivi come numeri o stringhe; in caso di oggetti occorre lavorare con oggetti immutabili ovvero referenze di oggetti il cui valore non cambia una volta creato. Consiglio di utilizzare la libreria immutable js la quale permette di modificare un oggetto garantendo l’immutabilità’ della variabile: la libreria ad ogni modifica crea sotto il naso del programmatore una nuova istanza e quindi nuova referenza contente l oggetto aggiornato. In questo modo anche in caso di input property object Angular e’ in grado di lavorare con la strategy on Push rilevando correttamente le modifiche di oggetti avendo essi una nuova referenza.

e in caso di modifiche a variabili non input ?

E se un componente che fa uso di strategy on push ha bisogno di refreshare la propria view a causa di una modifica di una propria variabile utilizzata all’interno del template senza aspettare una modifica di referenza di una input variable ?  Se la CD salta non viene neanche eseguito il processo che si accorge che la variabile utilizzata dal template e’ cambiato e  che quindi la view ha bisogno di essere ri-disegnata con il nuovo valore. In questi casi Angular mette a disposizioni delle api del change detector per forzare da codice l’esecuzione della change detection sul componente e relativi figli con il metodo detectChanges del change detector ref

constructor(private cd: ChangeDetectorRef) {}

refresh() {
  this.cd.detectChanges();
}

Altri Api utili che danno il pieno controllo della CD al programmatore sono:

  • markForCheck: la quale comanda Angular ad eseguire la change detection al prossimo ciclo bypassando la on push strategy e i suoi vincoli.
  • detach() e reattach(): con questi metodi puoi staccare completamente e ricollegare manualmente il rilevamento delle modifiche.

ONPUSH E OBSERVABLE

Gli Observable si prestano bene a lavorare con la strategia On Push. Quando un Observable viene passato come variabile di input per utilizzare i valori emessi nel template, si riesce facilmente a superare il limite del vincolo di immutabilita’ non rispettato ( essendo un oggetto  l Observable):

  • quando l Observable emette il valore si puo’ utilizzare l’api markForCheck dopo aver aggiornato la variabile utilizzata dal template con il valore emesso
  • utilizzare direttamente l Observable nel template utilizzando la pipe async 

on push vs default

Come abbiamo appena letto On Push Strategy puo’ in molti casi alleggerire il ciclo di CD ma comporta limitazioni e delle forti regole nello scrivere i propri componenti. Il mio consiglio e’ di utilizzare entrambi le strategie nella propria applicazione:

  • Utilizzare la strategy on Push su tutti i componenti foglia dell’albero di gerarchia dei componenti. In una buona architettura frontend i componenti foglia dovrebbero essere tutti stateless e quindi dipendere dagli input del padre.
  • Utilizzare la strategy on Push sui componenti in cui il costo di change detection non e’ trascurabile.
  • Utilizzare la strategy di default per i componenti piu’ in alto nella gerarchia. Come ho scritto più’ volte i change detector di Angular sono veramente veloci e raramente si ha bisogno di approcciarsi interamente con la strategy on push la quale comporterebbe vincoli e controllo manuale della change detection da parte del programmatore. Occorre riflettere anche su un altra cosa: spesso quando si ha problemi di performance non e’ dovuto al tempo di esecuzione di un ciclo di change detection ma piuttosto alla frequenza in cui viene triggerata una CD. In questi casi l’utilizzo di On Push e’ solo un modo per arginare il problema; piuttosto occorrerebbe individuare il motivo per cui la change detection viene triggerata di continuo: come un timer che potrebbe girare tranquillamente al di fuori della zone di angular o uno stream che varia con un altissima frequenza la quale potrebbe essere controllata con un debounceTime.

nella prossima puntata…

Ora che abbiamo chiaro come funziona la Change Detection, nel prossimo articolo andremo a integrare quello già’ scritto in questo articolo con le best practices e trucchi per migliorare le prestazioni della tua web application dal punto di vista della change detection.

A proposito di me

Dario Frongillo

Uno degli admin di Italiancoders e dell iniziativa devtalks.
Dario Frongillo è un software engineer e architect, specializzato in Web API, Middleware e Backend in ambito cloud native. Attualmente lavora presso NTT DATA, realtà di consulenza internazionale.
E' membro e contributor in diverse community italiane per developers; Nel 2017 fonda italiancoders.it, una community di blogger italiani che divulga articoli, video e contenuti per developers.

Di Dario Frongillo

I nostri Partner

Gli articoli più letti

Articoli recenti

Commenti recenti