blexin

Consulenza
Servizi e
Formazione it


Blog

Evolvi la tua azienda

Come migliorare le performance delle nostre applicazioni Angular usando le Pipe e disabilitando la Change Detection dove non serve.

Angular Performance Improvements

Una delle caratteristiche più interessanti delle librerie di front end è la capacità di tenere in binding l'interfaccia utente con le proprietà contenenti i dati da visualizzare. Anche Angular offre questa caratteristica, utilizzando un meccanismo che viene chiamato Change Detection, grazie al quale "capisce" quando aggiornare l'interfaccia a fronte di un cambiamento di stato dell'applicazione.

Trovate molti articoli in rete che spiegano come funziona nel dettaglio questo meccanismo e come sfrutta zone.js per capire quando intervenire per aggiornare la UI, ma vorrei provare, con un esempio semplice, a evidenziare come questo meccanismo può impattare sulle performance della nostra applicazione se non facciamo attenzione.

Partiamo da un semplice componente in cui inseriamo una casella di testo in binding bidirezionale con una proprietà del componente stesso. Quando digitiamo in questa casella un testo e premiamo invio, vogliamo pushare la stringa digitata all'interno di una semplice lista non ordinata, insieme a un numero tra 1 e 10 generato casualmente. Se vogliamo vedere a occhio nudo l'impatto che può avere la change detection sulla user experience dobbiamo però forzare un po' la mano, quindi quando visualizzeremo l'elemento della lista non ci limiteremo a stampare semplicemente il valore numerico, ma invocheremo un metodo, direttamente dall'espressione di binding, che calcolerà dal valore originale, moltiplicato per 4, il corrispondente numero di Fibonacci:

import { Component } from '@angular/core';

@Component({
  selector: 'app-sample-list',
  templateUrl: './sample-list.component.html'
})
export class SampleListComponent {

  text: string;
  items: {label: string, value: number}[] = [];

  addTextToList() {
    const randomValue =  Math.floor(Math.random() * 10) + 1;
    this.items.push({ label: this.text, value: randomValue });
  }

  generateValue(value: number) {
    console.log('value ' + value + ' generated');
    return this.fibonacci(value * 4);
  }

  private fibonacci(num: number): number {
    if (num === 1 || num === 2) {
      return 1;
    }
    return this.fibonacci(num - 1) + this.fibonacci(num - 2);
  }
}
<input type="text" [(ngModel)]="text" (keydown.enter)="addTextToList()">
<ul>
  <li *ngFor="let item of items">
    {{ item.label }} ({{ generateValue(item.value) }})
  </li>
</ul>

Reminiscenze del corso di informatica vi ricorderanno che calcolare un valore della sequenza di Fibonacci ha una complessità computazionale che cresce esponenzialmente al crescere del valore stesso. Ovviamente questa implementazione è solo un esempio, ma ci serve per esasperare il problema. Notate che nel codice non ho chiamato direttamente l'algoritmo, ma ho invocato un metodo che prima stampa in console una stringa e poi esegue il calcolo. Questo ci permette di vedere dalla console quante volte il metodo viene chiamato mentre interagiamo con l'interfaccia:

change detection sample

Come potete vedere dall'immagine, ogni volta che modificate il testo la change detection richiama il metodo di calcolo per ogni elemento nella lista. Se provate a inserire un po' di elementi vi accorgerete dopo un po' che la responsività dell'applicazione si riduce visibilmente.

Come possiamo intervenire per migliorare la situazione? Un primo approccio è quello di riorganizzare il nostro componente esternalizzando la casella di testo, in modo che la lista da visualizzare venga passata in input al componente e non elaborata contestualmente. Nel nostro esempio spostiamo in app-component il testo e la gestione della lista:

<input type="text" [(ngModel)]="text" (keydown.enter)="addTextToList()">
<app-sample-list [items]="items"></app-sample-list>
import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  text: string;
  items: {label: string, value: number}[] = [];

  addTextToList() {
    const randomValue =  Math.floor(Math.random() * 10) + 1;
    this.items.push({ label: this.text, value: randomValue });
  }
}

Lasceremo nel componente lista solo la parte di calcolo della sequenza di Fibonacci. La change detection in Angular si propaga dal componente in cui stiamo interagendo a tutti i suoi figli, quindi spostando la casella di testo nel componente padre non risolviamo il problema. Possiamo però disabilitare la change detection nel componente lista, in modo che il valore non venga ricalcolato ogni volta. Potete fare questa cosa impostando la change detection strategy del componente nel suo decoratore. I valore possibili sono Default, il comportamente predefinito, e OnPush, con cui possiamo dire ad Angular che saremo noi a indicare quando eseguire il ciclo di aggiornamento dell'interfaccia. La cosa interessante è che l'interruzione della change detection non si applica ai parametri di Input, quindi se il valore in Input cambia, l'interfaccia sarà aggiornata:

import { Component, Input, ChangeDetectionStrategy } from '@angular/core';

