blexin

Sviluppo
Consulenza e
Formazione IT


Blog

Evolvi la tua azienda

Cosa si nasconde dietro il rendering in un web browser

La change detection in Angular

Mercoledì 11 Novembre 2020

Quando parliamo di soluzioni web, la change detection è un elemento fondamentale del framework che stiamo utilizzando perché è responsabile degli aggiornamenti nel DOM.

Non dimentichiamo, poi, che influisce in maniera significativa sulle performance dell’applicazione stessa.

La change detection ha due momenti principali: il tracciamento delle modifiche e il rendering.

Partiamo dal rendering che è quel processo che prende lo stato interno del programma (ossia oggetti e array Javascript) e lo proietta in qualcosa che vediamo sullo schermo (immagini, bottoni e altri elementi visuali).

In principio, l’implementazione della logica di rendering non sembra difficile, ma si complica quando consideriamo la variabile temporale. Lo stato dell’applicazione può cambiare in qualsiasi momento per effetto dell’interazione dell’utente o per l’arrivo dei dati da una chiamata HTTP.

Fonte

C’è un esempio molto interessante a questo indirizzo scritto in puro Javascript per un widget di rating.

A parte il codice di inizializzazione, possiamo scrivere una classe (chiamiamola RatingComponent) contenente una proprietà rating il cui valore muta in base alla selezione. Tale mutamento deve produrre anche un aggiornamento del DOM. Il setter della proprietà rating è il momento giusto per triggerare da codice il cambio delle classi css coinvolte.

export class RatingsComponent {
    ...
    set rating(v) {
        this._rating = v;

        // triggers DOM update
        this.updateRatings();
    }

    get rating() {
        return this._rating;
    }

    updateRatings() {
        // Update DOM code
    }
}

Questo codice non scala all’aumentare della complessità della pagina e della logica condizionale da usare nei suoi elementi. La nostra attenzione deve concentrarsi sulla logica applicativa e non sull’aggiornamento dello schermo.

Questo è il motivo per cui usiamo un framework come Angular o librerie come React. Un framework può occuparsi del tracciamento delle modifiche e del rendering al posto nostro e lo può fare in maniera molto efficiente.

Come viene implementata in Angular la change detection?

Quando il compilatore analizza il template, identifica le proprietà del componente che sono associate con gli elementi del DOM. Per ciascuna di queste associazioni, il compilatore crea un binding. Un binding definisce una corrispondenza 1:1 tra la proprietà coinvolta e una proprietà di un elemento del DOM.

Una volta che i binding vengono creati, Angular smette di lavorare col template. Il meccanismo di change detection esegue istruzioni che processano ogni binding.

Queste istruzioni controllano se il valore di un’espressione con una proprietà di un componente è cambiata e se necessario aggiornano il DOM.

Prendiamo come esempio una possibile implementazione del componente rating visto in precedenza.

<ul class="rating" (click)="handleClick($event)">
    <li [className]="'star ' + (rating > 0 ? 'solid' : 'outline')"></li>
    <li [className]="'star ' + (rating > 1 ? 'solid' : 'outline')"></li>
    <li [className]="'star ' + (rating > 2 ? 'solid' : 'outline')"></li>
    <li [className]="'star ' + (rating > 3 ? 'solid' : 'outline')"></li>
    <li [className]="'star ' + (rating > 4 ? 'solid' : 'outline')"></li>
</ul>

In questo caso, la proprietà rating nel template è collegata alla proprietà className mediante l’espressione:

[className]="'star ' + ((ctx.rating > 0) ? 'solid' : 'outline')"

Il binding che viene creato inizialmente sarà un oggetto con un valore corrente (supponiamo che sia ‘outline’) e una proprietà chiamata dirty settata a false.

Quando il rating viene aggiornato, il binding sarà marcato come dirty uguale a true e il valore impostato a ‘solid’. Una istruzione successiva controlla se il binding è dirty e in tal caso usa il nuovo valore per aggiornare il DOM (ossia la className).

Ci sono due modi per innescare la change detection. Il primo è invocarla manualmente dal nostro codice. Il secondo è affidarsi completamente al framework e farla gestire in maniera automatica.

Nel primo caso, possiamo iniettare il servizio ChangeDetectorRef (documentato qui) in un componente e in corrispondenza di un evento invocare il suo metodo detectChanges.

Nel secondo caso, non aggiungiamo nulla al nostro codice. La domanda da porsi è quindi: come fa il framework a sapere di dover eseguire la change detection?

È semplice intercettare un evento e schedulare una change detection dopo che il codice applicativo è stato eseguito. Il problema sono ancora una volta gli eventi asincroni come setTimeout, Promise o XHR che non sono gestiti da Angular.

Per risolvere il problema, il framework usa una libreria esterna chiamata zone.js che crea un wrapper attorno a tutti gli eventi asincroni nel browser. Zone.js può quindi notificare Angular quando un particolare evento avviene.

Per usare Zone, occorre importare il package zone.js. Se abbiamo usato la command line interface ciò avviene alla creazione di un progetto. Inoltre vedremo la riga seguente nel file src/polyfills.ts

