Angular “changedetection” e “databinding”

Strategie per ottimizzare la change-detection e realizzare applicazioni performanti e veloci (OnPush Strategy, Smart e Dump component)

Stefano Marchisio
10 min readAug 6, 2020

Introduzione

In un applicazione Angular il rilevamento delle modifiche rappresenta uno dei pilastri fondamentali; essendo poi Angular un framework a componenti, ogni componente avrà la propria “change-detection” formando così una struttura ad albero.

Il rilevamento delle modifiche (o change-detection) viaggia a braccetto con un’altro pilastro portante di Angular: il “data-binding”. Insieme riescono a rilevare cosa è cambiato per poter nuovamente renderizzare la UI. Angular fornisce inoltre la possibilità di creare strategie di change-detection al fine di ottenere performance migliori.

La prima cosa che ci può venire in mente ora è: ma dobbiamo creare noi una la classe di “change-detection” (o qualche cosa di simile) da legare poi al nostro componente? La risposta è no!

Infatti quando si crea un applicazione Angular, il componente viene scritto in TypeScript ed il relativo template HTML utilizzando la template sysntax (ngIf, ngFor, etc). All’interno del template possono poi essere presenti altri componenti piuttosto che tag speciali quali: ng-template, ng-container, ng-content, etc.

Una cosa che non è pienamente evidente a molti sviluppatori è che questo codice HTML non arriverà mai al browser così come è stato scritto. Infatti tutti i bundle generati sono bundle JavaScript, non c’è traccia di file HTML (stesso discorso per i CSS). Cosa succede allora?

In Angular (oltre al compilatore TypeScript) è presente un altro compilatore che compila il template HTML in istruzioni JavaScript. Queste istruzioni verranno poi interpretate da un runtime per creare gli elementi del DOM, piuttosto che modificarli quando lo stato del componente cambia.

Oltre a queste istruzioni il compilatore genera anche le classi di change-detection (una per ogni componente). Durante il processo di compilazione il compilatore verifica anche se nel templete HTML esistono dei binding, che verranno poi usati dalle classi di change-detection per sincronizzare la UI con il modello sottostante. Senza entrare troppo nel dettaglio, la classe di change-detection mantiene il valore corrente e quello vecchio per ogni proprietà di binding. Quando scatta il rilevamento delle modifiche i 2 valori vengono confrontati, e nel caso siano diversi sarà change-detection che propaga le modifiche alla vista (in modo che view e model siano sincronizzate).

@Component({
selector: ‘app-root’,
template: `
<h2>{{count}}</h2>
`
})
export class AppComponent implements OnInit {
count: number = 10;
ngOnInit() {
setInterval(() => {
this.count = this.count + 1;
},100)
}
}

Il codice sovrastante manterrà sincronizzate la view ed il model aggiornando la vista ogni 100 millisecondi.

Quando viene eseguita la change-detection

Ma come fa un applicazione a sapere che sono state fatte delle modifiche, e pertanto deve essere lanciato il processo di change-detection in modo da individuare esattamente cosa è cambiato?

In Angular questo compito è svolto da ngZone / zone.js. Senza entrare nel dettaglio ngZone è un estensione di zone.js, questa libreria viene utilizzata per rilevare quando si verificano determinate operazioni asincrone a fronte delle quali è necessario attivare un ciclo change-detection. Infatti il meccanismo di change-detection da solo non è in grado di fare ciò, può solo verificare se le proprietà di binding presenti nel template sono cambiate ed è necessario aggiornare la UI. Ma non è in grado di rilevare eventi che necessitano un ciclo di change-detection.

Zone.js (come detto) è una libreria esterna e si occupa di rimappare: eventi di interfaccia, eventi XHR, funzioni javascript asincrone ( setTimeOut() or setInterval() ). Ovvero tutti quegli eventi che possono in qualche modo modificare l’interfaccia e rendere necessario un nuovo ciclo del rilevamento delle modifiche. Nel momento in cui zone.js rileva uno di questi eventi, invierà una notifica al meccanismo di change-detection. Di default zone.js è abilitato (grazie a questo la MAGIA di Angular), ma è anche possibile disabilitare zone.js. In questo caso sarà lo sviluppatore che si deve preoccupare di avviare un ciclo di change-detection da codice utilizzando le opportune API (quando necessario).

Il codice sottostante è contenuto nel file main.ts, tramite “{ ngZone: ‘noop’ } ” permette di configurare un applicazione senza l’uso di zone.js.

