In questo articolo vedremo 10 trick e suggerimenti per ottimizzare le performance delle nostre applicazioni. L’articolo è un po’ lungo, ma ne varrà la pena, promesso! Pronti, via!
Indice dei contenuti
Angular DevTools
Prima di pensare a qualsiasi ottimizzazione, è importante avere un quadro completo delle performance della nostra app. Utilizzare i DevTool di Chrome può essere difficile, specialmente in app complesse. Un tool che consiglio di utilizzare sono gli Angular DevTools, un’estensione ufficiale che agevola questo lavoro.
Questa estensione si divide in due parti: Components e Profiler. Potete utilizzarla comodamente in ambiente di sviluppo.
Il tab Components mostra l’alberatura completa dell’applicazione, comprensiva di Componenti e Direttive. In questo modo abbiamo a colpo d’occhio il quadro della situazione, e possiamo eventualmente modificare lo stato dei nostri componenti a piacimento per fare dei test rapidi.
Il tab Profiler, invece, è quello che ci interessa maggiormente: grazie a questo strumento possiamo analizzare tutti i cicli di Change Detection del framework, capire immediatamente quali componenti sono stati coinvolti e farci un’idea abbastanza precisa del tempo impiegato per le operazioni e del frame-rate della schermata.
Mi fermo qui per non annoiare, ma se volete altri dettagli sullo strumento vi suggerisco di leggere questa pagina della documentazione… e di provarlo!
Unsubscribe (RxJS)
Ora passiamo alle ottimizzazioni vere e proprie! Una delle cose a cui fare più attenzione in Angular è il ciclo di vita degli Observable: dobbiamo assicurarci infatti di farli terminare al momento giusto.
Ci sono diversi modi per far completare un Observable: quello principale è quello di fare manualmente l’unsubscribe su una Subscription, ad esempio:
ngOnInit() {
this.sub = source$.subscribe(...);
}
ngOnDestroy() {
this.sub.unsubscribe();
}
Ricordatevi quindi di tenere sempre conto del ciclo di vita del componente in cui state usando un Observable: nel momento in cui il componente viene distrutto, Angular lo toglie dal DOM, ma la sua istanza deve essere poi raccolta dal Garbage Collector quando non è più utilizzata. Assicuratevi quindi di terminare ogni logica asincrona nel momento in cui il componente non prende più parte all’applicazione, e questo vale per qualsiasi Observable, a partire da eventi, timer, intervalli, chiamate HTTP, webSocket…
Naturalmente questo non è l’unico modo! Possiamo anche usare diversi operatori, come first
, take
, takeUntil
, takeWhile
…
Tutti questi operatori hanno lo scopo di far terminare l’Observable sorgente in base a un valore, una condizione o un altro Observable: in altre parole, ci evitano di dover fare l’unsubscribe manualmente. Vi lascio qualche esempio:
// Completa dopo i primi 10 valori
source$.pipe(
take(10)
)
// Completa quando emette un numero maggiore di 3
source$.pipe(
takeWhile(x => x < 3)
)
// Completa dopo il primo valore, va in errore se non emette nulla
source$.pipe(
first()
)
// Completa dopo il primo valore che soddisfa la condizione
source$.pipe(
first(x => x > 3)
)
Un altro classico esempio è questo: spesso si utilizza takeUntil
per evitare di assegnare più Observable ad una subscription con lo scopo di far completare tutto all’OnDestroy. Mi spiego meglio.
Questo è un esempio classico:
ngOnInit() {
const s1 = a$.subscribe(...);
const s2 = b$.subscribe(...);
const s3 = c$.subscribe(...);
this.subscription.add(s1);
this.subscription.add(s2);
this.subscription.add(s3);
}
ngOnDestroy() {
this.subscription?.unsubscribe();
}
Come vedete, ci salviamo tutte le Subscription e, quando il componente viene distrutto, eseguiamo l’unsubscribe su tutte.
Invece in questo altro modo potremmo ottenere lo stesso comportamento, senza dover salvare le Subscription:
destroy$ = new Subject<void>();
ngOnInit() {
a$.pipe(
takeUntil(this.destroy$)
).subscribe(...);
b$.pipe(
takeUntil(this.destroy$)
).subscribe(...);
c$.pipe(
takeUntil(this.destroy$)
).subscribe(...);
}
ngOnDestroy() {
this.destroy$.next();
}
Tuttavia bisogna fare un po’ di attenzione quando si utilizza takeUntil: ne ho parlato in quest’altro articolo!
Un altro modo per evitare completamente il problema è quello di non effettuare il subscribe manualmente, ma di affidarsi alla async
pipe di Angular:
<h2>{{ source$ | async }}</h2>
In questo modo, Angular fa il subscribe al posto nostro e, quando il componente viene distrutto, la Subscription viene automaticamente terminata: comodissimo!
Utilizzare le interfacce al posto delle classi
Tipicamente chi proviene da un linguaggio orientato agli oggetti tende ad utilizzare molto le Classi per descrivere il modello di dati. Le classi sono particolarmente utili perché ci permettono, oltre che a modellare un dato con delle proprietà, di poter incapsulare dei comportamenti tramite metodi d’istanza.
Ad esempio, potrei scrivere una classe Person
in questo modo:
export class Person {
public firstName: string;
public lastName: string;
constructor(firstName: string, lastName: string) {
this.firstName = firstName;
this.lastName = lastName;
}
getFullName(): string {
return `${this.firstName} ${this.lastName}`
}
}
Questo modo di ragionare è comodo sotto molti aspetti, ed è un approccio familiare a molti. Ci sono però un paio di problemi!
Il primo macro-problema (perché si divide in più parti), è che a lungo andare modellare il dominio di un’applicazione tramite classi può risultare molto frustrante. Un utilizzo sbagliato delle classi nel paradigma OOP può portare a diversi problemi, ad esempio:
- Banana Monkey Jungle
- Fragile Base Class
- Duplication By Necessity
Ne parlo nel mio videocorso Functional JavaScript, assieme ad una comparazione fra Ereditarietà e Composizione.
Invece, riguardo le performance, il problema è che una classe, per forza di cose, occupa spazio: semplificando il discorso, è un po’ come una funzione, quindi rimane nel codice compilato.
Quello che consiglio di fare quando si tratta di modellare dei dati, è utilizzare Tipi e Interfacce. A differenza delle classi, questi sono costrutti di TypeScript che spariscono in fase di compilazione, e quindi non finiscono nel nostro bundle finale!
Ad esempio, potremmo riscrivere il codice sopra in questo modo:
export interface Person {
firstName: string;
lastName: string;
}
Mentre per i comportamenti possiamo crearci dei metodi stand-alone (e volendo possiamo anche raggrupparli in classi, perché questi sarebbero comunque lasciati nel bundle finale):
// Esempio 1
export function getFullName(firstName: string, lastName: string): string {
return `${firstName} ${lastName}`;
}
// Esempio 2
export function getFullName(person: Person): string {
return `${person.firstName} ${person.lastName}`;
}
// Esempio 3
export class PersonHelpers {
static getFullName(person: Person): string {
return `${person.firstName} ${person.lastName}`;
}
}
In questo modo si cerca anche di evitare i possibili problemi legati all’Ereditarietà delle classi che ho accennato poco sopra.
Pipes vs Methods
Spesso, in un template, è necessario mostrare un dato che ha la necessità di essere elaborato.
Spesso elaborare il dato è molto semplice e può essere fatto direttamente nel template:
<h2>{{ person.firstName }} {{ person.lastName }}</h2>
Altre volte, invece, si preferisce spostare la logica nella classe e chiamare un metodo nel template:
<h2>{{ getFullName() }}</h2>
E chiaramente il discorso vale per le interpolazioni, ma anche per i binding con attributi, proprietà o Input.
Chiaramente l’esempio appena riportato è estremamente semplice e non causerebbe alcun problema di performance, ma se il calcolo da fare fosse pesante, avremmo dei problemi. Questo perché, in uno scenario standard, Angular non ha modo di capire quando è necessario ricalcolare il dato, e quindi chiama nuovamente quella funzione ad ogni ciclo di Change Detection in cui è coinvolto il componente, anche se non ce n’è bisogno.
Possiamo risolvere il problema con una Pipe pura, in questo modo:
@Pipe({
name: "fullName",
pure: true // default
})
class FullNamePipe implements PipeTransform {
transform(person: Person) {
return `${person.firstName} ${person.lastName}`;
}
}
E utilizzandola così:
<h2>{{ person | fullName }}</h2>
In questo modo Angular sa esattamente quando ricalcolare il valore, ovvero quando la proprietà person
cambia. E’ anche possibile passare dei parametri ad una Pipe, e il valore verrà ricalcolato anche quando un parametro cambierà.
Chiaramente il discorso non vale per le Pipe impure, che perdono tale vantaggio.
trackBy
In Angular, quando vogliamo ciclare su un array in un template, utilizziamo la direttiva ngFor
:
<li *ngFor="let person of people">{{ person.firstName }}</li>
Ora immaginiamo che l’array originale cambi, e venga rimpiazzato totalmente con dei nuovi oggetti. Magari alcuni di questi oggetti non sono veramente cambiati nella loro struttura, ma sono cambiati i loro riferimenti in memoria: magari perché li riceviamo da un Observable, o da uno Store che tratta i dati con un approccio immutabile.
Immaginate quindi che l’array originale sia questo:
[
{ id: 1, firstName: 'Michele' },
{ id: 2, firstName: 'Fabio' },
{ id: 3, firstName: 'Giorgio' }
]
E che in un secondo momento venga riassegnato a questo:
[
{ id: 4, firstName: 'Nicola' }, // bye bye, Michele!
{ id: 2, firstName: 'Fabio' },
{ id: 3, firstName: 'Giorgio' }
]
Cosa succede nel DOM? Se l’array viene completamente riassegnato e Angular si accorge che i riferimenti agli utenti sono cambiati, Angular aggiorna tutti i 3 elementi HTML. Potete accorgervene dai DevTool di Chrome, dove vedrete i 3 elementi lampeggiare.
Naturalmente, in molti casi, non ci sono grossi problemi di performance, ma se l’array o gli oggetti che contiene fossero complessi, potremmo avere qualche problema. O, addirittura, comportamenti indesiderati della UI: sto parlando ad esempio di animazioni di entrata e uscita.
Possiamo risolvere facilmente dicendo ad Angular quale proprietà identifica ogni singolo oggetto, nel nostro caso è l’id. Possiamo farlo con trackBy
:
<li *ngFor="let person of people; trackBy: trackPerson">{{ person.firstName }}</li>
export class PeopleComponent {
people: Person[] = [];
trackPerson(person: Person) {
return person.id;
}
In questo modo, quando Angular rileva un cambiamento, utilizza la nostra funzione per capire se l’oggetto è veramente cambiato oppure no. Tornando al nostro esempio precedente, l’unico elemento del DOM ad essere cambiato sarebbe il primo.
Se volete leggere altro su questa tecnica, questo mio altro articolo potrebbe fare al caso vostro!
ChangeDetectionStrategy.OnPush
Di default, ad ogni ciclo di Change Detection (quindi ad ogni evento nel DOM, chiamata HTTP, intervallo, timer…) Angular parte dal componente principale dell’applicazione e percorre tutta la gerarchia dei nostri componenti per controllare se le proprietà dei rispettivi template sono aggiornate.
Da un lato questo meccanismo è estremamente comodo: nel momento in cui qualcosa cambia, Angular lo riflette magicamente nel template. D’altro canto, questa operazione potrebbe portare qualche problema di performance, specialmente in componenti molto complessi.
Possiamo far fare meno fatica ad Angular impostando la Change Detection dei nostri componenti a OnPush, in questo modo:
import { ChangeDetectionStrategy } from '@angular/core';
@Component({
selector: 'app-person',
template: `...`,
changeDetection: ChangeDetectionStrategy.OnPush
})
export class PersonComponent {
@Input() person: Person | null = null;
}
In questo modo, Angular ricontrolla questo componente solo quando:
- Uno dei suoi
@Input
cambia. Attenzione però: se si tratta di una non-primitiva (come un oggetto o un array) è importante che cambi il suo riferimento in memoria: quindi, in parole povere, fare unpush()
oppure unperson.firstName = '...'
non è l’ideale; è invece l’ideale un approccio immutabile. - Viene scatenato un evento nel DOM (ad esempio un click) sul template del componente o in uno dei suoi figli.
- Un Observable ascoltato da Angular tramite la pipe
async
emette un nuovo valore.
In altre parole, se il nostro componente si comporta un po’ come una funzione pura (quindi dipende solamente da suoi Input), possiamo impostare la strategia OnPush per migliorarne le performance. In questo modo potremmo anche rivalutare (con moderazione) la prassi di chiamare direttamente dei metodi nel template, poiché Angular li richiamerebbe solo quando si verifica una delle tre situazioni appena descritte, quindi non avremmo problemi di performance eccessivi.
Una cosa che dovete sapere, però, è che quando impostate a OnPush un componente, Angular considera impostati a OnPush anche tutti i suoi figli! Quindi, se state pensando di provare questa nuova tecnica, vi consiglio di partire dai componenti più in basso (detti “leaf“, ovvero le “foglie” dell’albero) e man mano risalire verso AppComponent, per essere sicuri di accorgervi in fretta di eventuali bug.
Una volta che abbiamo impostato OnPush, possiamo eventualmente iniettare ChangeDetectorRef
per controllare la Change Detection manualmente:
export class PersonComponent {
constructor(private cdr: ChangeDetectorRef) {}
}
Questo servizio ci mette a disposizione diversi metodi, ad esempio:
detectChanges
: fa partire la Change Detection per il componente e tutti i suoi figli.markForCheck
: non fa partire la Change Detection esplicitamente, ma fa in modo che tutti i genitori del componente vengano ricontrollati durante il ciclo di Change Detection corrente o quello subito successivo.detach
: stacca completamente il componente dalla Change Detection di Angular: utile raramente per componenti veramente complessi.reattach
: riattacca il componente alla Change Detection di Angular, è l’opposto di detach.
In alternativa, possiamo iniettare ApplicationRef
e chiamare il metodo tick()
per causare un ciclo di Change Detection in tutta l’applicazione.
NgZone
Come abbiamo detto poco sopra, Angular riesce ad accorgersi di diversi avvenimenti (eventi nel DOM, chiamate HTTP, timer, intervalli…) e ad ognuno di questi prova a capire cosa è cambiato nella nostra applicazione.
Se volessimo fare qualcosa ma senza farlo sapere ad Angular, potremmo utilizzare un metodo chiamato runOutsideAngular
iniettando NgZone
:
import { NgZone } from '@angular/core';
export class PersonComponent {
constructor(private: ngZone: NgZone) {
this.ngZone.runOutsideAngular(() => {
// Quello che farai qui sarà invisibile agli occhi di Angular,
// e non causerà Change Detection!
})
}
}
Questa funzione ci fa anche il favore di ritornarci il valore ritornato dalla funzione al suo interno (scusate il gioco di parole!).
Esiste anche un metodo opposto, chiamato run
, che esegue la funzione all’interno della Zona di Angular, e quindi causa Change Detection. Questo metodo è particolarmente utile quando ad esempio dobbiamo passare una funzione di callback ad una libreria esterna, che “scappa” dalla Zona di Angular:
import { foo } from 'foojs';
foo.doSomething(x => {
// Quello che accade qui non causa Change Detection
...
this.ngZone.run(() => {
// Quello che accade qui invece, viene osservato da Angular
// e causa Change Detection!
});
});
Se vi capitasse di vedere in console un errore che recita qualcosa del tipo “did you forget to call ‘ngZone.run()’?“, controllate se state passando una vostra funzione come callback ad una libreria o componente di terze parti e risolvete il problema wrappando il codice nel metodo run()
.
Lazy Loading
Normalmente, quando un utente accede alla vostra applicazione, il browser scarica tutti i file per farla funzionare, per ogni rotta, anche se l’utente ne ha visitata una soltanto.
Potete invece suddividere la vostra applicazione in moduli e lasciare che il Router di Angular li carichi dinamicamente solo quando l’utente accede alla rotta corrispettiva!
Ad esempio, al posto di avere una configurazione del genere:
const routes: Routes = [
{
path: 'items',
component: ItemsComponent
}
];
Potreste scegliere di creare un modulo a parte per la rotta /items
e tutte le rotte figlie: ItemsModule!
@NgModule({
imports: [CommonModule, RouterModule.forChild(itemsRoutes)],
declarations: [ItemsComponent]
})
export class ItemsModule {}
Nota bene
Il componente ItemsComponent viene dichiarato solo nel rispettivo modulo!
Questo modulo avrà le sue rotte:
const itemsRoutes: Routes = [
{
path: '',
component: ItemsComponent
}
];
Mentre le rotte principali dell’applicazione diventeranno:
const routes: Routes = [
{
path: 'items',
loadChildren: () => import('./items/items.module').then(m => m.ItemsModule)
}
];
A questo punto, le rotte da /items
in poi verranno delegate al modulo ItemsModule, che può impostare le sue sotto-rotte. In questo esempio, visitando /items
senza aggiungere nulla (stringa vuota) verrà caricato ItemsComponent.
In questo modo l’utente scaricherà ItemsModule e i relativi moduli e componenti solo quando accederà alla rotta /items
o una rotta figlia!
PreloadingStrategy
Continuando il discorso del Lazy Loading, Angular ci offre due possibilità per scegliere come caricare i moduli, dette Preloading Strategies:
- Quella di default è quella di caricare il modulo solo quando la sua rotta viene visitata dall’utente: questa strategia si chiama NoPreloading.
- Possiamo dire ad Angular di caricare subito tutti i moduli, a prescindere dalla rotta visitata dall’utente: questa strategia si chiama PreloadAllModules.
Ecco un esempio della seconda possibilità:
import { PreloadAllModules } from '@angular/router';
// In AppModule...
RouterModule.forRoot(
routes,
{
preloadingStrategy: PreloadAllModules
}
)
Perché mai optare per questa strategia? Beh, in qualche caso può far comodo, se ad esempio c’è la necessità di caricare subito tutti i moduli ma si vuole comunque mantenere un approccio modulare.
Una cosa ancora più interessante però è la possibilità di specificare una strategia custom!
Ad esempio, potremmo flaggare con una proprietà tutte le rotte di cui vogliamo caricare immediatamente i moduli, ad esempio:
const routes: Routes = [
{path: "admin", loadChildren: ..., data: { preload: true }},
{path: "items", loadChildren: ..., data: { preload: false }},
];
A questo punto scriviamo la nostra strategia, in questo modo:
import { PreloadingStrategy, Route } from '@angular/router';
import { Observable, of } from 'rxjs';
export class MyPreloadingStrategy implements PreloadingStrategy {
preload(route: Route, load: () => Observable<any>): Observable<any> {
if (route.data?.preload) {
return loadMe();
}
return of(null);
}
}
E infine indichiamo ad Angular che questa è la strategia da utilizzare:
// In AppModule...
RouterModule.forRoot(
routes,
{
preloadingStrategy: MyPreloadingStrategy
}
)
E abbiamo fatto! A seconda del comportamento che vogliamo ottenere possiamo personalizzare questa strategia: ad esempio, potremmo indicare nei dati della rotta anche un delay per ogni sezione e, nella classe, utilizzare l’operatore di creazione timer()
per ritardarne il caricamento, oppure ancora potremmo chiedere informazioni ad un servizio, o altro.
Web Workers
Come chicca finale, abbiamo i Web Worker. Vi descrivo brevemente il loro scopo!
La nostra applicazione Angular gira sul main thread, che il browser utilizza per creare l’interfaccia grafica. E’ un unico thread, quindi se abbiamo a che fare con calcoli pesanti o complessi, inevitabilmente finiremo per rallentare l’intera applicazione e l’utente lo noterà.
Possiamo però creare dei Web Worker, che il browser avvierà in un thread in background senza impattare sulle performance del thread principale.
Creare un Web Worker con la CLI di Angular è un gioco da ragazzi. Ci basta scegliere dove vogliamo piazzare il Web Worker e lanciare un comando. Se ad esempio vogliamo piazzarlo nella cartella /app
, ci basta usare questo comando:
ng generate web-worker app
Questo comando configura in automatico il progetto per utilizzare Web Worker (se non è già stato fatto), in più crea il file /app/app.worker.ts
:
addEventListener('message', ({ data }) => {
const response = `worker response to ${data}`;
postMessage(response);
});
E infine include questo codice in AppComponent:
if (typeof Worker !== 'undefined') {
// Create a new
const worker = new Worker(new URL('./app.worker', import.meta.url));
worker.onmessage = ({ data }) => {
console.log(`page got message: ${data}`);
};
worker.postMessage('hello');
} else {
// Web workers are not supported in this environment.
// You should add a fallback so that your program still executes correctly.
}
A questo punto potete spostare le vostre funzioni più pesanti all’interno del Web Worker e poi interagirci in questo modo:
- L’applicazione Angular può inviare messaggi al worker con il metodo
postMessage
- L’applicazione Angular può ricevere i messaggi del worker con la funzione di callback
onmessage
- Il Web Worker ascolta i messaggi provenienti dall’applicazione con
addEventListener
- Il Web Worker invia una risposta con
postMessage
Sta a voi quindi utilizzare messaggi adeguati per dialogare fra i due file!