/***************************************************************************************************
 * Zone JS is required by default for Angular itself.
 */
    import 'zone.js/dist/zone';  // Included with Angular CLI.

 

È importante sottolineare che le zone non fanno parte del meccanismo di change detection in Angular. Infatti, Angular può lavorare anche senza di esse come potete leggere qui.

Basta modificare il file main.ts che esegue il bootstrap della nostra applicazione.

platformBrowserDynamic()
    .bootstrapModule(AppModule, {
        ngZone: 'noop'
    });

Le zone sono un contesto di esecuzione per operazioni asincrone. Sono molto utili per gestire gli errori e fare profiling. Cosa vuol dire in pratica?

Per capire la parte della definizione relativa al contesto di esecuzione, cerchiamo di isolare il problema che le zone stanno cercando di risolvere.

Consideriamo 3 funzioni che vengono eseguite in sequenza:

functionA();
functionB();
functionC();

function functionA() {...}
function functionB() {...}
function functionC() {...}

Supponiamo di voler misurare il tempo di esecuzione di questo codice.

<script>
const t0 = performance.now();
functionA();
functionB();
functionC();
const t1 = performance.now();
console.log(Math.floor((t1-t0)*100) / 100 + ' ms');
 
function functionA() { console.log('function A');}
function functionB() { console.log('function B');}
function functionC() { console.log('function C');}
</script>

Spesso però abbiamo a che fare con l’esecuzione di codice asincrono.

Le operazioni asincrone non vengono considerate dal nostro profiler. Ad esempio:

<script>
const t0 = performance.now();
functionA();
functionB();
functionC();
const t1 = performance.now();
console.log(Math.floor((t1-t0)*100) / 100 + ' ms');
 
function functionA() { setTimeout(doSomething, 0);}
function functionB() { setTimeout(doSomething, 0);}
function functionC() { setTimeout(doSomething, 0);}
function doSomething() {
   console.log('Async task');
}
</script>

produce come risultato:

Cosa è successo nel nostro codice? Il linguaggio Javascript è single-threaded. Il comportamento asincrono non è parte del linguaggio stesso, piuttosto è stato aggiunto ad esso nel browser. Le funzionalità asincrone sono accessibili mediante le cosiddette API del browser.

Fonte

In questa immagine, l’Heap è una regione di memoria per lo più non strutturata dove vengono allocati gli oggetti.

Lo Stack è il single thread fornito per l’esecuzione di codice Javascript. Le chiamate alle funzioni formano uno stack di frame. Cos’è un frame? Torniamo al nostro esempio nel caso sincrono e modifichiamolo leggermente

function functionA(x) {
   let y = 3;
   return functionB(x * y);
}
 
function functionB(b) {
   let a = 10;
   return a + b + 11;
}
console.log(functionA(5));

Quando invochiamo functionA, viene creato un frame contenente l’argomento passato e le variabili locali. Quando functionA invoca functionB, viene creato un secondo frame e messo in cima al primo. Anch’esso conterrà l’argomento passato e le variabili locali. Quando functionB ritorna un valore, il suo frame viene rimosso dallo stack (lasciando solo il frame della functionA). Quando functionA restituisce un valore, lo stack viene svuotato.

Resta da capire cos’è quella Queue nell’immagine. Consideriamo il seguente esempio che introduce una chiamata setTimeout.

function main() {
   console.log('A');
   setTimeout( function exec() {
       console.log('B');
   }, 0);
}
 
main();
// Output
// A
// C
// B

setTimeout() ha un tempo di attesa di 0 ms e una funzione di callback chiamata exec(). setTimeout() viene pushata nello stack e parte la sua esecuzione. Ma setTimeout fa parte delle API del browser. Quindi la responsabilità di avviare la funzione exec() è delegata alle API del browser. Nel frattempo setTimeout() viene tolto dallo stack.

Dopo i 0 ms di attesa, le API del browser non possono pushare la funzione exec direttamente nello stack. Ciò che possono fare è mettere la funzione exec() in una coda di funzioni in attesa (Message Queue).

Solo quando lo stack è vuoto (privo di frame), il primo elemento in coda nella Message Queue può essere pushato nello stack.

Poiché nello stack nel frattempo è stato pushato console.log(‘C’), questa stampa precederà quella prodotta da exec()

Lo 0 che c’è in setTimeout() provoca spesso confusione. Non è mai 0 ma piuttosto il tempo minimo di attesa dopo il quale verrà eseguita la funzione di callback.

Se inseriamo un’istruzione bloccante, lo svuotamento della coda verrà ulteriormente ritardato.

Vi consiglio di dare uno sguardo a questa pagina web contenente una fantastica rappresentazione visuale di quello che abbiamo appena spiegato. È possibile anche modificare il codice.

Torniamo al nostro problema del profiling in presenza di codice asincrono. Avremmo bisogno di quelli che in gergo si chiamano hooks (ganci) che ci consentano di eseguire il codice di profiling ogni qual volta scatta un'operazione asincrona.