if (environment.production) {
enableProdMode();
}
platformBrowserDynamic().bootstrapModule(AppModule,{ngZone: ‘noop’}

Nel componete sottostante all’interno del costruttore viene iniettato un oggetto di tipo “ChangeDetectorRef”, che rappresenta la classe di “change-detection” associata al componente. Questa classe ha diversi metodi tra i quali: detectChanges(), markForCheck(), etc. Tramite questi metodi è possibile invocare da codice il rilevamento delle modifiche. Il metodo detectChanges() avvia subito un rilevamento delle modifiche, il metodo markForCheck() contrassegna come da controllare il componente (il rilevamento delle modifiche sarà effettuato al prossimo ciclo).

@Component({
selector: ‘app-child’,
templateUrl: ‘./child.component.html’,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChildComponent {
@Input() data: string[];
constructor(private cd: ChangeDetectorRef) {} refresh() {
this.cd.detectChanges();
}
}

In questo modo è responsabilità dello sviluppatore invocare il rilevamento delle modifiche. Va inoltre ricordato che se si disabilità zone.js questa non verrà inclusa nel bundle, l’effetto positivo di questa cosa sarà un conseguente risparmio nelle dimensioni del bundle.

Di default zone.js è abilitato, ma all’interno del nostro codice possiamo definire zone in cui il rilevamento delle modifiche non è abilitato. Il componente sottostante mostra un esempio di ciò.

@Component(…)
export class AppComponent {
date: Date;
constructor(private zone: NgZone) {} ngInit() {
this.ngZone.runOutsideAngular(() => {
setInterval(() => {
this.time = new Date()
this.cdr.detectChanges()
}, 1000)
})
}

Il codice eseguito all’interno della arrow function this.ngZone.runOutsideAngular() non farà scattare il rilevamento delle modifiche, anche se si utilizzano istruzioni ( come setInterval() ) che fuori l’avrebbero generato.

Abbiamo detto che ogni componente ha la propria “change-detection” ed insieme formano una struttura ad albero. Nel momento in cui verrà rilevato un evento che potrebbe aver cambiato qualche cosa, verrà lanciato il processo di “change-detection”. Questo avviene partendo dal componente radice e scendendo verso il basso.

Di default la “change-detection” in Angular controlla tutti i componenti partendo dal componente radice e scendendo verso il basso.

Come detto poco sopra, la classe di change-detection associata ad ogni componente mantiene il valore corrente e quello vecchio per ogni proprietà di binding. Quando scatta il rilevamento delle modifiche i 2 valori vengono confrontati, e nel caso siano diversi sarà change-detection che propaga le modifiche alla vista (in modo che view e model siano sincronizzate).

Cosa molto importante da tener presente, il confronto tra il vecchi ed il nuovo valore avviene per referenza.

Come è facilmente intuibile questo fa si che ad ogni minimo cambiamento debba essere ricontrollato tutto. Ora, se la nostra interfaccia ha un numero ridotto di componenti (e di dati) questo non è un problema, anche perchè in Angular sono presenti diverse ottimizzazioni. Ma se la nostra interfaccia è complessa allora potremmo avere qualche problema di prestazione. Per questo motivo esistono varie tecniche che permettono di effettuare delle ottimizzazioni.

Strategie per ottimizzare le prestazioni (OnPush)

In Angular esistono 2 strategie di change-detection:

· Default

· OnPush

Come dice il nome la strategia di Default è quella descritta poco sopra.

@Component({
selector: ‘ns-pony’,
template: `
<p>New color every 1s</p>
<img [src]=”’pony-’ + color + ‘.gif’”>
`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PonyComponent implements OnInit, OnDestroy {
}

Mentre la strategia “OnPush” si base sul seguente principio.

Angular lancia la chenge-detection solo se viene passato un nuovo valore ad una proprietà di @Input().

Come anticipato poco sopra, il controllo tra il vecchio ed il nuovo valore di una proprietà di @Input() avviene per referenza. Per cui se assegno un nuovo valore ad un proprietà di @Input() di tipo value-type (string, number, boolean, null, undefined); essendo i tipi valore immutabili il nuovo valore avrà una referenza diversa ogni volta e scatterà un nuovo ciclo di change-detection.

Se invece assegno ad una proprietà di @Input() un oggetto complesso (per empio Person), se provo a modificare il cognome della persona (ma senza ricreare l’oggetto Person) la chenge-detection non scatterà. Questo perchè il controllo avviene per referenza e l’oggetto Person è sempre lo stesso.

Da qui l’esigenza che nel caso sia stata abilitata una strategia “OnPush”, i valori passati come parametri di @Input() siano “IMMUTABILI”! Cioè nel caso desideriamo modificare anche solo una proprietà di un oggetto complesso, dobbiamo sempre ricreare l’oggetto!

Esistono diversi modi per fare ciò, si possono utilizzare una librerie esterne come “Immutable.js o “Lodash”; piuttosto che usare gli operatori si spred di JavaScript, in questo caso non otterremo una deep-copy ma una shallow-copy, un oggetto clonato solo di un livello.

Un modo alternativo per ottenere una deep-copy di un oggetto è utilizzare i metodi JSON.parse( JSON.stringify(value) ), in questo caso però l’oggetto deve avere solo: strings, numbers, true, false, null e nested-objects.

In questo modo possiamo far si che la “change-detection” non verrà rilanciata sull’intero albero dei componenti, bensì solo su alcuni rami dell’applicazione; i rami ai cui parametri di @Input() è stato passato un oggetto clonato.

Una cosa essenziale da tenere a mente è che: anche se abbiamo impostato una strategia onPush su un componente ma non è stata passata una nuova referenza ad un parametro di @Input(), Angular eseguirà comunque la change-detection se si verifica una delle seguenti condizioni: 1) Un evento all’interno del componente, 2) In presenza di Observable usati in un certo modo.

Parlando di best practice, è consigliabile definire una variabile di tipo $Observable ed usare async pipe all’interno del template HTML. Angular automaticamente invocherà la change-detection quando l’async pipe avrà un nuovo valore. Dall’altro canto è comunque possibile sottoscrivere manualmente Observable, in questo caso la change-detection non scatterà automaticamente, ma sarà necessario richiamare manualmente detectChanges(). Ma questa non è una best practice!

Smart component o Dump component

Nel caso abbiamo scelto di abilitare la “OnPush Strategy” è buona norma tenere in considerazione il pattern architetturale “Smart component e Dump component”.

Come abbiamo detto all’inizio dell’articolo, Angular un framework a componenti ed applicazione Angular è una gerarchia di componenti innestati, formando così una struttura ad albero.

Ma questo punto sorge la necessità di far comunicare tra loro i componenti di livello superiore con quelli di livello inferiore e viceversa, per far ciò abbiamo diverse opzioni (alcune buone altre no). Infatti possiamo passare ad un componente la referenza di un altro componente ma in questo modo si viene a creare una dipendenza tra le parti e questo noi non lo vogliamo perché viola in principi base in una qualsiasi architettura software.

Che fare allora? Potrebbero essere di aiuto le proprietà di @Input() e @Output()? La risposta è SI!

Come si può vedere dal grafico sottostante se facciamo in modo che i componenti di livello superiore comunicano con quelli di livello inferiore solo tramite proprietà di @Input(), mentre i componenti di livello inferiore comunicano con quelli di livello superiore solo tramite proprietà di @Output() ( o eventi ), otterremo invece una struttura debolmente accoppiata.

A questo punto sorge spontanea un’altra domanda. Ma se un componente ha la necessità di leggere dei dati cosa faccio? Passo un servizio tramite il quale posso leggere ciò che mi serve (a qualsiasi livello il componente si trova) ?

La risposta è “ni”, infatti otterrei anche in questo caso una dipendenza (non da un componente) ma da un servizio. Avendo poi un potenziale servizio sparso in vari componenti, nel momento in cui ho la necessità di cambiare qualche cosa, dovrei intervenire su tutti i componenti che utilizzano il servizio. Che fare allora?

La cosa ottimale è avere un componente di livello superiore che conosce il servizio, e non è necessariamente collegato alla UI in modo diretto, ma è in grado di recuperare i dati che servono. Poi attraverso le proprietà di @Input() li passa ai componenti inferiori. Analogamente se un componente ad un livello inferiore che ha la necessità di modificare qualche cosa, attraverso le proprietà di @Output() passa i dati al componete di livello superiore (che conosce il servizio) e questo fa ciò che serve. Eventualmente poi passa i dati nuovamente verso il basso avendo cura di ricrearli (IMMUTABILITA!)

Quello appena detto è in sintesi ciò che fanno: “Smart component” e “Dump component”.

In rete possiamo trovare la seguente definizione.

Dumb components are also called ‘presentational’ components because their only responsibility is to present something to the DOM. Once that is done, the component is done with it. No keeping tabs on it, no checking in once in a while to see how things are going. Nope. Put the info on the page and move on.

Smart components (or container components) on the other hand have a different responsibility. Because they have the burden of being smart, they are the ones that keep track of state and care about how the app works.

Conclusioni

Un architettura simile si presta bene all’uso di una strategia “OnPush”. Il componente “Dump” è l’unico a conosce il servizio, una volta che ha recuperato i dati li passa verso il basso attraverso le proprietà di @Input(). Se è una “fresh reference” scatterà la chage-detection, in caso contrario la chage-detection non scatterà. Da qui l’esigenza che i valori passati come parametri di @Input() siano “IMMUTABILI”! Nel caso desideriamo modificare solo una proprietà di un oggetto complesso, dobbiamo sempre ricreare l’oggetto.

Se volete contattarmi il mio profilo Linkedin è il seguente: Stefano Marchisio: Consulente freelance Angular ASP.NET MVC C#

--

--