@Component({
  selector: 'app-sample-list',
  templateUrl: './sample-list.component.html',
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class SampleListComponent {

  @Input()
  items: {label: string, value: number}[] = [];

  generateValue(value: number) {
    console.log('value ' + value + ' generated');
    return this.fibonacci(value * 4);
  }

  private fibonacci(num: number): number {
    if (num === 1 || num === 2) {
      return 1;
    }
    return this.fibonacci(num - 1) + this.fibonacci(num - 2);
  }
}

Eseguendo però questo codice l'applicazione smette di funzionare. Questo accade perchè stiamo fornendo in input un array che cresce ad ogni inserimento, ma il riferimento all'array non cambia, quindi Angular non si accorge del cambiamento. Questo è il momento in cui l'immutabilità ci risolve il problema. Come primo approccio proviamo a ricreare l'array ogni volta che aggiungiamo un valore, il modo più stupido è convertire l'array in una stringa e poi ritrasformarlo in un array:

addTextToList() {
  const randomValue =  Math.floor(Math.random() * 10) + 1;
  this.items.push({ label: this.text, value: randomValue });
  this.items = JSON.parse(JSON.stringify(this.items));
}

Brutto ma efficace. In questo modo potete vedere che la modifica al testo nella casella non scatena il ricalcolo dei valori della lista:

change detection disabilitata

Questo approccio però ha il problema che l'operazione di ricreazione della lista ad ogni inserimento, oltre a essere scomodo, è anche poco efficiente, sia in termini di tempo che di occupazione di memoria. Per risolvere questo ulteriore problema possiamo utilizzare immutable.js (https://immutable-js.github.io/immutable-js/), che fornisce tutti gli strumenti necessari a lavorare con oggetti immutabili ottimizzando le risorse. Potete facilmente installarlo con il solito npm i immutable. A questo punto possiamo utilizzare le liste immutabili invece del semplice array, che forniscono il metodo unshift per l'aggiunta di un nuovo elemento, il quale restituisce la nuova istanza dell'array.

import { Component } from '@angular/core';
import { List } from 'immutable';

@Component({
  selector: 'app-root',
  templateUrl: './app.component.html'
})
export class AppComponent {
  text: string;
  items = List<{label: string, value: number}>();

  addTextToList() {
    const randomValue =  Math.floor(Math.random() * 10) + 1;
    this.items = this.items.unshift({ label: this.text, value: randomValue });
  }
}

Questa libreria ottimizza le risorse creando una struttura dati in cui gli elementi già presenti non vengono persi, ma riutilizzati nel nuovo array.

Sfruttiamo adesso una caratteristica poco conosciuta delle Pipe di Angular. Creiamo una Pipe per calcolare la sequenza di Fibonacci invece di invocare il metodo del componente:

import { Pipe, PipeTransform } from '@angular/core';

@Pipe({
  name: 'fibonacci',
  pure: true
})
export class FibonacciPipe implements PipeTransform {
  transform(value: any, ...args: any[]): any {
    console.log('value ' + value + ' generated');
    return this.fibonacci(value * 4);
  }

  private fibonacci(num: number): number {
    if (num === 1 || num === 2) {
      return 1;
    }
    return this.fibonacci(num - 1) + this.fibonacci(num - 2);
  }
}
<ul>
  <li *ngFor="let item of items">
    {{ item.label }} ({{ item.value | fibonacci }})
  </li>
</ul>

Nel codice ho esplicitato il valore di default del parametro pure della Pipe, che è true. Questo significa che, se anche non esplicitamente detto come in questo caso, le Pipe sono trattate come funzioni pure, cioè il risultato della loro elaborazione cambia solo se cambiano i parametri. Una Pipe pura in Angular non viene richiamata se i parametri non cambiano, dato che il risultato non cambierà. Questo ottimizza tantissimo il nostro codice, perchè invocheremo la Pipe solo sui nuovi valori inseriti:

immutable e pipe

Se impostate il parametro pure a false vedrete invece che Angular invocherà ogni volta la Pipe. Anche lasciando la Pipe pura però, non possiamo evitare che il valore venga calcolato per una nuova riga, nonostante i parametri possano coincidere con una riga differente. Se ci pensate il valore n-simo della sequenza di Fibonacci è sempre lo stesso, quindi una volta calcolato potremmo riutilizzarlo in tutti i casi in cui ci viene richiesto lo stesso valore. Angular non ha uno strumento integrato per questa funzionalità, che non è altro che una strategia di caching dei valori. Possiamo però installare una libreria che sa fare questa cosa, con il comando npm i memo-decorator. La Pipe diventa la seguente:

import { Pipe, PipeTransform } from '@angular/core';
import memo from 'memo-decorator';

@Pipe({
  name: 'fibonacci'
})
export class FibonacciPipe implements PipeTransform {

  @memo()
  transform(value: any, ...args: any[]): any {
    console.log('value ' + value + ' generated');
    return this.fibonacci(value * 4);
  }

  private fibonacci(num: number): number {
    if (num === 1 || num === 2) {
      return 1;
    }
    return this.fibonacci(num - 1) + this.fibonacci(num - 2);
  }
}

Aggiungendo il decoratore memo al metodo transform, andremo a salvare il risultato dell'elaborazione a parità di parametri, in modo da saltarne l'esecuzione per valori già precedentemente calcolati:

memo decorator

Spero vi sia utile. Trovate il codice qui:
https://github.com/apomic80/angular-performance-improvements

Happy Coding!

Servizi

Evolvi la tua azienda