Ecco dove entrano in azione le zone. Le zone possono eseguire un’operazione (ad esempio avviare o fermare un timer o salvare uno stack trace) ogni qual volta quel codice entra o esca da una zona. Possono addirittura fare l’override di metodi nel nostro codice o anche associare dati a singole zone.

Tornando ad Angular, quando viene eseguito il bootstrap di un’applicazione viene creata una zona chiamata Ngzone. Questa è la zona in cui l’applicazione gira e il framework viene notificato solo da eventi avvenuti in questa zona.

Esiste anche un servizio NgZone che può essere iniettato nella nostra applicazione che consente di forkare (consentitemi il termine) la zona principale di Angular se vogliamo eseguire codice al suo esterno.

Più in generale, il servizio NgZone mette a disposizione molti Observable e metodi per stabilire lo stato della zona di Angular e per eseguire codice in modalità differenti all’interno ed esterno della zona di Angular.

Possiamo vedere un semplice esempio dove diventa necessario usare questo servizio.

Prendiamo il codice seguente con il suo template:

export class AppComponent {
 get time() {
   return Date.now();
 }
}

<h3>
     Change detection is triggered at:
     <span [textContent]="time | date:'hh:mm:ss:SSS'"></span>
</h3>
<button (click)="0">Trigger Change Detection</button>

Sorprendentemente, questo codice genera un errore in console

Si tratta di un errore che è capitato a chiunque abbia mai lavorato con Angular come potete leggere nella pagina delle issue della repository su github. Nonostante il nome lungo spieghi esattamente cosa sia successo la prima reazione è sicuramente una ricerca disperata su StackOverflow.

Quando Angular crea i nodi del DOM per renderizzare il template di un componente sullo schermo, occorre conservare da qualche parte le referenze a questi nodi. A tale scopo, c’è una struttura dati chiamata View che conserva la referenza all’istanza del componente e i precedenti valori delle binding expression.

Abbiamo detto che la change detection avviene su ciascun componente. Ora che sappiamo che un componente è internamente rappresentato come una view, possiamo dire che la change detection viene eseguita su ciascuna view.

Quando una view viene controllata, Angular esegue un loop su tutti i binding in cui vengono valutate tutte le expression e i valori risultanti confrontati con un array chiamato oldValues. Se c’è una differenza, la proprietà del DOM viene aggiornata così come l’array oldValues.

Dopo ciascun ciclo di change detection, in modalità di sviluppo, Angular esegue in sincrono un altro check per assicurarsi che le expression producano gli stessi valori del precedente ciclo di change detection. Questo check non aggiorna il DOM ma solleva la nostra simpatica eccezione.

Perché eseguire un nuovo ciclo di controllo?

Supponiamo che alcune proprietà del componente siano state aggiornate durante la change detection. Le expression corrispondenti hanno dunque un nuovo valore inconsistente con ciò che è stato mostrato sullo schermo. Angular potrebbe sicuramente avviare un nuovo ciclo di change detection ma è chiaro che nascerebbe un loop infinito di cicli di change detection .

Per evitare questa situazione, il framework ha imposto ciò che viene chiamato UniDirectional Data Flow. Ossia, una volta che sono stati processati i binding per un componente, non possiamo più aggiornare le sue proprietà che vengono usate nelle expression. Il sollevamento della ExpressionChangedAfterItHasBeenCheckedError è lo strumento attraverso il quale viene implementato il data flow.

Ok, a questo punto potremmo usare un setInterval per scatenare un nuovo ciclo di change detection. Sbagliato! Ancora una volta avremmo un ciclo infinito di change detection.

La soluzione consiste nell’eseguire l’aggiornamento della proprietà time in una zona diversa da quella di Angular. Come realizzarla? La prima cosa che ci può venire in mente è usare un setInterval in questa nuova zona.

export class AppComponent {
    mytime: number;
 
    get time(): number {
        return this.mytime;
    }
 
constructor(private zone: NgZone) {
    this.mytime = Date.now();
 
    this.zone.runOutsideAngular(() => {
        setInterval(() => {
            this.mytime = Date.now();
        }, 0);
    });
    }
}

Ricordiamo però che l’uso di un setInterval è sempre da deprecare nel codice reale che si porta in produzione.

Possiamo invece adottare la strategia onPush su un componente (ChangeDetectionStrategy.onPush) per dispensare Angular dalla scelta di quando eseguire la change detection.

Con onPush la change detection strategy verrà avviata quando:

  1. ci sono cambi in una @Input
  2. c’è un evento originato nel componente o in suo figlio
  3. usiamo una async pipe
  4. Eseguiamo, come nel nostro esempio, la change detection esplicitamente (con markForCheck() sul ChangeDetectorRef iniettato nel componente)

Spero che l’argomento vi abbia interessato e che abbia gettato un po’ di luce su un argomento estremamente complesso.

Alla prossima!

ISCRIVITI ALLA NEWSLETTER

Autore

Servizi

Evolvi la tua azienda