
GUIDE PER ASPIRANTI PROGRAMMATORI
Guida Angular in italiano


Vuoi avviare una nuova carriera o fare un upgrade?
Trova il corso Digital & Tech più adatto a te nel nostro catalogo!
- 3.1 - Le direttive in Angular
- 3.2 - I componenti in Angular
- 3.3 - Il template in Angular
- 3.4 - Le direttive strutturali in Angular
- 3.5 - La content projection in Angular
- 3.6 - I servizi in Angular
- 3.7 - Le Pipes in Angular
- 3.8 - Routing in Angular
- 3.9 - Invio di form in Angular
- 3.10 - Built-in control flow in Angular
- 3.11 - Deferrable views in Angular
- 3.12 - Image optimization in Angular
- 3.13 - Standalone components in Angular
1
Introduzione ad Angular
1.1
Cos'è Angular?
Avvicinandoti al mondo dello sviluppo web, e in particolare al campo dei framework front end, ti sarà sicuramente capitato di imbatterti in Angular, un framework che occupa da anni la top 3 di tutte le classifiche dei framework più popolari per costruire web app e in particolare le cosiddette single page applications (SPA).
Abbiamo già introdotto un po' di concetti caldi, quindi vediamo di scandagliarli per bene:
un framework è una piattaforma comprensiva di tutte le implementazioni architetturali e la strumentazione necessaria per avviare e mantenere lo sviluppo di un progetto per una determinata applicazione software;
all'interno di un framework frontend troviamo generalmente tutto il necessario per gestire lo stato dell'applicazione, la comunicazione con uno o più backend, e ovviamente anche la rappresentazione dell'interfaccia grafica (UI) e l'interazione con l'utente;
un'applicazione a pagina singola (in inglese single page application, abbreviato SPA) è una web app che una volta avviata è in grado di gestire internamente tutte le logiche di navigazione delle varie viste senza richiedere una ricarica della pagina; questo risulta in un'esperienza d'uso più fluida e simile al funzionamento di un'applicazione nativa.
Ora che abbiamo messo giù le basi, possiamo procedere con una definizione più rigorosa: che cos’è Angular?
Angular è un framework frontend open source scritto e mantenuto da Google in TypeScript e basato su un'architettura orientata agli oggetti.
Questo significa che con un progetto Angular possiamo scrivere un progetto per una web app dotata di:
supporto per TypeScript nativo;
un'architettura basata su classi con dependency injection nativa e ispirata al modello MVC/MVVM;
un ricchissimo ecosistema di librerie logiche e grafiche;
un altrettanto ricco assortimento di strumentazione di sviluppo, testing e debugging pronta all'uso.
Ma facciamo un piccolo inciso: che cos’ è TypeScript?
Che cos’ è Typescript
TypeScript è a sua volta un linguaggio di programmazione che estende il linguaggio JavaScript, scritto e mantenuto da Microsoft. Per questo Angular è un prodotto mantenuto da due assoluti giganti del software e del web!
Ora sì che abbiamo un po' di carne al fuoco. Nel corso di questa guida affronteremo tutti i concetti introdotti addentrandoci nella teoria e nella pratica di come funziona un progetto Angular e di come realizzarne e mantenerne uno al meglio.
Per cominciare, nel capitolo a seguire, facciamo qualche paragone con altri framework popolari.
1.2
Angular VS AngularJS
È abbastanza comune sentir norminare Angular e AngularJS. Ma qual è la differenza tra i due? Si tratta dello stesso framework? Scopriamolo.
AngularJS è il primo framework scritto nel 2010 da Miško Hevery, creatore poi di Angular e oggi CTO di Builder.io, l'azienda che, tra le altre cose, sviluppa il framework Qwik, ennesima sua innovativa creazione.
Come il nome suggerisce, questo framework è l'antenato di Angular, che ne rappresenta una completa riscrittura in TypeScript. Non a caso, il nome originale della versione riscritta era Angular2, e questo ha inizialmente causato non poca confusione tra i programmatori informatici. Il numero di versione fu rimosso con l'uscita della versione 4.0.0, e da allora facciamo semplicemente riferimento ad Angular.
Il supporto ad AngularJS è stato definitivamente interrotto da Gennaio 2022.
Ma che cos'hanno in comune, e di diverso, queste due versioni di Angular?
Angular VS AngularJS: le differenze.
La differenza più ovvia si evince proprio dal nome: AngularJS era scritto in linguaggio JavaScript puro, mentre Angular è scritto in TypeScript. Questa differenza sostanziale fa sì che Angular sia adatto allo sviluppo di progetti ben più complessi e dotati di tipizzazione statica, una caratteristica che facilita di molto la scalabilità e manutenibilità del codice.
Entrambi i framework implementano un'architettura a componenti di tipo Model-View-Controller/Model-View-ViewModel (MVC/MVVM); questa architettura prevede la suddivisione delle competenze di un'applicazione grafica su tre livelli:
Model: il dato vero e proprio che comporre lo stato logico dell'applicazione; in un form ad esempio, il model è l'oggetto contenente i valori inseriti nel form.
View: rappresentazione visiva dello stato dell'applicazione UI, ma anche l'insieme delle logiche esclusivamente dedicate all'interazione dell'utente, come animazioni e schermate di caricamento.
Controller:gestisce la business logic del frontend quando la View segnala un'interazione dell'utente, aggiornando il Model e generando una nuova View aggiornata.
ViewModel: variante del controller, implementa un canale a doppio senso tra View e Model attraverso il concetto di two-way data binding (associazione di dati a doppia via), che consente di creare una mappatura automatica tra lo stato della vista e quello del model.
In Angular questa architettura è formalizzata in modo più rigoroso e strettamente tipizzato: ogni componente è costituito separatamente da un template, da una classe che ne gestisce le logiche e da uno strato sottostante di classi e servizi che consentono una gestione piuttosto sofisticata dei dati all'interno dell'applicazione.
In conclusione, molti dei concetti introdotti in AngularJS sono mantenuti in Angular, ma la struttura del progetto è stata fortemente modificata e rafforzata con la riscrittura. Ad oggi AngularJS si può considerare un framework obsoleto, da non utilizzare per nuovi progetti.
1.3
Angular, React e Vue.js a confronto
Angular VS React.
A differenza di Angular, React non si può definire un framework front end vero e proprio, ma una libreria di render UI basata su componenti. Infatti tutto ciò che React implementa è un'architettura sufficiente per associare un'alberatura di componenti ad un determinato stato dell'applicazione.
A differenza di Angular, che richiede la creazione di un progetto da zero e la compilazione del progetto per generare il pacchetto dell'applicazione, React può tranquillamente essere introdotto in un progetto preesistente e senza necessariamente richiedere la compilazione di un pacchetto per generare la web app finale.
Tuttavia va detto che, nello sviluppo di applicazioni reali, React è quasi sempre impiegato al fianco di altre librerie e strumenti che gestiscono la navigazione, il testing, l'implementazione dei design pattern più frequenti; per questo è spesso utilizzato come il fondamento per un framework.
Questa caratteristica di React lo rende decisamente più facile da imparare nelle fasi iniziali, ma richiede comunque molta esperienza nelle fasi di definizione dell'architettura generale del progetto e di composizione delle altre funzionalità dell'applicazione, al di là della UI; tutte queste operazioni in Angular sono relativamente già gestite ed implementate in qualunque progetto come parte delle funzionalità nativamente offerte dal framework.
Da un punto di vista architetturale, il meccanismo di React e quello di Angular sono relativamente agli antipodi:
React usa un linguaggio tipo-XML per generare un DOM virtuale, che poi viene applicato sulla pagina web reale aggiornando solo le parti che hanno subito modifiche;
Angular renderizza un normalissimo DOM nativo e poi ci appende le funzionalità definite nei suoi componenti e direttive (più avanti vedremo bene come).
Angular VS Vue.js.
Vue.js è un framework frontend scritto in linguaggio JavaScript e nato come una versione più leggera e semplice di Angular. Anche Vue implementa un'architettura di tipo MVC, ma in una forma più leggera.
A differenza di Angular, Vue è scritto in JavaScript e non implementa una complessa architettura orientata agli oggetti, perciò è decisamente più semplice da imparare, ma anche meno adatto a sviluppare progetti particolarmente complessi e strutturati.
Come in React, i componenti Vue utilizzano un DOM virtuale, una versione alleggerita di un DOM vero e proprio del browser; perciò Vue è generalmente più leggero e performante di Angular.
Come Angular anche Vue è completamente open source, ma interamente mantenuto dalla community; questo implica tutti i pro e contro di uno sviluppo più indipendente da logiche commerciali, ma anche non sostenuto da un'azienda che possa garantire una pianificazione e un supporto come Google può permettersi.
2
Avvio di Angular
2.1
Angular CLI
Come abbiamo detto, Angular è un framework provvisto di un completo assortimento di strumenti per lo sviluppo. Il principale di questi è senz'altro la sua interfaccia da riga di comando (in inglese command line interface, o CLI). La CLI è indispensabile per creare un nuovo progetto Angular ma, come vedremo, può fare molto di più.
Per cominciare, andiamo a installare la CLI come tool globale; per farlo avremo bisogno di Node e NPM installati sulla nostra macchina.
Con Angular 20, la CLI supporta ora Node.js versioni 18.19.0 o successive. È raccomandato usare la LTS più recente.
Per installare la CLI procediamo con il seguente comando da terminale:
npm install -g @angular/cli
Questo comando andrà bene anche in futuro per aggiornare la CLI all'ultima versione, in modo da generare progetti Angular sempre aggiornati!
Il gioco è fatto. La CLI è ora disponibile globalmente tramite il comando ng.
2.2
Come creare un nuovo progetto in Angular
Ora che abbiamo la Angular CLI installata globalmente, possiamo procedere a creare il nostro primo progetto usando il comando ng new:
ng new <app_name>
Dove app_name è il nome della nostra app.
Per cominciare, dovremo rispondere ad alcune domande sul nostro progetto; per lo scopo di questa guida, possiamo utilizzare il linguaggio CSS come linguaggio per gli stili e attivare il server side rendering (ci arriveremo più avanti).
A questo punto la CLI installerà automaticamente i pacchetti npm di Angular e le dipendenze necessarie per avviare il progetto; i file di progetto verranno creati in una nuova cartella omonima.
Ora che abbiamo creato un nuovo progetto, possiamo avviarlo e iniziare lo sviluppo;
anche per questo usiamo la CLI:
cd <app_name>
npm start
oppure
cd <app_name>
ng serve [--open]
serve. Normalmente passiamo da npm per eseguire gli script del progetto, perché i tool di sviluppo di ogni progetto Node sono installati localmente; con Angular, avendo il comando ng disponibile globalmente, è comune scavalcare npm e chiamare direttamente ng serve.
L'opzione --open non fa altro che aprire il browser lanciando la nostra web app su http:\\localhost:4200.
Tutto quello che vediamo al lancio dell'app si trova nel file app.component.html e può essere rimosso per iniziare lo sviluppo.
In questa guida non porteremo avanti un progetto in particolare ma, piuttosto, ci soffermeremo su tutti gli elementi costitutivi del framework con esempi e approfondimenti specifici; con questa premessa, siamo pronti per cominciare con l'elemento architetturale più importante di tutto Angular: le direttive.
3
Concetti principali in Angular
3.1
Le direttive in Angular
Ad un primo impatto, data la natura component based della maggior parte dei framework frontend moderni, potrebbe risultare strana la scelta di affrontare le direttive (e non i componenti) prima di ogni altra cosa.
La ragione di questa scelta è che, come abbiamo detto confrontando Angular con altri framework a componenti come React e Vue, il principio di funzionamento di Angular non consiste nel creare un'alberatura virtuale di componenti (virtual DOM), bensì nell'agganciare funzionalità ai nodi del DOM nativo.
Vediamo, dunque, che cosa sono le direttive nel dettaglio, come funzionano e perché sono così importanti.
Che cos’è un direttiva in Angular?
Iniziamo dando una definizione formale di direttiva: una direttiva (in inglese: directive) Angular è una classe TypeScript che implementa un comportamento su un elemento del DOM. In pratica, le direttive sono il ponte tra gli elementi del DOM e il codice della nostra web app.
Esistono tre tipi di direttive in Angular:
Componenti: il tipo di direttiva più ovvio; in Angular, il componente non è altro che un particolare tipo di direttiva dotata di un template, cioè di una porzione di codice HTML da proiettare nel DOM.
Attributi: si tratta di direttive che vengono applicate attraverso degli attributi sui tag HTML; in genere, quando si parla semplicemente di direttiva, si fa riferimento a questo tipo. Come vedremo a breve, il bello di questo tipo di direttiva è il fatto di essere componibile, semplicemente applicando più attributi a uno stesso tag.
Direttive strutturali: si tratta di direttive che modificano il DOM aggiungendo o rimuovendo porzioni di template; sono usate quasi esclusivamente per implementare sintassi di controllo di flusso (if/else, switch, for) nel template dei componenti, e con le ultime versioni di Angular stanno venendo soppiantate dalle sintassi built-in control flow (controllo di flusso integrato), che vedremo approfonditamente più avanti.
A questo punto, non ci resta che implementare un paio di esempi di direttiva attributo, dato che approfondiremo gli altri due tipi più avanti.
Type numeric: una direttiva attributo personalizzata.
Immaginiamo di avere nel nostro template app.component.html un form con dei campi di input che, pur essendo di tipo text, devono accettare solo valori numerici. Ci sono molti esempi di questa casistica: CAP, codici bancari, qualunque tipo di campo stringa che però non debba accettare altro che numeri.
Se volessimo ragionare in un'ottica di validazione di un form in HTML, useremmo l'attributo nativo pattern="[0-9]"; ma noi vogliamo fare di più: vogliamo inibire l'inserimento di qualunque lettera o simbolo al momento in cui viene premuto il rispettivo tasto.
Se volessimo implementare questo comportamento in linguaggio HTML puro, finiremmo a scrivere una cosa di questo tipo:
<input type="text" onkeypress="return event.key.match('[0-9]') !== null">
L'attributo pattern e il metodo match accettano come argomento una regular expression (espressione regolare). Con questo speciale tipo di dato si possono fare operazioni piuttosto sofisticate sulle stringhe; per lo scopo di questa guida, possiamo limitarci a osservare che [0-9] significa semplicemente "un carattere compreso tra 0 e 9".
Vediamo di capire bene che cosa succede qui: abbiamo un normalissimo input di tipo text con un evento onkeypress in cui ritorniamo il risultato di un'espressione che ci dice se il tasto premuto corrisponde o meno ad una cifra numerica.
Nel caso in cui non ci sia corrispondenza l'espressione risolve a false e in questo modo l'evento viene rigettato, e dunque il carattere digitato a tastiera non viene inserito nel nostro input.
Navighiamo, dunque, nella cartella del nostro progetto Angular appena creato. Nella root abbiamo i file di configurazione del nostro progetto e una cartella src che contiene il codice vero e proprio della nostra app.
Ora creiamo una direttiva con questo comando:
ng generate directive forms/type-numeric
La CLI creerà per noi una classe TypeNumericDirective opportunamente decorata dal decoratore Directive con un selettore appTypeNumeric che potremo andare a customizzare.
Abbiamo anche sfruttato la CLI per collocare direttamente la direttiva nella cartella src/forms, che è stata creata automaticamente.
A questo punto andiamo a implementare il comportamento desiderato:
import { Directive, ElementRef, HostListener } from '@angular/core';
@Directive({
selector: 'input[type="text"][appTypeNumeric]',
standalone: true
})
export class TypeNumericDirective {
@HostListener('keypress', ['$event'])
rejectNonNumeric(event: KeyboardEvent): void {
if (event.key.match('[0-9]') === null) event.preventDefault();
};
}
Vediamo di capire bene che cosa sta succedendo qui:
Abbiamo modificato il selettore della direttiva in modo che questa si applichi solo agli input con type="text"; in questo modo non dovremo preoccuparci di gestire il caso in cui a qualcuno (ad esempio ad un altro developer, o più probabilmente a noi, qualche tempo dopo) venga in mente di applicare l'attributo appTypeNumeric sull'elemento sbagliato.
Abbiamo scritto un metodo rejectNonNumeric che blocca l'effetto dell'evento keypress secondo lo stesso criterio di prima; in questo caso, il blocco non avviene restituendo false in caso di mismatch, ma invece bloccando proceduralmente gli effetti dell'evento. Il motivo per questa diversa implementazione è che, per gli eventi registrati tramite addEventListener, restituire false non ha alcun effetto.
Per agganciare il metodo rejectNonNumeric, lo abbiamo decorato con HostListener, che si occupa appunto di chiamare addEventListener e removeEventListener per noi automaticamente.
A questo punto non ci resta che applicare la nostra direttiva; in app.component.ts scriviamo:
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { TypeNumericDirective } from './forms/type-numeric.directive';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterOutlet, RouterLink, TypeNumericDirective], // <- import new directive
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'examples';
}
Ora, in app.component.html scriviamo:
<input type="text" onkeypress="return event.key.match('[0-9]') !== null">
<input type="text" appTypeNumeric>
Ora possiamo confrontare il funzionamento dei due input e verificare che è lo stesso, come ci aspettavamo.
Prima di procedere con un altro esempio, una nota sui decoratori: i decoratori sono speciali funzioni che possono essere applicate (usando il prefisso @) su classi, proprietà o metodi per annotare dei metadati oppure per associarli a specifiche logiche di comportamento gestite dal framework.
Questo è generalmente il modo in cui Angular mette in collegamento la sua gestione delle API native di JavaScript (come appunto gli event handlers) e la sua architettura orientata agli oggetti.
Nel corso di questa guida vedremo diversi decoratori messi a disposizione dal framework, e il loro funzionamento.
Highlight error: integriamo un altro esempio di direttiva attributo.
Abbiamo detto che le direttive sono componibili; perché, allora, non creare una seconda direttiva che "collabori" con la nostra TypeNumericDirective per migliorarne l'esperienza?
Andiamo a implementare una nuova direttiva che alla pressione di un carattere non numerico modifichi lo stile dell'elemento per segnalare l'errore. Per fare questo useremo di nuovo il decoratore HostListener, e gli affiancheremo un altro decoratore molto utilizzato nelle direttive: HostBinding.
Creiamo una nuova direttiva con la CLI:
ng generate directive forms/highlight-invalid
Abbiamo una nuova HighlightInvalidDirective!
Andiamo a implementarla:
import { Directive, HostBinding, HostListener } from '@angular/core';
@Directive({
selector: 'input[type="text"][appTypeNumeric][appHighlightInvalid]',
standalone: true
})
export class HighlightInvalidDirective {
@HostBinding('style.color') color: 'red' | 'unset' = 'unset';
@HostListener('keypress', ['$event'])
onKeyPress(event: KeyboardEvent) {
this.color = event.key.match('[0-9]') === null
? 'red'
: 'unset';
}
}
Proviamo ad analizzare questo codice:
la nuova direttiva è applicabile solo a elementi che già applicano la direttiva TypeNumeric;
abbiamo un listener sull'evento keypress che assegna una property color che può assumere due valori: red o unset;
ogni volta che viene premuto un carattere non numerico, il colore viene cambiato a rosso se il carattere premuto non è valito, altrimenti viene semplicemente disimpostato;
il nostro color è decorato con HostBinding, che accetta un parametro stringa dove possiamo indicare a quale attributo del nostro elemento vogliamo collegare il valore della nostra property; in questo caso, quando color è red, il nostro input avrà l'attributo style impostato come color: red.
A questo punto non ci resta che includere la nostra nuova direttiva nel nostro template:
<input type="text" appTypeNumeric appHighlightInvalid>
Per farla funzionare, dobbiamo ricordarci di importarla in app.component.ts:
import { Component } from '@angular/core';
import { RouterLink, RouterOutlet } from '@angular/router';
import { TypeNumericDirective } from './forms/type-numeric.directive';
import { HighlightInvalidDirective } from './forms/highlight-invalid.directive';
@Component({
selector: 'app-root',
standalone: true,
imports: [
RouterOutlet,
RouterLink,
TypeNumericDirective,
HighlightInvalidDirective // <- new import
],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
title = 'examples';
}
Ora che ci siamo fatti un'idea di che cos'è e come si implementa una direttiva, andiamo finalmente a vedere il tipo di direttiva con cui avremo più spesso a che fare: i componenti.
3.2
I componenti in Angular
Come già detto, i componenti sono il tipo più comune di direttiva che useremo per costruire applicazioni Angular; possiamo definire sinteticamente un componente come una direttiva dotata di un suo tag e di un template HTML.
Come una normale direttiva, anche un componente viene legato al DOM tramite un selettore CSS; nel caso dei componenti il selettore è solitamente un selettore di tag anziché di attributo, per esempio:
@Component({
selector: 'app-component', // <- tag selector
// ...
})
export class AppComponent {
// ...
}
Con questo selettore, ogni volta che in un template Angular troverà il tag <app-component /> inizializzerà il componente AppComponent e lo aggancerà al rispettivo nodo del DOM, proiettando il suo template come contenuto del tag.
A differenza di altri framework frontend come React, Angular genera davvero un custom tag con il nome del selettore, e non si limita a sostituirlo con il suo template.
Questo significa che esplorando il DOM nel browser potremo riscontrare tag per tag tutti i componenti che compongono la nostra applicazione!
Come abbiamo anticipato, il contenuto grafico del nostro componente è rappresentato dal suo template HTML. In Angular un template non è altro che un semplice file HTML, ma oltre alla sintassi standard presenta convenzioni per richiamare le direttive o passare valori dinamici, e nelle ultime versioni anche per fare controllo di flusso.
Abbiamo già incontrato un esempio di template HTML in app.component.html, generato automaticamente da ng new e modificato con il nostro esempio sulle direttive; più avanti vedremo dettagliatamente come funziona la template syntax e cosa ci permette di realizzare.
Accanto a selettore e template, la parte attiva di comportamento di un componente è implementata in una classe TypeScript, esattamente come vale per quasi tutti gli elementi di Angular; in questa classe definiremo dipendenze, proprietà e metodi, e collegheremo logicamente queste definizioni alle diverse parti del template attraverso due meccanismi complementari: il data binding (associazione di dati) e l'event binding (associazione di eventi).
Ovviamente approfondiremo tutto a tempo debito, ma torniamo con le mani nel codice per vedere un paio di esempi: abbiamo detto che un componente gestisce una porzione di UI con un comportamento, perciò implementiamo un componente esemplare: un contatore.
Esempio di componente in Angular: il counter.
Iniziamo invocando la CLI:
ng generate component counter
Notiamo subito che la CLI crea automaticamente i componenti in una loro cartella, in modo da poter tenere vicini codice, template e stili, senza mischiarli con quelli di altri componenti.
In genere la CLI creerà per noi anche dei file *.spec.ts usati per fare unit testing; nel corso di questa guida non considereremo questi file.
Andiamo a implementare quello che ci serve lato TypeScript, in counter.component.ts:
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
standalone: true,
imports: [],
templateUrl: './counter.component.html',
styleUrl: './counter.component.css'
})
export class CounterComponent {
count: number = 0;
increment(): void { this.count++ }
reset(): void { this.count = 0 }
}
In questa semplice classe abbiamo raccolto lo stato del contatore e le due operazioni che potranno essere effettuate sullo stato: incremento e azzeramento.
Vediamo come tutto questo si riflette nel template in counter.component.html:
<h1>Counter</h1>
<code>{{ count }}</code>
<div>
<button (click)="increment()">Increment</button>
<button (click)="reset()">Reset</button>
</div>
Notiamo subito la sintassi di interpolazione {{ }}, che ci permette di inserire espressioni TypeScript nel nostro codice HTML; in questo caso l'abbiamo fatto per rappresentare il conto del nostro contatore.
L'altra cosa che non siamo abituati a vedere in HTML puro è l'event binding fatto con l'attributo (click); apparentemente questo funziona come l'attributo nativo onclick, ma in realtà si comporta in manierà più sofisticata, agganciando e sganciando dinamicamente l'event handler in base al ciclo di vita del componente, tramite l'API add/removeEventListener.
A questo punto procediamo a richiamare effettivamente il nostro contatore nel template del nostro app.component.html, ma prima importiamolo in app.component.ts:
// ...imports
@Component({
selector: 'app-root',
standalone: true,
imports: [
// ...
CounterComponent
],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
// ...
}
<app-counter />
Poiché il nostro CounterComponent non riceve dati dal suo componente padre e non ha nodi figli, l'unica cosa che abbiamo dovuto fare per rappresentarlo è inserire il suo tag in forma self closing, che equivale a scrivere <app-counter></app-counter>.
Prima di vedere i template nel dettaglio, rimaniamo un attimo sul nostro contatore per capire come gli stili del linguaggio CSS vengono gestiti da Angular.
Tanto per cominciare, poiché il nodo del template dedicato al componente app-component rappresenta un tag vero e proprio, possiamo stilizzarlo in app.component.css con il suo selettore:
app-counter {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid gray;
border-radius: 10px;
padding: 20px;
}
Il fatto di poter stilizzare il tag di un componente è già di per sé molto interessante, e possiamo sempre farlo quando vogliamo usare il CSS per regole di collocamento spaziale di un componente, senza doverlo wrappare in un div o simili; tuttavia, delegare lo stile interno di un componente al suo componente padre non è il massimo in termini di separazione delle competenze, perciò possiamo spostare tutte queste regole in counter.component.css, e applicarle al componente usando lo pseudoselettore :host al posto del tag app-counter.
E, già che ci siamo, oltre a questo vediamo di stilizzare un po' il resto del template:
:host {
display: flex;
flex-direction: column;
gap: 10px;
border: 1px solid gray;
border-radius: 10px;
padding: 20px;
}
h2 {
margin: 0;
}
div {
display: flex;
gap: 10px;
}
Qui possiamo notare un'altra cosa interessante: non ci siamo preoccupati di introdurre alcun tipo di specificità sui selettori (classi, id, parentela tra gli elementi), e il motivo non è dovuto alla semplicità di questo particolare esempio: il fatto è che non servirebbe.
Se, ad esempio, tornassimo in app.component.html e lo modificassimo come segue:
<app-counter />
<h2>h2</h2>
<div>div</div>
noteremmo che né h2 né div (al di fuori di quelli dentro app-counter) sono stati impattati dal CSS che abbiamo scritto in counter.component.css. Questo perché Angular di default incapsula automaticamente tutti gli stili dei componenti, perciò qualsiasi regola scriviamo in un file *.component.css, questa non avrà effetto al di fuori del componente in questione.
Questo meccanismo di isolamento può essere modificato a livello di componente:
@Component({
selector: 'app-counter',
standalone: true,
imports: [],
templateUrl: './counter.component.html',
styleUrl: './counter.component.css',
encapsulation: ViewEncapsulation.None // <- no view encapsulation
})
In ogni caso, tutti i componenti hanno accesso agli stili globalmente definiti in src/styles.css.
È arrivato il momento di procedere, approfondendo i template.
3.3
Il template in Angular
A questo punto dovremmo aver acclarato che un template è un blocco di codice HTML associato ad un componente, che ne rappresenta la parte grafica.
Abbiamo già visto che la CLI genera per noi ogni componente in forma di più files associati; il template è contenuto nel file *.component.html, linkato al codice TypeScript tramite la proprietà templateUrl:
@Component({
selector: 'app-component',
templateUrl: './app.component.html',
})
Nei casi più semplici possiamo inserire il template di un componente direttamente nel suo file *.component.ts, rimuovendo la proprietà templateUrl e usando invece template:
@Component({
selector: 'app-component',
template: '<h1>Hello World!</h1>',
})
La template syntax è una combinazione di codice HTML classico e un set di sintassi specifiche con cui Angular applica diversi concetti del framework; praticamente tutti i tag HTML sono supportati, fanno eccezione quei tag che normalmente rappresentano una pagina HTML intera e non una sua sezione, come <html> <body> e <base>; per ragioni di sicurezza, anche il tag <script> viene ignorato da Angular se presente.
L’ interpolazione in Angular.
L'interpolazione, che abbiamo già visto, è la sintassi più semplice in assoluto: consiste nel rappresentare un'espressione JavaScript tra doppie parentesi graffe; normalmente questa sintassi viene utilizzata per effettuare dei semplici data binding tra proprietà della classe componente e parti del template, ma può essere utilizzata anche per rappresentare valori statici o calcoli.
Vediamo qualche esempio valido:
{{ 'Hello world!' }}
{{ 'Hello world!'.replace('!', '?') }}
{{ 2 * 1000 }}
{{ 2 }}
{{ count }}
{{ counte.toFixed(2) }}
Poiché tutte le variabili accessibili dal template provengono alla classe componente, la parola chiave this è facoltativa.
Ovviamente l'interpolazione si usa principalmente per mescolare testo statico e porzioni dinamiche che devono essere popolate con i dati del componente:
@Component({
selector: 'app-component',
template: '<h1>Name: {{firstName}}; Surname: {{lastName}}</h1>',
})
export class AppComponent {
firstName = 'Mario';
lastName = 'Rossi';
}
Data binding.
Un altro caso d'uso per l'interpolazione è quello di passare valori dinamici agli attributi dei tag presenti nel template:
@Component({
// ...
template: '<input value="{{value}}">'
})
export class AppComponent {
value = 42;
}
È importante notare che in questo modo possiamo interpolare solo testo, perciò non ci consente di passare riferimenti a oggetti o array; per fare questo possiamo utilizzare la sintassi di data binding vera e propria:
<input [value]="value">
In questo caso, nel momento in cui mettiamo l'attributo value tra parentesi quadre, ammettiamo come valore qualunque dato proveniente dalla component class. Questa sintassi è particolarmente utile se combinata con l'utilizzo di input property sui componenti figli.
Prendiamo l'esempio di un componente <app-profile> che rappresenti il profilo di un utente; ecco il suo profile.component.html:
<p>Profile of {{user.name}}</p>
<p>Age: {{user.age}}</p>
<img [src]="user.imageUrl" alt="profile pic">
Questo componente ha una proprietà user che contiene un oggetto con diverse proprietà, rappresentate nel template; vediamo come un componente padre può usare il componente <app-profile> per rappresentare un oggetto come user.
Innanzitutto guardiamo il codice della classe componente profile.component.ts:
import { Component, Input } from '@angular/core';
export interface User {
name: string;
age: number;
imageUrl: string;
}
@Component({
selector: 'app-profile',
standalone: true,
imports: [],
templateUrl: './profile.component.html',
styleUrl: './profile.component.css'
})
export class ProfileComponent {
@Input({ required: true }) user!: User;
}
Qui abbiamo definito l'interfaccia User, e l'abbiamo usata per tipizzare la proprietà user.
Guardando questa proprietà, notiamo altre due cose:
la prima è che essa è decorata con @Input() e che è required; questo significa che qualunque componente padre sarà costretto a passare un utente da mostrare al componente profile;
la seconda è che abbiamo applicato una non-null assertion sulla proprietà usando !, dal momento che sappiamo con certezza che questo componente verrà sempre renderizzato con un data binding del tipo [user]="...", e dunque non sarà mai undefined.
Ora vediamo il suo componente padre, app.component.ts:
import { Component } from '@angular/core';
import { ProfileComponent, User } from './profile/profile.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [ProfileComponent],
template: '<app-profile [user]="user" />'
})
export class AppComponent {
user: User = { name: 'John Doe', age: 30, imageUrl: '/some/image.jpg' };
}
Questo componente importa ProfileComponent e l'interfaccia User, dopodiché fa esattamente quello che ci aspetteremmo: passa il valore di una sua proprietà di tipo User al componente figlio usando un data binding sull'attributo [user].
Per il momento il meccanismo di funzionamento dei data binding dovrebbe essere chiaro; più avanti approfondiremo le input property e il rispettivo decoratore in maniera più esaustiva.
Event binding.
Abbiamo visto che il data binding ci permette di trasmettere dati giù per l'alberatura dei componenti; ma se volessimo far risalire dei dati?
Tendenzialmente il flusso di dati all'interno di un albero di componenti è unidirezionale, dall'alto verso il basso; la comunicazione a ritroso non si fa dunque passando dati, ma scatenando eventi. Vediamo, dunque, un esempio molto semplice di event binding.
Nel nostro app.component.ts vogliamo rappresentare un input e raccogliere in una proprietà del componente il valore aggiornato:
import { Component } from '@angular/core';
@Component({
selector: 'app-root',
standalone: true,
imports: [],
template: '<input #el (input)="onInput(el.value)" /> <p>value: {{value}}</p>'
})
export class AppComponent {
value: string | undefined = undefined;
onInput(value: string) { this.value = value }
}
Vediamo bene cosa succede qui:
il template rappresenta un input, si lega al suo evento input e vi aggancia come event listener il metodo onInput;
onInput riceve come parametro un valore stringa, che usa per aggiornare la proprietà value;
sempre nel template, un paragrafo ci rappresenta il valore della proprietà value, e il cerchio si chiude.
Similarmente ai data binding, gli event binding possono essere usati tra componenti usando le output properties e il decoratore @Output(); il meccanismo, tuttavia, è un po' più complicato, perciò non lo approfondiamo immediatamente: lo faremo più avanti in un capitolo dedicato.
Intanto, riguardando l'esempio appena fatto, possiamo chiederci: ma che cos'è quello strano attributo #el nell'input?
Ebbene, quella è una template variable, cioè una variabile che rappresenta il riferimento all'elemento input e può essere utilizzata in tutto il resto del template; per esempio, qui l'abbiamo usata per passare il valore a onInput.
Template reference.
In Angular è possibile identificare con una template variable (cioè un attributo che inizia con #) tre tipi di riferimento, in base al tipo di tag su cui applichiamo la variabile; i primi due tipi li possiamo facilmente immaginare:
se la variabile è applicata su un tag HTML nativo, farà riferimento all'elemento stesso;
se questa viene applicata sul tag di un componente farà riferimento all'istanza della classe componente.
Quanto al terzo tipo, per spiegarlo dobbiamo introdurre un tag speciale di Angular: ng-template.
Questo tag viene utilizzato esclusivamente per rappresentare il suo contenuto, e non viene renderizzato sul posto ma invece conservato per essere mostrato a comando.
Se applichiamo una template variable a un tag ng-template, questa avrà il tipo TemplateRef.
Abbiamo così introdotto l'ultimo concetto che affronteremo riguardo ai template HTML in Angular, e cioè i riferimenti a template (template reference); questo concetto diventa cruciale per implementare il terzo tipo di direttiva, che non abbiamo ancora approfondito: le direttive strutturali.
3.4
Le direttive strutturali in Angular
Abbiamo detto che una direttiva strutturale è una direttiva che modifica il DOM aggiungendo o rimuovendo porzioni di template; con i concetti introdotti fin qui, possiamo iniziare ad intravedere anche come questo avviene.
Poiché implementare questo tipo di direttiva è particolarmente complesso e decisamente poco comune, ci limiteremo a studiare il comportamento delle direttive strutturali già presenti in Angular.
La direttiva strutturale Angular ngTemplateOutlet.
La direttiva strutturale ngTemplateOutlet consente di renderizzare un template nel punto desiderato, tramite la sua template variable. Questa direttiva è presente in CommonModule, un modulo Angular che dovremo importare in app.component.ts:
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [CommonModule],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent { }
A questo punto possiamo usarla:
<div [style]="{ border: '1px solid black' }">
<ng-container *ngTemplateOutlet="ref" />
</div>
<hr />
<div [style]="{ border: '1px solid red' }">
<ng-template #ref>
<p>Hello</p>
</ng-template>
</div>
In questo esempio abbiamo due div, uno con bordo nero e uno con bordo rosso.
Dentro il div con bordo rosso è dichiarato un template di nome ref. Questo template viene renderizzato nel div con bordo nero grazie a ngTemplateOutlet.
Per stilizzare i div abbiamo usato un data binding passando le regole rappresentate come oggetto anziché popolando l'attributo in modo HTML standard. Questa è una semplicissima comodità con cui Angular ci permette di controllare dinamicamente gli stili inline di un elemento.
Prima di vedere le altre direttive strutturali più comuni, osserviamo che ngTemplateOutlet non è applicata su un tag comune, ma su ng-container; questo è un tag speciale di Angular che consente di applicare una direttiva strutturale senza generare un rispettivo nodo sul DOM; il suo uso è facoltativo.
La direttiva strutturale Angular ngIf.
Attenzione : la seguente direttiva strutturale *ngIf é ora DEPRECATA in Angular 20. Viene mantenuta per compatibilità ma Angular raccomanda fortemente l'uso della nuova sintassi di controllo del flusso @if introdotta dalla versione 17.
La direttiva ngIf ci permette di condizionare ad un'espressione una certa porzione di template:
<p *ngIf="false">Hello</p>
<p *ngIf="true">World</p>
Solitamente questa direttiva si applica direttamente sul tag coinvolto, ma è comunque possibile usare ng-container qualora la porzione di template condizionale includa più di un nodo del DOM. È anche possibile prevedere un else sfruttando le template variables.
<p *ngIf="loading; else data">Loading...</p>
<ng-template #data>{{ text }}</ng-template>
Come vedremo più avanti, questa direttiva strutturale è stata sostituita da una nuova sintassi introdotta in Angular, perciò raramente ci capiterà di usarla su progetti nuovi.
La direttiva strutturale Angular ngSwitch.
Attenzione : la seguente direttiva strutturale *ngSwitch é ora DEPRECATA in Angular 20. Viene mantenuta per compatibilità ma Angular raccomanda fortemente l'uso della nuova sintassi di controllo del flusso @switch introdotta dalla versione 17.
Similarmente a ngIf, ngSwitch ci permette di rappresentare condizionalmente porzioni di template a seconda di diverse casistiche:
<ng-container [ngSwitch]="expression">
<p *ngSwitchCase="1">Case 1</p>
<p *ngSwitchCase="2">Case 2</p>
<p *ngSwitchDefault>Case 3</p>
</ng-container>
Questa direttiva è particolare, nel senso che non è una direttiva strutturale di per sé, ma lavora insieme a ngSwitchCase e ngSwitchDefault per ottenere il funzionamento che tutti ci aspettiamo.
Come ngIf, anche questa direttiva strutturale è oggi sostituita dalla nuova sintassi.
La direttiva strutturale Angular ngFor.
Attenzione : le seguente direttiva strutturale *ngFor é ora DEPRECATA in Angular 20. Viene mantenuta per compatibilità ma Angular raccomanda fortemente l'uso della nuova sintassi di controllo del flusso @for introdotta dalla versione 17.
L'ultima direttiva strutturale che vedremo, anch'essa ormai sostituita, è ngFor. Questa direttiva ci permette di ripetere un template più volte per ogni elemento di un array:
<p *ngFor="let i of [1, 2, 3]">{{i}}</p>
Il funzionamento è abbastanza auto-esplicativo; da questo esempio si vede come tramite le direttive strutturali sia possibile non solo condizionare il rendering di alcune porzioni di template, ma anche introdurre sintassi ad hoc e creare nuove variabili che possono essere usate all'interno del template.
Abbiamo visto come le direttive strutturali *ngIf, *ngSwitch e *ngFor – da sempre centrali in Angular – siano state deprecate a partire da Angular 20, in favore della nuova sintassi di controllo del flusso introdotta nella versione 17.
L’unica direttiva strutturale non deprecata è *ngTemplateOutlet, ancora pienamente supportata.
Nei prossimi capitoli vedremo come usare la nuova sintassi, più chiara e moderna.
3.5
La content projection in Angular
Abbiamo, finora, visto come in un template sia possibile passare dati e registrare eventi e, dunque, operare a doppio senso nell'alberatura dei componenti. Esiste ancora un utilizzo dei componenti che non abbiamo visto: applicare un contorno, una cornice a una porzione di alberatura che sta sotto di loro, senza però occuparsene direttamente.
Immaginiamo un componente <app-border> che applichi un bordo al suo contenuto, e però si limiti a proiettare all'interno di questo bordo qualsiasi cosa sia rappresentata come figlia del suo tag, dal componente padre:
<app-border>
<p>Hello world!</p>
</app-border>
Con le sintassi viste fino ad ora, non sarebbe possibile implementare un simile componente. La capacità di un componente di rappresentare il contenuto del suo tag tramite il template del suo componente padre, è chiamata content projection (proiezione del contenuto) e in Angular si fa con il tag ng-content (da non confondere con ng-container).
Creiamo dunque il componente app-border:
ng generate component border
Eliminiamo i file border.component.html e border.component.css e implementiamo direttamente border.component.ts:
import { Component } from '@angular/core';
@Component({
selector: 'app-border',
standalone: true,
imports: [],
template: '<ng-content />',
styles: ':host { display: block; border: 1px solid blue; padding: 8px; border-radius: 5px; }'
})
export class BorderComponent { }
Qui abbiamo semplicemente stilizzato il componente usando lo pseudo-selettore :host e come template ci siamo limitati a usare <ng-content />, che rappresenta il contenuto del tag app-border, qualunque esso sia.
Possiamo sfruttare ancora meglio la content projection applicando l'attributo select su ng-content.
Se, ad esempio, oltre al bordo volessimo predisporre lo spazio per un titolo in un template più elaborato, potremmo modificare il template così:
<ng-content select="[title]" /><hr /><ng-content />
A questo punto, in app.component.html, qualunque elemento dentro al tag di app-border corrispondente al selettore [title] verrà mostrato prima del separatore:
<app-border>
<h2 title>Greetings!</h2>
<p>Hello world!</p>
</app-border>
Tutto ciò che invece non risponde al selettore, viene semplicemente proiettato al posto del secondo ng-content.
3.6
I servizi in Angular
Fino a questo momento, in tutti gli esempi che abbiamo fatto, le funzionalità erano contenute completamente nella porzione di codice esemplificata.
In casi più concreti, la complessità generale del progetto richiede che molte funzionalità siano trasversali a più componenti, oppure che ci siano dei contesti di dati o funzionalità accessibili più o meno globalmente, senza dover esplicitamente trasmettere tutto tramite i binding, che rendono più rigidi e verbosi i template.
Tipicamente nelle architetture OOP si incapsulano dati e funzioni trasversali all'interno di classi servizio, e le istanze di queste classi servizio sono condivise secondo determinate logiche, tramite un sistema di iniezione delle dipendenze (dependency injection, o DI).
Angular fornisce nativamente un sistema di DI con il quale una classe, sia essa un componente, una direttiva o altro, può richiedere un'istanza di un certo servizio.
Andiamo, dunque, subito a vedere come implementare e utilizzare una service class.
Riprendendo l'esempio del contatore, vediamo come possiamo utilizzare dei servizi per rendere più flessibile e potente questa funzionalità.
Counter service in Angular
Per prima cosa, vogliamo spostare le logiche del nostro contatore, dalla classe componente alla classe servizio:
ng generate service counter/counter
In questo modo il nostro counter.service.ts viene creato direttamente nella cartella del counter component. Spostando il codice, il nostro counter.service.ts diventa:
import { Injectable } from '@angular/core';
@Injectable({ providedIn: 'root' })
export class CounterService {
count = 0;
increment() { this.count++ }
reset() { this.count = 0 }
}
Fin qui, niente di sorprendente; il decoratore @Injectable farà in modo che CounterService sia disponibile a chi ne chieda un'istanza; la cosa bella è che se CounterService dovesse avere a sua volta degli injectable da cui dipende, questi verrebbero tutti forniti in automatico.
Vediamo come cambia il codice del nostro counter.component.ts:
import { Component } from '@angular/core';
import { CounterService } from './counter.service';
@Component({
selector: 'app-counter',
standalone: true,
imports: [],
templateUrl: './counter.component.html',
styleUrl: './counter.component.css'
})
export class CounterComponent {
constructor(protected counter: CounterService) { }
}
La nostra classe componente ora non fa altro che richiedere come argomento al costruttore un'istanza di CounterService. Angular prenderà nota di questa dipendenza e farà in modo che sia soddisfatta. L'accessibilità di counter è protected in modo tale che sia accessibile dal template (anche public andrebbe bene, ma non private).
Vediamo counter.component.html:
<h2>Counter</h2>
<code>{{ counter.count }}</code>
<div>
<button (click)="counter.increment()">Increment</button>
<button (click)="counter.reset()">Reset</button>
</div>
Il template è praticamente invariato, salvo il fatto che anziché recuperare proprietà e metodi dal componente, lo fa dall'istanza del servizio che il componente conserva nella proprietà counter.
Apparentemente la nostra applicazione è diventata più complessa, senza però cambiare di un millimetro. Mettiamo, però, caso che, in un altro punto dell'applicazione, sia necessario mostrare lo stato corrente del contatore; se avessimo voluto farlo con il codice precedente, avremmo dovuto usare una template variable per ottenere nel template app.component.html una reference al componente counter, e poi trasmettere il valore della sua property count giù per l'alberatura dei componenti fino al punto desiderato.
Per come stanno le cose ora, invece, è sufficiente richiedere un'istanza del servizio.
Vediamolo in un componente di esempio, other.component.ts:
import { Component } from '@angular/core';
import { CounterService } from './counter/counter.service';
@Component({
selector: 'app-other',
standalone: true,
imports: [],
template: '<p>{{ counter.count }}</p>',
})
export class OtherComponent {
constructor(protected counter: CounterService) { }
}
Qui non c'è alcun riferimento al CounterComponent, né ci sono proprietà da cui il componente riceva il valore aggiornato: è sufficiente il servizio.
In app.component.html:
<app-counter />
<app-other />
È importante notare che in casi più concreti, il componente contatore che presenta e aggiorna lo stato dell'applicazione, non sarebbe verosimilmente vicino a tutti gli altri punti dell'app in cui lo stato dev'essere disponibile. Avere un'architettura che consente di disaccoppiare dati e funzioni rispetto alla loro collocazione nell'albero dei componenti offre un livello di flessibilità irraggiungibile senza servizi. In altri framework, risultati simili vengono ottenuti con diverse strategie, ad esempio i context in React.
La service class HTTP Client in Angular.
Una service class importante che Angular mette a disposizione è HttpClient; com'è facile immaginare, questo servizio ci consente di fare chiamate HTTP.
L'idea dietro questa classe è quella di racchiudere le API native per le comunicazioni HTTP (es. fetch) dietro a un servizio completamente integrato in Angular in modo da potervi aggiungere altre funzionalità attraverso il framework.
Usando HttpClient sarà possibile ad esempio simulare le risposte HTTP nei nostri test, oppure intercettare le richieste HTTP della nostra web app per modificarle; il caso di esempio più semplice è quello in cui tutte le richieste siano intercettate per aggiungere un token di autenticazione per l'utente.
In questa guida non affronteremo le pratiche di autenticazione, ma al di là di queste, l'uso di HttpClient nelle applicazioni Angular rappresenta la norma, anche perché semplifica parecchio le cose.
Un aspetto da considerare nell'uso di HttpClient, è che tutti i suoi metodi implementano risposte asincrone attraverso l'uso del tipo Observable, al posto del nativo Promise. Questi due tipi sono relativamente simili, ma gli Observable appartengono a RxJS, una sofisticata libreria di cui Angular fa uso per gestire tutto ciò che è basato su eventi, e dunque anche le risposte asincrone.
L'uso di Observable al posto di Promise consente ad Angular di introdurre alcune ottimizzazioni, ad esempio l'annullamento automatico delle richieste qualora il componente che le abbia innescate venga disattivato, ad esempio perché l'applicazione ha navigato su un'altra vista.
La documentazione ufficiale di Angular suggerisce di utilizzare HttpClient dietro a servizi dedicati e, quindi, non direttamente nei componenti; per gli esempi che andremo a fare, trasgrediremmo questa raccomandazione.
Per cominciare, configuriamo l'app in modo da fornire HttpClient via DI; in app.config.ts:
import { ApplicationConfig } from '@angular/core';
import { provideRouter } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes),
provideClientHydration(),
provideHttpClient() // <- enable http client
]
};
Ora, iniettiamo HttpClient nel nostro app.component.ts, e facciamogli effettuare una richiesta GET:
import { Component, OnInit, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'app-root',
standalone: true,
imports: [],
template: '<pre>{{data}}</pre>'
})
export class AppComponent implements OnInit {
http = inject(HttpClient);
data = '';
ngOnInit(): void {
this.http.get('https://catfact.ninja/fact')
.subscribe(data => { this.data = JSON.stringify(data, null, 2) });
}
}
Qui vediamo come il risultato di this.http.get() sia accessibile passando una callback al metodo subscribe, esattamente come avviene con Promise.then. Ci sono molte differenze tra Observable.subscribe e Promise.then, e alcune le vedremo più avanti, ma in questo specifico esempio i due metodi svolgono lo stesso ruolo.
Notiamo anche che HttpClient non è stato iniettato tramite il costruttore di AppComponent, ma usando la funzione inject; i due metodi sono sostanzialmente equivalenti, inject non è altro che una utility function messa a disposizione dal framework per risparmiarci di dichiarare un costruttore vuoto.
L'altra novità di questo esempio è il metodo ngOnInit che implementa l'interfaccia OnInit; questo metodo è un cosiddetto lifecycle hook, letteralmente un gancio al ciclo di vita del componente.
Gli hook più utilizzati sono OnInit e OnDestroy, ma ce ne sono altri, specifici per diverse casistiche. OnInit viene chiamato immediatamente dopo il costruttore del componente e a differenza di quest'ultimo, è particolarmente indicato per logiche più complesse o asincrone.
Ovviamente, poiché Observable non è un tipo nativo, non è possibile sfruttare async/await direttamente; una buona notizia è che gli Observable restituiti dai metodi di HttpClient possono essere facilmente convertiti in Promise; un'altra buona notizia è che nel 90% dei casi, questa conversione non sarà necessaria, grazie all'utility AsyncPipe. Vediamo immediatamente come fare queste due cose, salvo poi approfondirle in sezioni dedicate, sulle pipes e sugli Observables.
Ecco il codice dell'esempio riscritto in modo da usare async/await con la conversione a Promise:
import { Component, OnInit, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { firstValueFrom } from 'rxjs';
@Component({
selector: 'app-root',
standalone: true,
imports: [],
template: '<pre>{{data}}</pre>'
})
export class AppComponent implements OnInit {
http = inject(HttpClient);
data: string | null = null;
async ngOnInit(): Promise<void> {
const data = await firstValueFrom(this.http.get('https://catfact.ninja/fact'))
this.data = JSON.stringify(data, null, 2);
}
}
Qui abbiamo per l'appunto usato ngOnInit come metodo asincrono; il risultato di this.http.get() è stato convertito in Promise da firstValueFrom (più avanti vedremo perché questa funzione si chiama così).
Vediamo, ora, il secondo esempio:
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs';
import { AsyncPipe } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [AsyncPipe],
template: '<pre>{{data | async}}</pre>'
})
export class AppComponent {
http = inject(HttpClient);
data = this.http.get('https://catfact.ninja/fact')
.pipe(map(data => JSON.stringify(data, null, 2)))
}
Ora il nostro componente ha un aspetto abbastanza diverso:
OnInit è sparita, e data viene inizializzata direttamente usando this.http.get();
dopo il metodo http.get è stato appeso un altro metodo pipe(map(...));
nel template, non mostriamo più solo data, ma data | async, e abbiamo anche la AsyncPipe tra gli imports del componente
Vediamo di capirci qualcosa. Quello che abbiamo fatto essenzialmente, è cambiare il tipo della property data, da string a Observable<string>, cioè il tipo restituito da http.get(...).pipe(map(...)): qui, l'uso di pipe e map corrisponde ancora a Promise.then, non nel caso in cui venga usato per estrarre un valore, bensì per mutare il contenuto della promise restituendo un'altra promise.
In pratica, this.data è un'observable che nasce da una chiamata HTTP e poi viene trasformato da una pipeline di operatori, in questo caso composta da un solo map da Object a string.
Una cosa importante da capire sugli observables è che sono completamente passivi fino a che qualcuno chiama il loro metodo subscribe; dunque come fa la web app a mostrare i dati in questo caso? Chi chiama la subscribe? La risposta esatta è... la AsyncPipe!
Come vedremo a brevissimo, le pipes sono funzioni che possono essere utilizzate nei template per trasformare le espressioni usate nei binding. Qui la AsyncPipe sta facendo un duplice lavoro: da un lato attiva l'observable data, causando la chiamata HTTP e la trasformazione della risposta a stringa JSON; dall'altro, estrae il valore dell'observable e lo rende visibile nella vista.
In questo caso non possiamo apprezzarlo, ma la async pipe fa anche una terza cosa: annulla la subscribe quando il ciclo di vita del componente termina. Questa operazione non è necessaria per observable che si concludono dopo aver emesso un solo dato, ma un observable generalmente rappresenta un flusso di dati nel tempo che potrebbe anche non avere una fine, perciò dobbiamo cancellare la sottoscrizione quando non ha più senso ricevere aggiornamenti.
3.7
Le Pipes in Angular
Nella precedente sezione di questa guida abbiamo visto come la AsyncPipe ci permette di estrarre il valore di un Observable in modo da renderlo visibile nel template senza gestirlo manualmente lato codice. Questo è uno dei tanti utilizzi possibili delle pipes, un costrutto molto semplice ma anche molto potente che possiamo utilizzare nelle interpolazioni e nei data binding.
Anche se ne abbiamo già visto un esempio di utilizzo, diamo una definizione formale di pipe.
Che cos’è una pipe in Angular?
Una pipe è una funzione di trasformazione di un valore, implementata secondo un certo contratto definito dal framework in modo da poter essere riconosciuta e utilizzata nei template.
La definizione funzione di trasformazione è volutamente vaga: si possono implementare pipe con ogni tipo di input e ogni tipo di output.
Per esempio, nella sezione precedente abbiamo chiamato pipe su un Observable per manipolare i dati al suo interno e ottenere la stringa JSON da mostrare a schermo, partendo da un oggetto; ebbene, per questo tipo di operazione esiste una pipe built-in molto comoda.
Vediamo come avremmo potuto usarla in app.component.ts:
import { Component, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { map } from 'rxjs';
import { AsyncPipe, JsonPipe } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [AsyncPipe, JsonPipe], // <- import JsonPipe
template: '<pre>{{data | async | json}}</pre>' // <- use JsonPipe
})
export class AppComponent {
http = inject(HttpClient);
data = this.http.get('https://catfact.ninja/fact'); // <- remove pipe/map
}
Rispetto al codice preesistente, è evidente che ci siamo semplificati un po' la vita: operare sugli Observable può essere difficile, ma estraendo prima il valore con | async (si legge "pipe async"), diventa tutto più facile. Il simbolo | è chiamato pipe operator.
Benché il metodo Observable.pipe e le pipe di Angular non siano la stessa cosa, la loro omonimia non è un caso. Qualunque concatenazione di funzioni che trasformano un valore in sequenza è chiamata pipe, cioè tubo, e spesso ci si riferisce a un sistema di funzioni in pipe come ad una pipeline di funzioni.
Angular fornisce di suo tutta una serie di built-in pipes per diversi scopi, ad esempio:
DatePipe: per formattare una Data
UpperCasePipe: per riscrivere una stringa di testo in maiuscolo
PercentPipe: per trasformare una value numerica in una percentuale
Le pipe possono anche accettare degli argomenti che ne configurino il comportamento; per esempio, DatePipe accetta un argomento di tipo stringa per impostare la formattazione desiderata:
import { Component } from '@angular/core';
import { DatePipe } from '@angular/common';
@Component({
selector: 'app-root',
standalone: true,
imports: [DatePipe],
template: '<pre>{{date | date: "dd/MM/yyyy" }}</pre>'
})
export class AppComponent {
date = new Date();
}
Per creare una custom pipe non dobbiamo fare altro che scrivere una classe decorata con @Pipe e che implementa l'interfaccia PipeTransform;
ci facciamo, come sempre, aiutare dalla CLI:
ng generate pipe quote
Andremo a generare una semplice pipe che inserisce tra virgolette il nostro dato:
import { Pipe, PipeTransform } from '@angular/core';
@Pipe({
name: 'quote',
standalone: true
})
export class QuotePipe implements PipeTransform {
transform(value: string | null = '', quoteChar: '\'' | '"' = '"'): string {
return `${quoteChar} ${value} ${quoteChar}`;
}
}
In app.component.ts:
import { Component } from '@angular/core';
import { DatePipe } from '@angular/common';
import { QuotePipe } from './quote.pipe';
@Component({
selector: 'app-root',
standalone: true,
imports: [DatePipe, QuotePipe],
template: '<pre>{{date | date: "dd/MM/yyyy" | quote: "\'" }}</pre>'
})
export class AppComponent {
date = new Date();
}
Ma non avevamo detto che le pipe erano funzioni?
Ebbene sì, Angular implementa le pipe sotto forma di classi ma le espone al template sotto forma di funzioni che si concatenano con l'operatore |. Grazie a questa astrazione, in una pipe è possibile iniettare dei servizi via DI, ma anche conservare uno stato e gestire il ciclo di vita, come fa AsyncPipe.
Una semplice funzione non potrebbe implementare comportamenti dilazionati nel tempo, avendo un'unica esecuzione al momento della chiamata.
Le pipe pure e le pipe impure in Angular.
Le ultime pipe che abbiamo visto sono cosiddette pure, nel senso che il loro output dipende esclusivamente dal loro input. Ma noi abbiamo appena detto che una pipe class può avere uno stato, perciò questo potrebbe influenzare l'output: ecco che subentrano le pipe impure.
Annotando una pipe con pure: false indichiamo al framework che la pipe non dipende esclusivamente dall'input che arriva dal template; un esempio di pipe impura è la AsyncPipe, che riceve un solo valore Observable come input e poi detiene internamente come stato i valori estratti nel tempo, senza che questo venga mai riassegnato e dunque senza che l'input cambi.
3.8
Routing in Angular
Se volessimo sintetizzare al massimo il funzionamento di un sito web classico, potremmo ridurre tutto a un continuo ping pong tra client e server, dove il primo fa le richieste e il secondo risponde con delle pagine HTML; queste pagine HTML, a loro volta, ci permettono di fare nuove richieste, generalmente in GET se vogliamo navigare su un'altra pagina, oppure in POST se vogliamo inviare dei dati.
Questo meccanismo costituisce una ragione importante per cui normalmente l'esperienza di un'applicazione nativa è preferita dagli utenti alla navigazione sul web, specialmente per tutte quelle attività che ci permettono di utilizzare un qualche servizio in modo particolarmente interattivo.
Come altri framework frontend, Angular mira a colmare questo divario realizzando le cosiddette applicazioni a pagina singola (single page application, o SPA); questa definizione vale per tutti quei siti web che implementano una navigazione a frontend e quindi non richiedono mai il ricaricamento della pagina mentre vengono utilizzati. L'insieme di funzionalità che consentono la navigazione a frontend prende il nome di routing; il routing si occupa di:
associare ad ogni percorso dell'URL una vista o pagina, rappresentata da un componente;
supportare l'uso di parametri per fornire dati dinamici alla rotta attiva;
valutare l'indirizzo di atterraggio al primo caricamento della web app e attivare la vista associata;
implementare la navigazione a frontend sincronizzando la sostituzione della vista con il nuovo indirizzo e manipolando la cronologia del browser, simulando una navigazione reale.
In Angular tutte queste operazioni girano intorno alla service class Router, e la sua configurazione è generata automaticamente da ng new rispondendo y alla domanda: Would you like to add Angular routing?
In ogni applicazione Angular dotata di navigazione esiste un'unica istanza (singleton) della classe Router; il router espone metodi che consentono la navigazione ed eventi che possono essere intercettati per reagire alla navigazione. Insieme ad altri servizi, componenti, direttive, ecc, la classe Router è impacchettata nel RouterModule.
Il RouterModule è un NgModel, cioè un pacchetto di classi Angular (componenti, direttive, servizi, ecc) che possono essere importate insieme usando la proprietà imports passata al decoratore @Component.
Anche il CommonModule importato in precedenza è un NgModule! In quel caso, ci ha permesso di importare le direttive strutturali tutte insieme.
Il router module è configurato in app.config.ts tramite la funzione provideRouter che accetta come argomento la configurazione delle rotte di navigazione. Questa configurazione si trova in app.routes.ts e ha lo scopo di associare i percorsi di navigazione dell'app ai rispettivi componenti.
Siamo pronti per vedere un semplice esempio. Costruiamo un'app che contiene una generica pagina elenco e una pagina dettaglio; dagli elementi dell'elenco si navigherà verso i dettagli passando alla rotta un parametro identificativo. Aggiungeremo anche una pagina not found, nel caso in cui si atterri su un indirizzo non valido.
Creiamo le nostre pagine con la CLI:
ng g c pages/list
ng g c pages/detail
ng g c pages/not-found
Il comando g c è abbreviazione di generate component; con ng --help possiamo consultare tutti gli altri alias.
Ora associamo le pagine alle diverse rotte; la rotta dell'elenco è una semplice rotta statica, mentre quella del dettaglio include un parametro per identificare l'articolo; la rotta not found dovrà invece intercettare tutte le rotte non configurate.
In app.routes.ts:
import { Routes } from '@angular/router';
import { ListComponent } from './pages/list/list.component';
import { DetailComponent } from './pages/detail/detail.component';
import { NotFoundComponent } from './pages/not-found/not-found.component';
export const routes: Routes = [{
path: 'list',
component: ListComponent
}, {
path: 'detail/:id',
component: DetailComponent
}, {
path: '',
redirectTo: 'list'
}, {
path: '**',
component: NotFoundComponent
}];
In questa configurazione abbiamo:
una rotta con path statico;
una rotta con un path parametrizzato da :id;
una rotta che intercetta il path / e naviga verso list, la nostra home;
una rotta "pigliatutto" che intercetta qualunque percorso (**) e mostra il nostro not found.
Va notato che l'ordine delle rotte conta: se la rotta “pigliatutto” ** fosse all'inizio dell'array, intercetterebbe anche i percorsi che hanno un componente associato e, dunque, la nostra app mostrerebbe sempre e solo not found.
Per poter visualizzare le pagine, nel nostro app component dobbiamo proiettare la pagina attiva; lo facciamo con il router-outlet:
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterModule], // <- import router module
template: '<router-outlet />' // <- active page placeholder
})
export class AppComponent { }
Il tag router-outlet è un segnaposto che indica al router dove collocare la pagina attiva; è importante sottolineare che il contenuto della pagina non verrà renderizzato dentro ma sotto al router-outlet, che rimarrà un tag vuoto nel codice HTML generato da Angular.
Implementiamo dunque le nostre varie pagine.
In list.component.html mettiamo l'elenco dei link di navigazione al dettaglio, passando diversi :id
<ul>
<li><a routerLink="/detail/1">Item 1</a></li>
<li><a routerLink="/detail/2">Item 2</a></li>
<li><a routerLink="/detail/3">Item 3</a></li>
</ul>
Al posto dell'attributo href incontriamo la direttiva routerLink ; per farla funzionare, dobbiamo importare RouterModule in list.component.ts:
import { Component } from '@angular/core';
import { RouterModule } from '@angular/router';
@Component({
selector: 'app-list',
standalone: true,
imports: [RouterModule],
templateUrl: './list.component.html',
styleUrl: './list.component.css'
})
export class ListComponent { }
La direttiva routerLink è molto importante perché implementa lo stesso comportamento che avremmo utilizzando l'attributo href, salvo il fatto che la navigazione avviene tramite il Router, che è una sua dipendenza interna. Se utilizzassimo href, causeremmo una ricarica della pagina (e dunque un riavvio dell'intera app) ad ogni navigazione!
Nel nostro DetailComponent vogliamo mostrare l'identificativo ricevuto attraverso il percorso di navigazione; per farlo dobbiamo introdurre una nuova classe injectable: ActivatedRoute. Questa classe può essere iniettata nel componente associato alla rotta attiva e ci permetterà di consultare i dati di navigazione (parametri, query string, eccetera).
Vediamolo in detail.component.ts:
import { Component } from '@angular/core';
import { ActivatedRoute } from '@angular/router';
@Component({
selector: 'app-detail',
standalone: true,
imports: [],
templateUrl: './detail.component.html',
styleUrl: './detail.component.css'
})
export class DetailComponent {
constructor(private route: ActivatedRoute) {}
get id() { return this.route.snapshot.paramMap.get('id') }
}
DetailComponent qui richiede ActivatedRoute come dipendenza alla DI, dopodiché espone un getter che estrae id dai parametri di navigazione.
Dunque detail.component.html avrà questo aspetto:
<p>Element with id: <code>{{ id }}</code></p>
Prima di andare avanti, facciamo qualche osservazione.
Anzitutto, abbiamo iniettato ActivatedRoute tramite costruttore; sarebbe stato equivalente farlo usando la funzione inject. L'API di ActivatedRoute espone perlopiù eventi rappresentati da proprietà di tipo Observable; possiamo navigare, invece, i dati già estratti attraverso la proprietà snapshot di tipo ActivatedRouteSnapshot; se ci servono solo i dati da questa classe, possiamo direttamente iniettarla al posto di ActivatedRoute.
L'uso dell'API basata su eventi diventa particolarmente comodo se si vuole avere un'API uniforme tra la navigazione e il recupero asincrono di dati, associando alla ricezione del parametro id una chiamata HTTP che recuperi i relativi dati; più avanti vedremo meglio come.
Poiché sia lavorare per eventi, sia estrarre i dati da uno snapshot può risultare macchinoso, il team di Angular ha introdotto un'API più leggera per accedere ai dati di navigazione: riceverli attraverso delle normalissime input property, come se fossero passati tramite il template del componente padre.
Per attivare questa API dobbiamo fare una piccola aggiunta in app.config.ts:
import { ApplicationConfig } from '@angular/core';
import { provideRouter, withComponentInputBinding } from '@angular/router';
import { routes } from './app.routes';
import { provideClientHydration } from '@angular/platform-browser';
import { provideHttpClient } from '@angular/common/http';
export const appConfig: ApplicationConfig = {
providers: [
provideRouter(routes, withComponentInputBinding()), // <-
provideClientHydration(),
provideHttpClient()
]
};
Con quest'impostazione detail.component.ts diventa:
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-detail',
standalone: true,
imports: [],
templateUrl: './detail.component.html',
styleUrl: './detail.component.css'
})
export class DetailComponent {
@Input() id!: string; // just this!
}
A questo punto, se volessimo reagire alla ricezione del parametro id per innescare una qualche chiamata, ci basterebbe scrivere esplicitamente il setter della proprietà:
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-detail',
standalone: true,
imports: [],
templateUrl: './detail.component.html',
styleUrl: './detail.component.css'
})
export class DetailComponent {
#id!: string;
get id() { return this.#id }
@Input() set id(value: string) {
this.#id = value;
this.#asyncHttpCall();
}
async #asyncHttpCall() {
// await ...
}
}
Nonostante l'estrema comodità di questo approccio, dobbiamo sottolineare due rischi: il primo è che stiamo in realtà facendo un uso improprio delle input property, o per lo meno ne stiamo implicitamente estendendo l'area di competenza; il secondo è che ci stiamo affidando a una magia del framework per associare le proprietà ai parametri, e dunque abbiamo possibilità più limitate nel gestire casi limite ed errori.
Prima di abbandonare questo argomento, andiamo a vedere come usare Router nella component class per navigare programmaticamente oppure intercettare gli eventi di navigazione. Per farlo, implementiamo un pulsante "Torna alla home" nella vista di dettaglio, ma anziché usare routerLink su un tag a useremo il metodo navigateByUrl sul router, iniettato nel componente.
In detail.component.ts:
import { Component, Input, inject } from '@angular/core';
import { Router } from '@angular/router';
@Component({
selector: 'app-detail',
standalone: true,
imports: [],
templateUrl: './detail.component.html',
styleUrl: './detail.component.css'
})
export class DetailComponent {
router = inject(Router);
@Input() id!: string;
}
In detail.component.html:
<p>Element with id: <code>{{ id }}</code></p>
<button (click)="router.navigateByUrl('/list')">Home</button>
Niente di particolarmente sorprendente o particolarmente utile data la semplicità di questo esempio; l'uso del router per navigare programmaticamente diventa, però, d'obbligo nel momento in cui la navigazione dovesse avvenire a seguito di altre logiche, ad esempio un salvataggio.
Per intercettare gli eventi di navigazione ci spostiamo invece in AppComponent che essendo il contenitore di router-outlet rimane sempre attivo.
In app.component.ts:
import { Component, OnInit, inject } from '@angular/core';
import { Router, RouterModule } from '@angular/router';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterModule],
template: '<router-outlet />'
})
export class AppComponent implements OnInit {
router = inject(Router);
ngOnInit(): void {
this.router.events.subscribe(console.log);
}
}
In questo esempio abbiamo implementato l'interfaccia OnInit per agganciarci agli eventi del router, esposti dalla proprietà events sotto forma di un Observable; in questo modo andremo a loggare tutto quello che viene fuori dal router quando navighiamo.
Se andiamo nel browser e apriamo la console, vedremo che questi eventi rappresentano tutto il ciclo di navigazione (da NavigationStart a NavigationEnd); in una casistica più verosimile, se volessimo, ad esempio, agganciare un comportamento all'avvenuta navigazione, saremmo interessati esclusivamente all'evento NavigationEnd che chiude il ciclo:
import { Component, OnInit, inject } from '@angular/core';
import { EventType, Router, RouterModule } from '@angular/router';
import { filter } from 'rxjs';
@Component({
selector: 'app-root',
standalone: true,
imports: [RouterModule],
template: '<router-outlet />'
})
export class AppComponent implements OnInit {
router = inject(Router);
ngOnInit(): void {
this.router.events
.pipe(filter(e => e.type === EventType.NavigationEnd))
.subscribe(console.log);
}
}
Qui abbiamo usato il metodo Observable.pipe insieme all'operatore filter per generare un flusso di eventi che emette solo NavigationEnd.
3.9
Invio di form in Angular
Parlando di routing abbiamo detto che una web app classica funziona come un ping pong tra client e server, e abbiamo visto come il frontend routing consente di spostare le logiche di navigazione lato client in modo da evitare continui ricarichi della pagina.
Ebbene la navigazione non è l'unica ragione per cui il browser normalmente ricarica una pagina web: un'altra ragione può essere l'invio di un form (o modulo). Lavorando in linguaggio HTML puro, senza l'aiuto del linguaggio JavaScript, il tag form è sostanzialmente l'unico modo che abbiamo per interagire con l'applicazione, ma il submit di un form coincide con una richiesta POST al server e una conseguente redirezione alla pagina che segue l'invio del modulo.
Nelle moderne web app interattive, assumere il controllo sulle logiche di compilazione e invio di un form è importantissimo per garantire una user experience (UX) ottimale. Anche in questo ambito Angular non ci lascia soli e ci mette a disposizione due diversi ng module che rappresentano due diversi approcci nella gestione dei form: FormsModule e ReactiveFormsModule.
Come accade rispetto ad altre API del framework, anche in questo caso la differenza tra i due approcci è principalmente la seguente: in un caso il framework si fa carico di una parte del carico di lavoro e ci rende la vita più facile, al costo di un minore controllo sulle logiche di funzionamento del form.
In questo caso, l'API più di basso livello è ReactiveFormsModule, fortemente basata anch'essa su RxJS e sull'uso di Observable per esporre le modifiche che l'utente apporta al form, da cui il nome reactive forms; su questa API si basa FormsModule, che incapsula il modello reattivo in una serie di direttive da applicare nel template del form, da cui il nome template driven forms.
Prendiamo ad esempio un form di registrazione con i seguenti campi:
username: obbligatorio, univoco;
password: obbligatorio, minimo 6 caratteri, almeno una lettera, almeno un numero;
verifyPassword: contenuto uguale a quello di password.
Nel caso in cui i campi falliscano la validazione, dovranno comparire dei messaggi sotto ai campi interessati.
Prima di partire, facciamo qualche osservazione:
il campo username dovrà essere validato sia in modo sincrono, sia in modo asincrono; la validazione asincrona consiste in una chiamata HTTP a un endpoint che restituirà un booleano che ci dice se l'username inserito rispetta o no il vincolo di unicità, cioè in sostanza se il nome utente scelto è disponibile;
il campo password dovrà attraversare una serie di passaggi di validazione tutti basati sul suo contenuto, mentre la validazione del campo verifyPassword implicherà un confronto tra due diversi campi del form.
Template driven forms in Angular.
Per prima cosa implementiamo il form in forma template driven.
In app.component.ts:
import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
@Component({
selector: 'app-root',
standalone: true,
imports: [FormsModule, JsonPipe],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
model = {
username: '',
password: '',
verifyPassword: ''
}
}
Qui abbiamo semplicemente inizializzato il model del nostro form, cioè un semplice oggetto che contiene i dati del form.
Abbiamo anche importato le direttive che useremo nel template in app.component.html:
<h2>Sign up!</h2>
<form #signup="ngForm" appVerifyPassword>
<div class="field">
<label for="username">Username</label>
<input
type="text"
name="username"
[(ngModel)]="model.username"
#username="ngModel"
required
appUniqueUsername
>
@if (signup.submitted && username.hasError('required')) {
<p class="error">This field is required.</p>
}
@if (signup.submitted && username.hasError('uniqueUsername')) {
<p class="error">This username is not available.</p>
}
</div>
<div class="field">
<label for="password">Password</label>
<input
type="text"
name="password"
[(ngModel)]="model.password"
#password="ngModel"
required
minlength="6"
appPasswordConstraints
>
@if (signup.submitted && password.hasError('required')) {
<p class="error">This field is required.</p>
}
@if (signup.submitted && password.hasError('minlength')) {
<p class="error">Password must be at least 6 characters long.</p>
}
@if (signup.submitted && password.hasError('letter')) {
<p class="error">Password must contain at least one letter.</p>
}
@if (signup.submitted && password.hasError('digit')) {
<p class="error">Password must contain at least one digit.</p>
}
</div>
<div class="field">
<label for="verifyPassword">Verify password</label>
<input
type="text"
name="verifyPassword"
[(ngModel)]="model.verifyPassword"
>
@if (signup.submitted && signup.hasError('verifyPassword')) {
<p class="error">Double check your password.</p>
}
</div>
<button type="submit">Submit</button>
</form>
<pre>{{ model | json }}</pre>
Ora si che abbiamo un po' di cose da spiegare!
Tanto per cominciare, abbiamo costruito un normalissimo form HTML standard, con campi input e le rispettive label.
Su questo abbiamo aggiunto:
degli speciali binding a doppia via (in inglese: two-way data binding) con la direttiva attributo ngModel, usando una sintassi che combina data binding e event binding per fare in modo che la proprietà model del componente sia sempre sincronizzata con i valori del form;
le template variable signup, username e password, necessarie per ispezionarne lo stato e mostrare i messaggi di errore, direttamente dal template; abbiamo usato la sintassi #variableName="directiveSelector" per specificare il tipo di riferimento che vogliamo associare alla variabile, cioè ngModel per i campi singoli e ngForm per l'intero form;
dei messaggi condizionati alla presenza di errori, mostrati solo dopo il primo tentativo di submit del form; per farlo abbiamo usato la sintassi di control-flow anziché le direttive strutturali, ma sarebbe stato equivalente;
gli attributi di validazione HTML standard required e maxlength, che Angular intercetta con delle sue direttive in modo tale da integrarli nella validazione del form;
gli attributi di validazione custom appVerifyPassword, appUniqueUsername e appPasswordConstraints, che dovranno essere associati a delle opportune direttive, che andremo presto a creare.
La sintassi del two-way binding è anche detta "banana in a box", dalla forma delle parentesi [()]. Questa speciale sintassi è supportata da Angular a partire da una convenzione: per implementarla, un componente o una direttiva deve avere una input property con un certo nome, più una output property con lo stesso nome seguito dal suffisso Change.
Per esempio, la direttiva ngModel espone una proprietà @Input() ngModel e una proprietà @Output() ngModelChange. La sintassi [(ngModel)]="model.username" è dunque semplicemente zucchero sintattico per [ngModel]="model.username" (ngModelChange)="model.username = $event"!
Prima di proseguire, osserviamo anche che:
tutti gli input hanno l'attributo name; questo è necessario per consentire ad Angular di dare un nome ai campi e associarli correttamente al form;
in coda al form abbiamo anche aggiunto un riferimento a model, rappresentato come oggetto JSON, per verificare che il two-way binding stia funzionando.
Definiamo anche un minimo di stili per rendere più funzionale la UI del nostro form;
in app.component.css:
:host {
display: grid;
justify-content: center;
}
h2 {
text-align: center;
}
form {
width: 400px;
display: flex;
flex-direction: column;
gap: 20px;
}
p {
margin: 0;
}
button {
height: 40px;
}
.field {
display: flex;
flex-direction: column;
gap: 5px;
}
.error {
color: red;
font-size: small;
}
/* highlight invalid fields after submit */
.ng-submitted > .field:has(.ng-invalid) {
color: red;
}
Qui l'unica cosa interessante da notare - ma non è cosa da poco - è che Angular mette a disposizione tutta una serie di classi che indicano lo stato del form; in questo modo possiamo stilizzarlo opportunamente in caso di errori.
Quello che abbiamo fatto è seguire con gli stili la stessa logica di condizionamento dei messaggi di errore: mentre l'utente compila il form non mostriamo errori, dopo il primo tentativo di submit li mostriamo e da quel momento li faremo sparire solo se i campi verranno opportunamente compilati.
Un'altra strategia potrebbe essere aspettare che l'utente modifichi un campo e, dunque, mostrare subito gli errori, prima del submit. Per farlo, possiamo usare le classi ng-dirty e ng-touched: la prima comparirà alla prima modifica del form, la seconda solo dopo aver tolto il focus al primo campo. Sulla template variable del form avremo le proprietà omologhe dirty e touched, che potremo usare al posto di submitted per condizionare i messaggi d'errore.
È giunto il momento di implementare le direttive di validazione. Per crearne una, dobbiamo sostanzialmente implementare l'interfaccia Validator e configurare un particolare tipo di provider, cioè una configurazione per consentire alla DI di associare l'istanza dell'interfaccia al form.
ng g d validation/password-constraints
ng g d validation/verify-password
ng g d validation/unique-username
Andiamo a implementarle.
Iniziamo con password-constraints.directive.ts:
import { Directive } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
@Directive({
selector: '[appPasswordConstraints]',
standalone: true,
providers: [{ // <- add provider
provide: NG_VALIDATORS,
useExisting: PasswordConstraintsDirective,
multi: true
}]
})
export class PasswordConstraintsDirective implements Validator {
validate(control: AbstractControl<any, any>): ValidationErrors | null {
const hasLetter = /[a-zA-Z]/.test(control.value);
const hasDigit = /[0-9]/.test(control.value);
return (hasLetter && hasDigit)
? null
: {
letter: !hasLetter,
digit: !hasDigit
};
}
}
Questa direttiva può restituire da nessuno a due errori. La presenza di errori viene marcata da proprietà con valore veritiero (truthy), in questo caso true; è anche possibile valorizzare la proprietà con un oggetto o una stringa, qualunque cosa sia d'aiuto per esempio per mostrare il messaggio di errore nel form.
Dunque, anche l'assenza di una proprietà sull'oggetto restituito implica l'assenza di un determinato errore.
Nel caso in cui non ci siano errori è importante restituire null, altrimenti il campo risulterà invalido anche se non conterrà alcuna flag.
Implementiamo ora verify-password.directive.ts:
import { Directive } from '@angular/core';
import { AbstractControl, NG_VALIDATORS, ValidationErrors, Validator } from '@angular/forms';
@Directive({
selector: '[appVerifyPassword]',
standalone: true,
providers: [{
provide: NG_VALIDATORS,
useExisting: VerifyPasswordDirective,
multi: true
}]
})
export class VerifyPasswordDirective implements Validator {
validate(control: AbstractControl<any, any>): ValidationErrors | null {
const password = control.get('password');
const verifyPassword = control.get('verifyPassword');
return password?.value !== verifyPassword?.value
? { verifyPassword: true }
: null;
}
}
In questo caso, è importante notare che la validazione non è stata applicata su un singolo campo, ma sull'intero form: i campi sono stati estratti tramite il metodo get, che consente di navigare tramite le stringhe utilizzate per popolare l'attributo name dei vari campi.
Il fatto che il modello del form sia stato costruito automaticamente a partire dal template HTML è il motivo essenziale per cui questo approccio è chiamato template driven.
Non ci resta che implementare unique-username.directive.ts:
import { Directive } from '@angular/core';
import { AbstractControl, AsyncValidator, NG_ASYNC_VALIDATORS, ValidationErrors } from '@angular/forms';
import { Observable, map, timer } from 'rxjs';
const mockUsernameDb = ['tim', 'john', 'anna'];
@Directive({
selector: '[appUniqueUsername]',
standalone: true,
providers: [{
provide: NG_ASYNC_VALIDATORS,
useExisting: UniqueUsernameDirective,
multi: true
}]
})
export class UniqueUsernameDirective implements AsyncValidator {
validate(control: AbstractControl<any, any>): Observable<ValidationErrors | null> {
return timer(1000).pipe(map(() => mockUsernameDb.includes(control.value)
? { uniqueUsername: true }
: null
));
}
}
Osserviamo subito che, in questo caso, l'interfaccia implementata è AsyncValidator anziché Validator, e come provider abbiamo usato il token NG_ASYNC_VALIDATORS anziché NG_VALIDATORS. Questo perché Angular deve sapere che dovrà aspettare prima di valutare il risultato della validazione.
Il metodo validate può ritornare un Observable o una Promise; in questo caso abbiamo ristretto il valore di ritorno a Observable.
Dato che non abbiamo un backend, lo mockiamo, cioè simuliamo il comportamento di una chiamata HTTP con un "database" fantoccio, rappresentato da un array contenente gli username già presi.
La flag d'errore si alza, dunque, dopo un secondo se il valore inserito è incluso nell'array.
Per simulare l'attesa di una chiamata HTTP abbiamo usato timer, una funzione che crea un Observable che emette dopo il tempo indicato, dopodiché abbiamo mappato l'evento con l'oggetto contenente la flag di errore. In alternativa, avremmo potuto creare una Promise e risolverla dopo un certo tempo usando setTimeout.
A questo punto le nostre direttive sono tutte completate; non ci resta che importarle in app.component.ts:
import { JsonPipe } from '@angular/common';
import { Component } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { PasswordConstraintsDirective } from './validation/password-constraints.directive';
import { VerifyPasswordDirective } from './validation/verify-password.directive';
import { UniqueUsernameDirective } from './validation/unique-username.directive';
@Component({
selector: 'app-root',
standalone: true,
imports: [
FormsModule,
JsonPipe,
PasswordConstraintsDirective, // <-
VerifyPasswordDirective, // <-
UniqueUsernameDirective // <-
],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
model = {
username: '',
password: '',
verifyPassword: ''
}
}
Ed ecco che, uniti tutti i tasselli, il nostro form soddisfa tutti i requisiti.
Abbiamo dunque visto le basi dei form con l'approccio template driven; non ci resta che implementare lo stesso esempio usando i reactive forms.
Reactive forms in Angular.
Iniziamo subito da app.component.ts:
import { JsonPipe } from '@angular/common';
import { Component, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { validateVerifyPassword } from './validation/validate-verify-password';
import { validateUniqueUsername } from './validation/validate-unique-username';
import { validatePasswordConstraints } from './validation/validate-password-constraints';
@Component({
selector: 'app-root',
standalone: true,
imports: [ReactiveFormsModule, JsonPipe],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent {
model = inject(FormBuilder).group({
username: ['', Validators.required, validateUniqueUsername],
password: ['', [
Validators.required,
Validators.minLength(6),
validatePasswordConstraints]
],
verifyPassword: ['']
}, {
validators: [validateVerifyPassword]
});
}
Ci accorgiamo immediatamente che model non è più un semplice oggetto, ma un'istanza della classe FormGroup, che fornisce tutta una serie di proprietà e metodi per interagire direttamente con le meccaniche interne del form.
Il FormGroup è stato costruito attraverso il servizio FormBuilder, che funziona così: per ogni campo richiede una tripla, cioè un array con 3 posizioni:
alla prima vuole il valore iniziale del form, in questo caso una stringa vuota per tutti i campi;
alla seconda, un validatore sincrono oppure un array di validatori sincroni;
alla terza, un validatore asincrono oppure un array di validatori asincroni.
Se non abbiamo bisogno di validatori possiamo semplicemente accorciare la tripla omettendo le posizioni vuote, oppure possiamo riempirle con un array vuoto o con il valore null. Il funzionamento dei metodi di FormBuilder serve esclusivamente a omettere la ripetizione del costruttore FormGroup, la cui firma funziona esattamente come appena spiegato, eccetto per il fatto che i posti della tripla diventano i 3 argomenti da passare al costruttore.
Quanto ai validatori, sia built-in sia custom, vediamo che sono semplici funzioni passate nella configurazione del form.
Prima di vedere come cambia il template, andiamo subito a implementarli.
Partiamo da validate-unique-username.ts, che possiamo creare manualmente:
import { AsyncValidatorFn } from '@angular/forms';
import { timer, map } from 'rxjs';
const mockUsernameDb = ['tim', 'john', 'anna'];
export const validateUniqueUsername: AsyncValidatorFn = control =>
timer(1000).pipe(map(() => mockUsernameDb.includes(control.value)
? { uniqueUsername: true }
: null
));
Vediamo che qui non stiamo dichiarando una direttiva che implementa l'interfaccia AsyncValidator, ma più semplicemente stiamo esportando una funzione di tipo AsyncValidatorFn, che non è altro che un tipo che rappresenta la firma del metodo AsyncValidator.validate.
Vediamo ora validate-password-constraints.ts:
import { ValidatorFn } from '@angular/forms';
export const validatePasswordConstraints: ValidatorFn = control => {
const hasLetter = /[a-zA-Z]/.test(control.value);
const hasDigit = /[0-9]/.test(control.value);
return (hasLetter && hasDigit)
? null
: {
letter: !hasLetter,
digit: !hasDigit
};
}
Qui abbiamo usato ValidatorFn al posto di AsyncValidatorFn, ma il parallelo con l'interfaccia Validator resta identico.
Chiudiamo con validate-verify-password.ts:
import { ValidatorFn } from '@angular/forms';
export const validateVerifyPassword: ValidatorFn = control => {
const password = control.get('password');
const verifyPassword = control.get('verifyPassword');
return password?.value !== verifyPassword?.value
? { verifyPassword: true }
: null;
}
Anche qui, nessuna sorpresa.
Vediamo infine come è cambiato app.component.html:
<h2>Sign up!</h2>
<form [formGroup]="model" #form="ngForm">
<div class="field">
<label for="username">Username</label>
<input type="text" formControlName="username">
@if (form.submitted && model.controls.username.hasError('required')) {
<p class="error">This field is required.</p>
}
@if (form.submitted && model.controls.username.hasError('uniqueUsername')) {
<p class="error">This username is not available.</p>
}
</div>
<div class="field">
<label for="password">Password</label>
<input type="text" formControlName="password">
@if (form.submitted && model.controls.password.hasError('required')) {
<p class="error">This field is required.</p>
}
@if (form.submitted && model.controls.password.hasError('minlength')) {
<p class="error">Password must be at least 6 characters long.</p>
}
@if (form.submitted && model.controls.password.hasError('letter')) {
<p class="error">Password must contain at least one letter.</p>
}
@if (form.submitted && model.controls.password.hasError('digit')) {
<p class="error">Password must contain at least one digit.</p>
}
</div>
<div class="field">
<label for="verifyPassword">Verify password</label>
<input type="text" formControlName="verifyPassword">
@if (form.submitted && model.hasError('verifyPassword')) {
<p class="error">Double check your password.</p>
}
</div>
<button type="submit">Submit</button>
</form>
<pre>{{ model.value | json }}</pre>
Notiamo subito che il template si è snellito di parecchio, dato che la validazione non passa più attraverso una serie di attributi, ma è interamente regolata lato codice.
L'unico binding presente è quello su [formGroup]="model", dove abbiamo associato l'intero form alla proprietà model di tipo FormGroup.
Dopodiché, al posto di name usiamo l'attributo formControlName, e il gioco è fatto.
Per vedere il valore del form non usiamo più direttamente model, ma la proprietà value esposta da FormGroup.
Per concludere, osserviamo che per valutare lo stato submitted del form abbiamo usato la template variable form che espone tale proprietà, perché per qualche motivo inspiegabile FormGroup non lo fa.
La verità è che una spiegazione per l'assenza di questa proprietà su FormGroup ci sarebbe: poiché i form group sono annidabili uno dentro l'altro, e form group non è l'unico modo di aggregare diversi campi, non è esattamente sensato che lo stato di sottoscrizione di un form sia consultabile a partire da questa classe; perciò dobbiamo ripiegare sulla direttiva ngForm, la stessa che useremmo nei template driven forms e che è necessariamente associata a un tag form HTML.
Prima di proseguire, ci resta una domanda a cui rispondere: perché si chiamano reactive forms? La risposta è molto semplice: perché, similarmente a quanto visto per ActivatedRoute, FormGroup espone una serie di eventi attraverso le proprietà di tipo Observable valueChanges e statusChanges.
Vediamolo rapidamente in app.component.ts:
import { JsonPipe } from '@angular/common';
import { Component, OnInit, inject } from '@angular/core';
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
import { validateVerifyPassword } from './validation/validate-verify-password';
import { validateUniqueUsername } from './validation/validate-unique-username';
import { validatePasswordConstraints } from './validation/validate-password-constraints';
@Component({
selector: 'app-root',
standalone: true,
imports: [ReactiveFormsModule, JsonPipe],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements OnInit {
model = inject(FormBuilder).group({
username: ['', Validators.required, validateUniqueUsername],
password: ['', [
Validators.required,
Validators.minLength(6),
validatePasswordConstraints
]],
verifyPassword: ['']
}, {
validators: [validateVerifyPassword]
});
ngOnInit(): void { // <-
this.model.valueChanges.subscribe(value => console.log({ value }));
this.model.statusChanges.subscribe(status => console.log({ status }))
}
}
Se ora apriamo la console del browser e guardiamo cosa succede mentre compiliamo il form, vedremo che ad ogni nostra azione riceveremo aggiornamenti su valore e stato del form.
Abbiamo visto come realizzare un form con validazione sincrona e asincrona secondo i due approcci messi a disposizione da Angular, ma abbiamo - fin qui - omesso la cosa più semplice, ma più importante: la gestione dell'evento submit: in questo caso, sia che usiamo FormsModule o ReactiveFormsModule, sarà sufficiente agganciarsi all'evento ngSubmit sul tag form.
Questo evento verrà scatenato ogni volta che un button type="submit" verrà cliccato, oppure se viene premuto invio. L'evento ngSubmit va usato al posto del semplice submit, anch'esso disponibile, perché tiene conto di tutta la gestione del form model.
Se, invece, volessimo semplicemente utilizzare il tag form senza usare né template driven forms né reactive forms, potremmo utilizzare direttamente submit.
3.10
Built-in control flow in Angular
Precedentemente abbiamo visto come con le direttive strutturali sia possibile condizionare o ripetere dei blocchi di template. Abbiamo anche accennato che è stata recentemente introdotta una nuova sintassi chiamata built-in control flow (flusso di controllo integrato), che consente di dichiarare blocchi if, switch e for introducendoli con @; ne abbiamo visto un esempio nella sezione sui form, dove abbiamo usato dei blocchi @if per condizionare la presenza dei messaggi d'errore.
Vediamo, dunque, nel dettaglio questi costrutti che sostituiscono con diverse ottimizzazioni le direttive strutturali.
@if in Angular.
Con @if si introduce un blocco condizionale che verrà mostrato se la condizione espressa è veritiera:
@if (error) {
<p class="error">Something's wrong.</p>
}
È possibile assegnare un alias all'espressione usata come condizione, ad esempio per estrarne il valore trasformato da una pipe:
@if (user$ | async; as user) {
<p>Name: {{user.name}}</p>
<p>Surname: {{user.surname}}</p>
}
Qui user$ è una proprietà del compontente di tipo Observable<{ name: string; surname: string }> o Promise<{ name: string; surname: string }>; la variabile user rappresenta il valore corrispondente estratto dalla AsyncPipe.
Il blocco @if può essere associato ad un blocco @else:
@if (loading) {
<p class="loader">Loading...</p>
} @else {
<section class="content">
Here's your content.
</section>
}
o ad un numero illimitato di blocchi @else if seguiti da un eventuale blocco @else finale:
@if (status === 'loading') {
<p class="loader">Loading...</p>
} @else if (status === 'error') {
<p class="error">Something went wrong.</p>
} @else {
<section class="content">
Here's your content.
</section>
}
@switch in Angular.
Nei casi in cui si debba usare un numero multiplo di @else if sulla stessa variabile, è preferibile utilizzare il blocco @switch, la cui sintassi è simile allo switch statement in linguaggio JavaScript:
@switch (status) {
@case ('loading') {
<p class="loading">Loading...</p>
}
@case ('error') {
<p class="error">Error!</p>
}
@case ('success') {
<section class="content">
Here's your content.
</section>
}
@default {
<p class="bug">State management is bugged.</p>
}
}
@for in Angular.
Come @if e @switch, anche @for è fortemente ispirato alla sintassi JavaScript for-of:
@for (num of [1, 2, 3]; track num) {
<p>{{num}}</p>
}
Accanto alla canonica formula variable_name of array abbiamo la keyword track seguita dall'espressione usata per distinguere gli elementi dell'array; nell'esempio usiamo il numero stesso, ma possiamo anche usare un id se stiamo ciclando su un array di oggetti:
@for (user of users$ | async; track user.id) {
<p>{{user.name}}</p>
}
In casi semplici in cui l'ordinamento dell'array è garantito, è possibile anche usare track $index, dove $index è una variabile di contesto spendibile all'interno del blocco:
@for (num of [1, 2, 3]; track $index) {
<p>{{num}} i {{$index}}</p>
}
Altre variabili di contesto disponibili sono:
$count: number: numero di elementi iterati in una collezione,
$index: number: indice dell'elemento attuale a partire da 0,
$first: boolean: indica se l'elemento attuale è il primo,
$last: boolean: indica se l'elemento attuale è l'ultimo,
$even: boolean: indica se l'indice dell'elemento attuale è pari,
$odd: boolean: indica se l'indice dell'elemento attuale è dispari.
È possibile assegnare un alias a queste variabili usando let:
@for (item of items; track item.id; let idx = $index) {
<p>#{{idx}} - {{item.name}}</p>
}
Un blocco @for può essere seguito da un blocco @empty il cui contenuto viene esposto nel caso in cui non vi siano elementi presenti, un po' come se fosse un @else rispetto ad un if-statement:
@for (item of items; track item.id) {
<li>{{item.name}}</li>
} @empty {
<li>There are no items.</li>
}
3.11
Deferrable views in Angular
Abbiamo già abbondantemente elencato le ragioni e i vantaggi dietro alle moderne web app e alla navigazione frontend. Tuttavia, poiché una caratteristica delle SPA è che tutte le viste e la loro logica fanno parte del pacchetto servito come pagina web al browser; questo, in genere, comporta maggiori tempi per il caricamento iniziale rispetto ad una web app classica, con le viste renderizzate una ad una lato server.
Le deferrable views (viste differibili), o più semplicemente blocchi @defer, sono uno strumento di Angular che rende possibile differire il caricamento di dipendenze come componenti, pipes o direttive, in modo che non pesino sul caricamento iniziale della nostra web app.
In pratica, con questo strumento, Angular ci consente di rimandare il caricamento di elementi non necessari immediatamente, eventualmente mostrando dei segnaposti durante il caricamento.
Un blocco @defer si dichiara in un template HTML esattamente allo stesso modo dei blocchi di control flow già visti, e può essere associato ad altri sotto-blocchi per regolarne il comportamento.
Un esempio basilare di vista differibile ha questo aspetto:
@defer {
<app-heavy-component />
}
In questo esempio, il componente app-heavy-component viene caricato appena il browser va in stato idle, in seguito al caricamento iniziale dell'app. Come vedremo a breve, è possibile impostare diversi criteri e condizioni per il differimento.
Se lasciamo tutto così, in attesa che il caricamento del componente sia ultimato, Angular non mostrerà nulla; va da sé che questa non è la UX più desiderabile. Possiamo fare in modo che Angular rappresenti sin da subito un segnaposto, e poi lo sostituisca con la vista differita quando questa è pronta:
@defer {
<app-heavy-component />
} @placeholder {
<p>Placeholder content</p>
}
Generalmente, ogni blocco @defer è associato a un sottoblocco @placeholder.
Per evitare sfarfallii dovuti al repentino cambio di schermata, è possibile anche introdurre un parametro minimum, che rappresenta il tempo minimo dopo il quale il blocco @placeholder può essere sostituito dal contenuto effettivo:
@defer {
<app-heavy-component />
} @placeholder (minimum 600ms) {
<p>Placeholder content</p>
}
In questo caso, il placeholder verrà mostrato per un minimo di 600 millisecondi; questo tempo può anche essere espresso in secondi (in questo caso: 0.6s).
Come abbiamo anticipato, il differimento della vista è soggetto a criteri e condizioni. Questo significa che potrebbe passare un tempo significativo tra il caricamento iniziale della web app e l'inizio del caricamento del blocco @defer.
Per questo, oltre a @placeholder, è possibile specificare anche un blocco @loading, specificamente dedicato all'effettivo caricamento della vista. Oltre a minimum, @loading ha un parametro after per regolare dopo quanto tempo si ritiene necessario dare all'utente un riscontro del caricamento in corso:
@defer {
<app-heavy-component />
} @loading (after 100ms; minimum 1s) {
<app-spinner />
}
Solitamente, come in questo esempio, il blocco @loading viene usato per mostrare uno spinner o qualcosa di analogo. Poiché il blocco @defer introduce un caricamento secondario, esiste anche l'eventualità che questo non vada a buon fine; per questo, un altro sottoblocco importante è @error, che ci consente di dare un riscontro all'utente nel caso in cui il caricamento del blocco @defer fallisca:
@defer {
<app-heavy-component />
} @error {
<p>Failed to load</p>
}
Vediamo, infine, come possiamo modificare l'innesco del caricamento di un blocco @defer.
Abbiamo detto che di default una deferrable view viene caricata nel momento in cui il browser termina il caricamento iniziale della web app; questo trigger è chiamato on idle.
Oltre a on idle, è possibile selezionare uno dei seguenti trigger:
on immediate: il caricamento parte non appena la web app viene caricata, cioè prima ancora di on idle;
on timer: il caricamento parte dopo un tempo prefissato espresso in millisecondi o secondi, ad esempio: @defer (on timer(100ms));
on viewport: il caricamento viene innescato dall'ingresso del blocco nella parte visibile della finestra del browser; questo trigger è molto utile nel caso in cui abbiamo una pagina molto lunga e vogliamo caricare i componenti man mano che l'utente scorre la pagina;
on interaction: il caricamento è innescato dall'interazione dell'utente con il placeholder, oppure opzionalmente con un altro elemento del template, referenziato tramite una template variable: <button #trigger>Load</button> ... @defer (on interaction(trigger));
on hover: il caricamento è innescato dal passaggio del mouse sul placeholder, o anche qui, su un altro elemento trigger, esattamente come per on interaction.
La combinazione di diversi trigger è supportata; ad esempio, @defer (on viewport; on timer(5s)) consente di attendere l'ingresso del componente nella finestra oppure 5 secondi. Il primo trigger che si verifica, innesca il caricamento.
Oltre ai trigger, un blocco @defer può essere condizionato da un'espressione, al pari di un blocco @if, usando la parola chiave when al posto di on. In questo caso, la condizione si comporta come un trigger nel momento in cui passa da essere falsy a truthy; il caricamento non viene annullato o riportato al placeholder se questa torna ad essere falsy.
Anche le when conditions possono essere combinate insieme ai normali trigger e l'operazione booleana tra espressioni e trigger è sempre un oppure logico, vale a dire che la prima condizione che si verifica causa il caricamento.
Prima di proseguire, vediamo un'ultima ottimizzazione che è possibile operare sulle deferrable views: il prefetching. Questa operazione consiste essenzialmente nel caricare in background un defer block senza mostrarne il contenuto fino a quando non scatta un trigger effettivo.
Anche il prefetching è soggetto a trigger e condizioni con le stesse dinamiche già viste:
@defer (on interaction(trigger); prefetch on idle) {
<app-heavy-component />
}
In questo esempio, il nostro app-heavy-component viene renderizzato solo quando l'utente interagisce con il trigger, ma in realtà nel momento in cui viene renderizzato è già stato scaricato dalla rete, poiché il prefetching è stato innescato on idle.
3.12
Image optimization in Angular
Con le deferrable views, abbiamo visto come ottimizzare i tempi iniziali di caricamento, riducendo le dimensioni del bundle scaricato inizialmente, e caricando i componenti secondari in un secondo momento.
Con questo tipo di ragionamento, il parametro che si va ad ottimizzare è il tempo di caricamento del minimo indispensabile affinché l'utente possa iniziare a interagire con l'app; questo parametro è anche chiamato First contentful paint (FCP, che potremmo tradurre un po' infedelmente come prima presentazione di contenuto).
Un altro parametro importante che si misura solitamente è il Largest contentful paint (LCP, cioè la più grande presentazione di contenuto), che va a misurare il tempo impiegato per mostrare completamente il più grande blocco di contenuto della pagina.
Chiaramente, i tempi di caricamento di un'immagine sono più lunghi di quelli di un testo, a parità di spazio occupato; per questa ragione, nel tempo si è sviluppata tutta una serie di pratiche per ottimizzare il caricamento delle immagini.
Ora, dato che uno dei propositi (nonché un grande punto di forza) di Angular è la completezza, non poteva mancare un'API del framework per affrontare la questione. Questa API è implementata attraverso la direttiva NgOptimizedImage, esportata dal modulo @angular/common; in questa sezione vediamo come usarla.
Trattandosi di una normalissima direttiva attributo, la base del suo funzionamento consiste nell'includerla tra gli imports del componente e attivarla usando l'attributo ngSrc al posto di src su un tag img:
<img ngSrc="https://picsum.photos/200/300">
In questo esempio usiamo Lorem Picsum come libreria di immagini a titolo esemplificativo. Come vedremo, alcune API sono specificamente dedicate a servizi CDN che si occupano più specificamente di image optimization.
Se proviamo a lanciare la web app, noteremo che in console compare un errore: questo ci dice che dobbiamo necessariamente impostare gli attributi height e width, oppure usare fill. Questo obbligo serve ad aiutare la direttiva a riservare correttamente lo spazio per l'immagine durante il caricamento, per evitare stravolgimenti del layout quando l'utente ha già iniziato a interagire con l'interfaccia grafica.
Quindi il nostro esempio diventa:
<img ngSrc="https://picsum.photos/200/300" height="200" width="300">
Per quanto riguarda, invece, l'uso di fill, questo attributo serve nel caso in cui vogliamo che le dimensioni dell'immagine siano ricavate dal suo contenitore, che dovrà necessariamente avere una posizione CSS uguale a relative, fixed o absolute.
Sarà possibile decidere come collocare l'immagine all'interno dello spazio disponibile stilizzando il tag img con la proprietà CSS object-fit impostata su contain o cover.-
L'uso di fill ci consente di utilizzare di fare image optimization anche laddove l'immagine ha semplicemente lo scopo di fare da sfondo a un elemento.
Per ottimizzare il tempo di caricamento dell'immagine più grande, e dunque migliorare il parametro LCP, possiamo usare l'attributo priority sull'immagine o sulle immagini più grandi.
<img ngSrc="https://picsum.photos/200/300" width="200" height="300" priority>
A questo punto, Angular fornirà un warning, suggerendoci di aggiungere il seguente tag nella <head> di index.html:
<link rel="preconnect" href="https://picsum.photos">
Questo è dovuto al fatto che parte delle logiche applicate da NgOptimizedImage per migliorare l'esperienza di caricamento delle immagini dipende dal concetto di loader, cioè una funzione che modifica l'url dell'immagine con parametri in grado di adattare la sua dimensione o risoluzione.
Il concetto di loader è a sua volta legato al funzionamento delle content delivery networks (CDN) che si occupano di hostare immagini e prevedono parametri con i quali manipolare l'immagine in modo da adeguarne la dimensione e la qualità all'esigenza effettiva.
Angular fornisce dei loader built-in per alcuni servizi CDN come Netlify o Cloudflare, ed è anche possibile fornire un custom loader per un altro servizio non supportato. Poiché NgOptimizedImage assume che tutte le immagini provengano da una singola CDN, è necessario implementare un custom loader nel caso in cui la nostra web app ne usi più di una.
Ebbene, tornando al preconnect, il warning di prima deriva proprio dal fatto che Angular non ha riconosciuto alcun loader per https://picsum.photos. In questa guida non ci occuperemo di interagire con alcun servizio CDN in particolare, ma vedremo comunque alcune ottimizzazioni supportate da NgOptimizedImage e basate sull'uso delle CDN e dei loader.
Una di queste è l'attributo placeholder, che scaricherà una seconda immagine a risoluzione più bassa, da mostrare mentre quella più grande viene caricata:
<img ngSrc="https://picsum.photos/200/300" width="200" height="300" placeholder>
Come placeholder è anche possibile usare un data-URL in base64 contenente l'immagine segnaposto:
<img ngSrc="https://picsum.photos/200/300" width="200" height="300" placeholder="data:image/png;base64,<base64_string>">
Fin qui abbiamo visto le API di ottimizzazione più comuni ed espresso i concetti principali legati alla direttiva NgOptimizedImage; volendo approfondire ulteriormente, dovremmo addentrarci nei seguenti argomenti:
dimensioni dell'immagine dinamiche in base al dispositivo (attraverso l'impostazione degli attributi srcset e sizes),
implementazione di loader custom.
Tuttavia, questi argomenti sono densi di tecnicismi legati alla responsiveness dei contenuti e all'interazione con le CDN, ed esulano dallo scopo di questa guida.
3.13
Standalone components in Angular
Nelle precedenti sezioni abbiamo scandagliato i concetti fondamentali di Angular e li abbiamo accompagnati con esempi di codice. In questi esempi abbiamo sempre usato l'attributo imports passato al decoratore @Component per importare componenti, pipes ed altri elementi fondamentali da usare nel template. Tra le cose importabili abbiamo anche visto gli ng-modules, dei contenitori di dipendenze che possono essere importate in blocco.
Un'altra cosa presente in tutti gli esempi di component class visti, è l'attributo standalone: true passato al decoratore @Component; in questa sezione esauriremo quello che c'è da dire sugli ng-modules e vedremo come il team di Angular sta lavorando per alleggerire l'architettura generale del framework frontend.
Originariamente, un'app Angular era costituita da un NgModule radice, che dichiarava AppComponent e tutti gli altri componenti utilizzati e non provenienti da altri ng-modules. Quando le cose stavano così, la vita dei componenti era strettamente legata a quella dei loro ng-modules di appartenenza; questo approccio è chiaramente più rigido poiché obbliga intere famiglie di componenti a condividere contesto e dipendenze:
// sample NgModule
@NgModule({
imports: [CommonModule],
declarations: [AppComponent],
exports: [AppComponent]
})
export class AppModule {}
Per semplificare questa struttura, con la versione 14 di Angular sono stati introdotti gli standalone components, definizione in realtà riduttiva poiché l'attributo standalone può essere applicato anche a direttive e pipes.
Uno standalone component (o directive, o pipe) non è altro che un componente che non ha bisogno di essere elencato tra le declarations di alcun NgModule.
Al momento Angular applica di default l'attributo standalone a tutti i componenti (o direttive, o pipes) creati usando ng generate; l’attributo standalone è considerato true come default. È, quindi, necessario specificare quando si vuole che un componente non lo sia.
In pratica, per come stanno le cose ad oggi, ha senso creare un NgModule solo se si vuole intenzionalmente raggruppare una serie di elementi, per facilitarne il consumo. La documentazione ufficiale di Angular, comunque, consiglia di utilizzare tutti componenti standalone.
4
Architetture in Angular
4.1
Gestione dello stato in Angular
Nel precedente blocco di questa guida abbiamo sostanzialmente esaurito tutto quello che c'è da sapere in teoria per lavorare con Angular: gli esempi erano tutti circoscritti in modo da illustrare, una ad una, tutte le caratteristiche del framework front end. A partire da questa sezione, daremo per assodata la conoscenza di tali caratteristiche e vedremo esempi più mirati ad affrontare tematiche ricorrenti nella pratica nella programmazione.
Per cominciare, vediamo qualche esempio concreto di state management (gestione dello stato), cioè di come organizzare nel concreto proprietà e metodi di un componente (o direttiva, o pipe) in modo tale da descrivere al meglio:
quali informazioni il componente tratta;
quali avvenimenti causano il mutamento di queste informazioni e come;
come gestire gli effetti dei passaggi di stato.
Prima di tutto, vediamo le basi dello state management per come questo si fa tradizionalmente in OOP; i principi di base sono:
una proprietà per ogni dato di un determinato tipo;
un metodo per ogni evento che può innescare un cambiamento di stato.
Abbiamo visto un esempio molto chiaro di questa impostazione realizzando il counter component o il counter service. Eppure, nel mondo reale le cose sono un po' più complicate di così: solitamente dobbiamo gestire dati asincroni, flag di errore, flag di caricamento, ecc..
Prima di proseguire, un'importante precisazione: tutto quello che vedremo in questo blocco della guida ha lo scopo di illustrare alcuni paradigmi che aiutano a gestire la complessità che un'applicazione può accumulare crescendo. È importante, tuttavia, notare che ogni paradigma porta con sé una quota di complessità per essere implementato, e dunque ha senso adottarlo solo se il costo del paradigma vale l'investimento.
Immaginiamo, dunque, di avere una nostra entità, che potrebbe rappresentare un record di una tabella a database oppure lo stato di un servizio del quale stiamo programmando un pannello di controllo, eccetera.
Qualunque sia l'entità a cui ci riferiamo, essa sarà probabilmente recuperata come un oggetto o array json attraverso una chiamata HTTP:
type JsonObject = Record<string, string | number | boolean | null>;
type Entity = JsonObject | JsonObject[];
A questo punto, dobbiamo scrivere un componente che lavori su questa entità, caricandola e poi inviando dei comandi al backend, che potrebbero avere come payload l'entità stessa, una sua parte oppure altre informazioni provenienti dall'utente.
Il nostro entity.component.ts appare così:
import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';
type JsonObject = Record<string, string | number | boolean>;
type Entity = JsonObject | JsonObject[];
const entityUrl = 'mock_url';
@Component({
selector: 'app-entity',
standalone: true,
imports: [JsonPipe],
templateUrl: './entity.component.html',
styleUrl: './entity.component.css'
})
export class EntityComponent implements OnInit {
http = inject(HttpClient);
entity: Entity | null = null;
ngOnInit(): void {
this.http.get<Entity>(entityUrl)
.subscribe(entity => { this.entity = entity });
}
command() {
this.entity = null;
this.http.post<Entity>(entityUrl, { /* command payload */ })
.subscribe(entity => { this.entity = entity });
}
}
Fin qui è tutto abbastanza lineare, e possiamo facilmente intuire come scrivere entity.component.html:
@if (entity) {
<pre>{{entity | json}}</pre>
<div class="actions">
<button (click)="command()">Command</button>
</div>
} @else {
<p>Loading...</p>
}
La logica che stiamo seguendo è: se la proprietà entity è null, allora dev'esserci un caricamento in corso. Questa è la ragione per cui la prima cosa che fa command() è svuotare entity.
Osserviamo anche che:
potremmo aggiungere tutti i commands che vogliamo;
potremmo astrarre http in un servizio;
entity potrebbe giungere al componente nella forma di un oggetto più complesso di un JsonObject, come una classe o altro.
Nessuna di queste cose cambierebbe sostanzialmente il nostro esempio, perciò proseguiamo.
Rappresentare lo stato di caricamento mediante l'assenza di un valore per entity è sintetico e funzionale, ma non è particolarmente descrittivo di quello che avviene realmente nel componente. Come programmatori informatici, dobbiamo pensare non solo a far funzionare le cose, ma anche a descrivere in modo chiaro le regole, i vincoli e i requisiti che le governano.
Perciò, una cosa che tipicamente va fatta è aggiungere una proprietà isLoading che funga da flag, e rappresentare il caricamento alzando e abbassando la flag:
entity: Entity | null = null;
isLoading: boolean = true;
ngOnInit(): void {
this.http.get<Entity>(entityUrl)
.subscribe(entity => {
this.isLoading = false;
this.entity = entity;
});
}
command() {
this.isLoading = true;
this.http.post<Entity>(entityUrl, { /* command payload */ })
.subscribe(entity => {
this.isLoading = false;
this.entity = entity;
});
}
A questo punto, cercando le occorrenze di isLoading sarà sempre chiaro quando il componente è in caricamento e quando questo caricamento è terminato; anche nella vista, la logica diventa più esplicita:
@if (isLoading) {
<p>Loading...</p>
} @else {
<pre>{{entity | json}}</pre>
<div class="actions">
<button (click)="command()">Command</button>
</div>
}
Un'altra cosa che può succedere è che i caricamenti falliscano; seguendo lo stesso approccio, possiamo aggiungere un'altra flag isError che funziona allo stesso modo:
entity: Entity | null = null;
isLoading: boolean = true;
isError: boolean = false;
ngOnInit(): void {
this.http.get<Entity>(entityUrl)
.subscribe({
next: entity => {
this.isLoading = false;
this.entity = entity;
},
error: () => {
this.isError = true;
this.entity = null;
}
});
}
command() {
this.isLoading = true;
this.http.post<Entity>(entityUrl, { /* command payload */ })
.subscribe({
next: entity => {
this.isLoading = false;
this.entity = entity;
},
error: () => {
this.isError = true;
this.entity = null;
}
});
}
Ora nella view possiamo considerare il nuovo stato di errore:
@if (isLoading) {
<p>Loading...</p>
} @else if (isError) {
<p class="error">Something went wrong!</p>
} @else {
<pre>{{entity | json}}</pre>
<div class="actions">
<button (click)="command()">Command</button>
</div>
}
A questo punto, sembra che abbiamo coperto tutti i casi correttamente. Eppure, se volessimo essere ambiziosi (ma neanche troppo), dovremmo ipotizzare che un utente voglia avere la possibilità di recuperare il controllo a partire da uno stato d'errore, per esempio ricaricando il componente:
@if (isLoading) {
<p>Loading...</p>
} @else if (isError) {
<p class="error">Something went wrong!</p>
<button (click)="ngOnInit()">Reload</button>
} @else {
<pre>{{entity | json}}</pre>
<div class="actions">
<button (click)="command()">Command</button>
</div>
}
Qui emerge una piccola falla nel nostro design iniziale: lo stato è inizializzato in costruzione anziché on init, perciò dobbiamo modificare ngOnInit per ribadire quale debba essere lo stato iniziale:
ngOnInit(): void {
this.isLoading = true;
this.isError = false;
this.entity = null;
this.http.get<Entity>(entityUrl)
.subscribe({
next: entity => {
this.isLoading = false;
this.entity = entity;
},
error: () => {
this.isError = true;
this.entity = null;
}
});
}
E, dunque, per evitare di ripeterci, ci conviene anche aggiustare il modo in cui inizializziamo lo stato:
entity!: Entity | null;
isLoading!: boolean;
isError!: boolean;
Che cosa significa quanto visto?
Ci siamo limitati a dichiarare le proprietà del nostro stato, e abbiamo silenziato gli avvisi di TypeScript con delle asserzioni di non-nullità. Volendo tradurre, abbiamo detto a TypeScript “non preoccuparti del fatto che queste proprietà siano tutte undefined in un primo momento, perché verranno comunque inizializzate e rispetteranno sempre il tipo annotato”.
A questo punto, possiamo solo finalizzare il nostro design OOP, delegando l'impostazione dello stato a dei metodi privati che ne garantiscano anche la consistenza in tutti i passaggi, eliminando anche le ripetute assegnazioni.
Il nostro entity.componnent.ts finale ha questo aspetto:
import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';
type JsonObject = Record<string, string | number | boolean>;
type Entity = JsonObject | JsonObject[];
const entityUrl = 'mock_url';
@Component({
selector: 'app-entity',
standalone: true,
imports: [JsonPipe],
templateUrl: './entity.component.html',
styleUrl: './entity.component.css'
})
export class EntityComponent implements OnInit {
http = inject(HttpClient);
entity!: Entity | null;
isLoading!: boolean;
isError!: boolean;
ngOnInit(): void {
this.#loading();
this.http.get<Entity>(entityUrl)
.subscribe({
next: entity => this.#update(entity),
error: () => this.#error()
});
}
command() {
this.#loading();
this.http.post<Entity>(entityUrl, { /* command payload */ })
.subscribe({
next: entity => this.#update(entity),
error: () => this.#error()
});
}
#loading(): void {
this.entity = null;
this.isLoading = true;
this.isError = false;
}
#update(entity: Entity): void {
this.entity = entity;
this.isLoading = false;
this.isError = false;
}
#error(): void {
this.entity = null;
this.isLoading = false;
this.isError = true;
}
}
Questo design è piuttosto ottimale perchè:
dichiariamo le proprietà che compongono lo stato una volta sola,
fissiamo tutte le combinazioni sensate del nostro stato con dei metodi privati,
usiamo dei metodi pubblici per innescare le operazioni e deleghiamo ai metodi privati l'impostazione corretta dello stato, evitando stati inconsitenti e ripetizioni.
Eppure, se osserviamo entity.component.html, possiamo notare che c'è ancora un problema, non esattamente da poco: stiamo facendo affidamento su dei vincoli che non sono formalizzati realmente da nessuna parte, se non nei metodi privati della component class. Chi ci garantisce che non esista uno stato in cui isError e isLoading siano false, ma entity sia comunque null e, dunque, non ci sia nulla da mostrare nel blocco @else finale?
Dal momento che siamo in un'architettura OOP, dobbiamo presumere che una classe si prenda cura dei suoi stati possibili, perciò dovremmo correttamente incapsulare le proprietà dello stato in modo che siano modificabili solo dall'interno della classe, ma questo ci complicherebbe la vita senza risolvere realmente il problema: noi vogliamo esprimere una combinazione esaustiva di tutti e soli gli stati possibili.
Vediamo, allora, come riprogettare il nostro entity component, usando i seguenti concetti:
una tagged union o discriminated union, cioè un tipo di tipo che è possibile dichiarare in TypeScript;
la macchina a stati finiti, cioè un modello matematico che descrive un sistema che può assumere un numero finito di condizioni con proprietà peculiari.
Cominciamo con il formalizzare tutte le condizioni che il nostro stato può assumere:
type State =
| { type: 'loading' | 'error' }
| { type: 'update', entity: Entity };
Poiché gli stati di loading e error sono mutualmente esclusivi, possiamo collassare più flag in un unico tag, cioè la proprietà type; in più, possiamo taggare lo stato che rappresenta l'aggiornamento della entity come update, e attribuirvi un payload.
Notiamo come questo renda incredibilmente più facile andare ad aggiungere un payload anche sulle altre condizioni dello stato: potremmo aggiungere un progress: number su loading e un message su error senza realmente aggiungere complessità alla gestione dello stato sul componente e, soprattutto, senza doverci preoccupare dei possibili valori null di proprietà che esistono solo in alcune condizioni.
Ora che abbiamo ridefinito il nostro stato, ci basterà dichiararlo così nel componente:
state!: State;
E, poiché abbiamo già una descrizione esaustiva e vincolante dei nostri stati possibili, possiamo disfarci dei nostri metodi privati e riscrivere il nostro componente così:
import { JsonPipe } from '@angular/common';
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';
type JsonObject = Record<string, string | number | boolean>;
type Entity = JsonObject;
type State =
| { type: 'loading' | 'error' }
| { type: 'update', entity: Entity };
const entityUrl = 'mock_url';
@Component({
selector: 'app-entity',
standalone: true,
imports: [JsonPipe],
templateUrl: './entity.component.html',
styleUrl: './entity.component.css'
})
export class EntityComponent implements OnInit {
http = inject(HttpClient);
state!: State;
ngOnInit(): void {
this.state = { type: 'loading' };
this.http.get<Entity>(entityUrl)
.subscribe({
next: entity => { this.state = { type: 'update', entity } },
error: () => { this.state = { type: 'error' } }
});
}
command() {
this.state = { type: 'loading' };
this.http.post<Entity>(entityUrl, { /* command 1 payload */ })
.subscribe({
next: entity => { this.state = { type: 'update', entity } },
error: () => { this.state = { type: 'error' } }
});
}
}
Questo approccio ha una serie di vantaggi:
potremmo comunque avere dei metodi a cui delegare l'impostazione dello stato, ma anche se non lo facciamo, TypeScript ci impedirà di usare entity in uno stato che non sia type: 'update'
il nostro stato è sempre discriminato da un'unica proprietà, e dunque è anche più facile capire in che condizione ci troviamo senza rischiare di perdersi qualcosa.
Quest'ultimo punto ci torna particolarmente utile per riscrivere entity.component.html:
@switch (state.type) {
@case ('loading') {
<p>Loading...</p>
}
@case ('error') {
<p class="error">Something went wrong!</p>
<button (click)="ngOnInit()">Reload</button>
}
@case ('update') {
<pre>{{state.entity | json}}</pre>
<div class="actions">
<button (click)="command()">Command 1</button>
</div>
}
}
Ora sì che abbiamo una copertura esaustiva dei casi e nessuna possibilità d'errore: al di fuori del caso update, la proprietà entity non esiste e, dunque, non corriamo il rischio di leggere un valore nullo!
4.2
Data bindings (input e output) in Angular
Generalmente, la ragione per cui un componente dichiara altri componenti nel suo template può essere riassunta nel concetto di delega: allo stesso modo in cui una funzione chiama altre funzioni per delegare operazioni di più basso livello, lo stesso avviene nella UI. Il meccanismo della delega e quello della composizione vanno a braccetto in programmazione informatica e ci consentono di gestire la complessità di un progetto riusando componenti oppure separandoli per sfere di competenza.-
Parlando di input e output properties abbiamo coperto l'API di comunicazione tra componenti padri e figli nel template; in questa sezione andremo a vedere come strutturare al meglio questa comunicazione prendendo ad esempio una classe di casi molto comuni.
Rivediamo brevemente la template syntax per quanto riguarda data e event binding:
<child-component [inputProperty]="data" (outputProperty)="action()" />
L'uso tipico di una input property consiste nel passare dati a un componente dedicato alla loro presentazione; quello di una output property nell'agganciare della logica ad un'azione dell'utente. In questo modo rendiamo delegabili le due funzioni di una UI: presentazione e interazione.
Un paradigma comune in Angular (ma anche in altri framework) prevede la distinzione dei componenti tra due categorie:
da un lato, i componenti che si occupano di gestire la logica dello stato e la reazione agli eventi;
dall'altro, i componenti che si focalizzano sulla rappresentazione dei dati e sul notificare l'interazione dell'utente.
Questa dicotomia passa sotto molti nomi diversi:
una nomenclatura che va per la maggiore chiama i primi smart component (componenti intelligenti) e i secondi dumb component (componenti stupidi); questa nomenclatura non è particolarmente riconoscente nei confronti dei componenti che si occupano dell'aspetto grafico ed estetico e della corretta esposizione degli eventi che l'utente scatena;
un'altra nomenclatura, che possiamo prendere in prestito da architetture più complesse, vede una distinzione tra controller e view, rendendo maggiore giustizia alle diverse responsabilità.
In generale, non è necessario né desiderabile avere una segregazione categorica dei componenti a prescindere; dopotutto, se li usiamo bene, la component class e i template sono a tutti gli effetti divisi tra controller e view, perciò questo design pattern è già implementato dentro Angular.
Qui si vuole esemplificare un modo per affrontare quei casi in cui la complessità di un componente rende necessaria la sua suddivisione; allora e solo allora ha senso spezzare il componente a sua volta in due secondo un criterio analogo a quello adottato da Angular per organizzare internamente ogni singolo componente.
Prendiamo ad esempio una situazione in cui un componente debba:
scaricare dei dati dal server;
mostrarli all'utente fornendo la possibilità di modificarli;
consentire all'utente di salvare i dati modificati inviandoli al server;
consentire all'utente di annullare le modifiche.
Il tutto sempre indicando correttamente lo stato dei caricamenti e mostrando con template diversi i dati in sola lettura e quelli in modifica.
Nulla ci vieterebbe di implementare tutto questo in un solo componente; il problema è che avremmo sostanzialmente due fronti di interazione (da un lato il backend, dall'altro la UI) che determinano due stati parzialmente interdipendenti. Sarebbe sufficiente un minimo incremento di complessità (per esempio la necessità di riusare il form in un altro punto dell'applicazione) per richiedere la ripetizione di molto codice oppure una modifica sostanziale dell'architettura del nostro componente.
Dunque, possiamo semplificarci un po' la vita pensando ai due aspetti separatamente: da un lato il recupero e l'invio dei dati, con le rispettive logiche di caricamento, ricarica, eccetera; dall'altro presentazione, modifica, validazione e infine conferma dei dati da parte dell'utente.
Cominciamo col definire una semplicissima entità di lavoro, che farà da data model al nostro esempio, e la mettiamo in un file a sé stante, ad esempio entity.ts:
export type Entity = {
id: string;
label: string;
};
Qualunque situazione reale vedrebbe molte più proprietà coinvolte, ma noi possiamo accontentarci di questo.
A questo punto ipotizziamo di aver già recuperato i dati e pensiamo esclusivamente a come vogliamo rappresentarli e consentirne la modifica; nel farlo, costruiremo un'API per delegare gli aspetti che non vogliamo considerare all'interno del nostro DumbComponent.
In dumb.component.ts:
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import { JsonPipe } from '@angular/common';
import { Entity } from '../domain';
type EntityForm = {
id: FormControl<string | null>;
label: FormControl<string | null>;
};
type State =
| { mode: 'view' }
| {
mode: 'edit',
init: Entity,
form: FormGroup<EntityForm>
};
@Component({
selector: 'app-dumb',
standalone: true,
imports: [ReactiveFormsModule, JsonPipe],
templateUrl: './dumb.component.html',
styleUrl: './dumb.component.css'
})
export class DumbComponent {
state: State = { mode: 'view' };
@Input({ required: true }) entity!: Entity;
@Output() entityChange = new EventEmitter<Entity>();
@Output() save = new EventEmitter<Entity>();
edit(): void {
this.state = {
mode: 'edit',
init: this.entity,
form: new FormGroup({
id: new FormControl(this.entity.id),
label: new FormControl(this.entity.label, [Validators.required])
})
};
}
raiseUpdate(form: FormGroup<EntityForm>): void {
this.entityChange.next(form.value as Entity);
}
raiseSave(form: FormGroup<EntityForm>): void {
this.save.next(form.value as Entity);
}
cancelForm(entity: Entity): void {
this.entityChange.next(entity);
this.state = { mode: 'view' };
}
}
Qui succedono un sacco di cose! Vediamole nel dettaglio.
Per cominciare, dichiariamo il model del form che useremo per consentire la modifica; dichiariamo anche lo stato, che ha 2 casi, uno per la lettura e uno per la modifica dei dati. Il caso edit porta anche un riferimento all'oggetto iniziale usato per costruire il form, e il form stesso, dotato di tutti i validatori e le logiche interne necessarie.
In una situazione concreta emergerebbe tutta la complessità che certi form comportano e, dunque, la necessità di delegare le logiche di interazione con l'app (es. routing) e con il back end (http).
Le input e output property costituiscono l'API per il componente padre; il nostro componente accetta l'entità da mostrare in input ed espone in output due eventi, che in Angular sono implementati dalla classe EventEmitter.
Mentre il funzionamento delle input property risulta sostanzialmente chiaro a prima vista, l'uso di EventEmitter è strettamente legato a RxJS ed è fondato sull'uso del metodo next per chiamare la funzione che è in ascolto sull'evento dal template del componente padre. Internamente, EventEmitter funziona come un Observable e la sintassi di event binding indica ad Angular di chiamare subscribe su quell'Observable, ma anche di chiudere la subscription quando il componente non è più attivo nel template, evitando memory leak.
Riguardo all'evento entityChange, qui lo abbiamo implementato per abilitare il two-way binding sulla proprietà entity, come vedremo a breve; questo crea una parziale sovrapposizione tra l'uso di entityChange e quello di save, poiché entrambi gli eventi forniscono il valore aggiornato di entity, ma secondo criteri diversi:
nel caso di entityChange abbiamo un semplice tracker in tempo reale delle modifiche che l'utente apporta al form; questo permetterebbe al componente padre di implementare logiche custom indipendenti dalla validazione e dalla sottoscrizione del form;
nel caso di save abbiamo il model in uscita dal form, validato e sottoscritto da un'azione esplicita dell'utente.
All'atto pratico, in questo esempio entityChange assolve a uno scopo puramente dimostrativo.
Un'ultima nota sull'evento save: potremmo essere tentati di dare ai nostri eventi nomi semplici e comuni come click o submit, ma questa pratica è tendenzialmente da evitare. Il funzionamento degli eventi in Angular potrebbe portare a dei conflitti di nomi tra i nostri eventi e quelli standard degli elementi HTML. Questo è anche il motivo per cui usiamo ngSubmit invece di submit, come avevamo anticipato parlando dei forms.
Passando al template, il nostro dumb.component.html avrà questo aspetto:
@switch (state.mode) {
@case ('view') {
<pre>{{entity | json}}</pre>
<button type="button" (click)="edit()">Edit</button>
}
@case ('edit') {
<form
[formGroup]="state.form"
(input)="raiseUpdate(state.form)"
(ngSubmit)="raiseSave(state.form)"
>
<input
type="hidden"
formControlName="id"
>
<input
type="text"
formControlName="label"
placeholder="Insert label"
>
<div class="actions">
<button
type="button"
(click)="cancelForm(state.init)"
>
Cancel
</button>
<button
type="submit"
[disabled]="state.form.invalid"
>
Save
</button>
</div>
</form>
}
}
Anche qui ci sono un po' di cose da spiegare.
Tanto per cominciare, abbiamo il canonico switch su state.mode, grazie al quale possiamo differenziare la presentazione della nostra entità ma anche e soprattutto distinguere tra i due diversi tipi di stato.
Questo ci porta alla seconda osservazione: i metodi chiamati nel caso edit non lavorano direttamente con l'istanza this.state del componente, ma si fanno passare state.form e state.init dal template in modo tale da non dover distinguere due volte tra i casi di state.mode e, dunque, lavorare in modo perfettamente type safe.
Una nota sulla nomenclatura: abbiamo adottato la convenzione smart/dumb esclusivamente per la semplicità dei termini; in contesti reali, il giusto nome per un componente è sempre quello più descrittivo delle sue responsabilità e non quello del ruolo che riveste nell'architettura; le architetture presentano convenzioni utili per generalizzare i problemi, ma il nostro codice operativo dovrebbe sempre rimanere ben ancorato al dominio della nostra applicazione.
A questo punto, siamo pronti per vedere una possibile implementazione del componente padre, e incominciamo questa volta dal template, così da illustrare immediatamente come vengono effettuati i binding.
In smart.component.html troviamo:
@switch (state.type) {
@case ('loading') {
<p>Loading...</p>
}
@case ('success') {
<div>
<p>Form</p>
<app-dumb [(entity)]="state.entity" (save)="save($event)" />
</div>
<div>
<p>Entity</p>
<pre>{{ state.entity | json }}</pre>
<button type="button" (click)="ngOnInit()">Refresh</button>
</div>
}
}
Da qui possiamo immediatamente dedurre che il nostro smart component gestirà due casi: un caricamento e un successo. Nel caso di successo vediamo app-dumb con un binding a doppia via sulla proprietà entity; questo include implicitamente un event binding del tipo: (entityChange)="entity = $event" che potremo riscontrare nel tag pre poco sotto. Vediamo anche il nostro evento save legato a un metodo onSave sul componente.
Prima di proseguire, parliamo un attimo della variabile $event, che abbiamo già incontrato in altri esempi in passato: questa corrisponde esattamente al parametro passato al metodo EventEmitter.next e il suo nome è fisso e convenzionalmente assegnato da Angular.
Ora possiamo passare a smart.component.ts:
import { Component, OnInit } from '@angular/core';
import { DumbComponent } from '../dumb/dumb.component';
import { Entity } from '../domain';
import { map, Observable, timer } from 'rxjs';
import { JsonPipe } from '@angular/common';
type State =
| { type: 'loading' }
| { type: 'success'; entity: Entity };
const mockGet = (): Observable<Entity> => (
timer(1000).pipe(map(() => ({ id: 'mock_id', label: 'Some label' })))
);
const mockPost = (entity: Entity): Observable<Entity> => (
timer(1000).pipe(map(() => entity))
);
@Component({
selector: 'app-smart',
standalone: true,
imports: [DumbComponent, JsonPipe],
templateUrl: './smart.component.html',
styleUrl: './smart.component.css'
})
export class SmartComponent implements OnInit {
state!: State;
ngOnInit(): void {
this.state = { type: 'loading' };
mockGet()
.subscribe(entity => this.update(entity));
}
save(entity: Entity): void {
this.state = { type: 'loading' };
mockPost(entity)
.subscribe(entity => this.update(entity));
}
update(entity: Entity): void {
this.state = { type: 'success', entity };
}
}
Qui troviamo relativamente tutte cose già viste: uno stato rappresentato come tagged union, con i due casi che ci aspettavamo dal template, e i metodi che si occupano di effettuare operazioni verso un back end “fantoccio” (per esemplificare) che risponde dopo 1 secondo.
4.3
Dependency injection in Angular
Precedentemente, parlando dei servizi, abbiamo introdotto il decoratore @Injectable e il concetto di dependency injection. In questa sezione vedremo come funziona esattamente questo paradigma architetturale che discende dal principio di IOC (inversion of control, inversione del controllo).
Iniziamo spiegando l'esigenza che sta dietro a questo modo di organizzare un framework.
Normalmente in OOP si raggruppano dati e funzionalità in classi, e queste vengono utilizzate come dei moduli dinamici (cioè istanziati durante l'esecuzione) che possono essere dotate di uno stato. In un contesto architetturale di questo tipo, esistono due modi per combinare le funzionalità di più classi e dunque gestire la complessità crescente dell'applicazione: ereditarietà e composizione.
Ora, tutti conosciamo bene l'ereditarietà, una funzionalità formalizzata nel linguaggio attraverso la keyword extends, che importa automaticamente tutti gli attributi della classe padre a bordo dell'istanza della classe. Purtroppo, però, l'ereditarietà presenta non poche problematiche ed è decisamente poco versatile, soprattutto nel momento in cui dobbiamo combinare classi che hanno strutture e competenze diverse.
La composizione non è altrettanto esplicitata nel codice, ma avviente semplicemente nel momento in cui una classe possiede una proprietà che è un'istanza di un'altra classe e ne chiama i metodi per delegare implementazioni di funzionalità che esulano dalle sue competenze.
Quando una classe A si avvale di un'altra classe B in tal senso, possiamo dire che B è una dipendenza di A.
Usare la composizione al posto dell'ereditarietà presenta parecchi vantaggi; eccone alcuni:
possiamo comporre un numero indefinito di classi, mentre possiamo ereditarne solo una;
la composizione non richiede costrutti sintattici ad hoc;
possiamo comporre non solo classi con classi, ma con funzioni o semplici oggetti;
sarà sempre chiaro da quale classe stiamo importando proprietà e metodi, anziché fare riferimento a un generico this.
Se ci fermiamo a considerare la composizione così definita, stiamo parlando di qualcosa che ha questo aspetto:
class DependencyA {
someMethod() {}
}
class DependencyB {
someMethod() {}
}
class Consumer {
depA = new DependencyA();
depB = new DependencyB();
someCompositeMethod() {
this.depA.someMethod();
this.depB.someMethod();
}
}
Ad ogni modo, per come è messa in pratica in questo esempio, anche la composizione risulta piuttosto rigida: se ad esempio diverse classi avessero una dipendenza in comune, dovrebbero duplicarne l'istanza, e questo non è necessariamente quello che vogliamo; se, poi, una di queste classi dovesse richiedere un parametro al costruttore, ogni suo consumatore dovrebbe fornire quel parametro.
Per ovviare a questa inconvenienza Angular integra un sistema di iniezione di dipendenze (dependency injection, o DI). Questo significa semplicemente che le dipendenze vengono istanziate e fornite dall'esterno della classe, tramite il costruttore (oppure tramite l'utility inject).
Il sistema di DI fa le seguenti cose:
registra tutte le classi che ne fanno parte; nel caso di Angular, sostanzialmente tutte le classi decorate;
annota tramite una speciale funzionalità di TypeScript i parametri del costruttore e il loro tipo;
quando un componente, una direttiva o una pipe viene istanziata, risolve ricorsivamente tutta l'alberatura delle dipendenze.
Quindi, il nostro esempio precedente diventa:
@Injectable()
class DependencyA {
someMethod() {}
}
@Injectable()
class DependencyB {
someMethod() {}
}
// could also be @Directive or @Pipe
@Component({...})
class Consumer {
constructor(depA: DependencyA, depB: DependencyB) {}
someCompositeMethod() {
this.depA.someMethod();
this.depB.someMethod();
}
}
Essenzialmente, con questo sistema di DI è possibile implementare una modularità piena in stile OOP, preservando una gestione ottimale e centralizzata dell'uso dei costruttori e dell'istanziamento delle classi.
Grazie a questa flessibilità diventa anche più facile sostituire un servizio con un altro che implementa la stessa interfaccia, senza dover aggiustare il codice dei suoi consumatori; questa operazione è particolarmente utile nel testing.
Possiamo indicare ad Angular che cosa fornire esattamente quando viene richiesta una certa dipendenza attraverso i providers. Un provider non è altro che un oggetto di tipo Provider, e può essere fornito attraverso la proprietà providers dei vari decoratori di Angular.
Vediamo qualche esempio:
import { Component, OnInit } from '@angular/core';
class SomeService {
someMethod(): void { console.log('method called') }
}
class SomeOtherService {
someMethod(): void { console.log('other method called') }
}
@Component({
selector: 'app-root',
standalone: true,
imports: [],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
providers: [
// use a service, even without @Injectable
SomeService,
// use a different class with the same method
{ provide: SomeService, useClass: SomeOtherService },
// use a static value, such as a plain object
{ provide: SomeService, useValue: { someMethod() { console.log('value method called') } } },
// use a factory to create instance
{ provide: SomeService, useFactory: () => new SomeService() }
]
})
export class AppComponent implements OnInit {
// inject dependency
constructor(private srv: SomeService) {}
// consume dependency
ngOnInit(): void { this.srv.someMethod() }
}
In questo esempio vediamo gli usi più comuni dei providers; ovviamente, nella realtà andrà usato uno solo di questi, oppure la dipendenza verrà risolta direttamente se la classe SomeService è decorata con @Injectable({ providedIn: 'root' }).
A partire dal concetto di provider possiamo farci un'idea ancora più chiara su come funziona il meccanismo della DI:
la DI è sostanzialmente una mappa tra un token che identifica un tipo (in questo caso un costruttore) e un qualche sistema per generare un valore (in questo caso un'istanza di classe o un semplice oggetto);
la proprietà provide va a definire la chiave che verrà usata dalla mappa per risolvere il valore da fornire;
le varie proprietà use- sono diversi modi per ottenere il valore da fornire.
In generale, qualunque espressione può essere usata come token di iniezione, ma in Angular vige la convenzione di usare direttamente il costruttore di una service class oppure un InjectionToken.
Un InjectionToken è una classe utility che possiamo associare a dipendenze che non sono istanze di una service class, ad esempio oggetti che rappresentano una configurazione da passare a un servizio.
Vediamo un esempio:
import { Component, InjectionToken, OnInit } from '@angular/core';
interface Config {
someProp: number;
someOtherProp: string;
}
const config: Config = {
someProp: 1,
someOtherProp: 'hello'
};
const Config = new InjectionToken<Config>('config');
@Component({
selector: 'app-root',
standalone: true,
imports: [],
templateUrl: './app.component.html',
styleUrl: './app.component.css',
providers: [{ provide: Config, useValue: config }]
})
export class AppComponent implements OnInit {
constructor(private config: Config) { }
ngOnInit(): void { console.log(this.config) }
}
In questo esempio abbiamo istanziato un InjectionToken<Config> e poi abbiamo fornito un provider che associa a Config l'oggetto config; in questo modo la classe che dichiara config: Config come dipendenza, riceverà il valore corretto e cioè la nostra configurazione.
È anche possibile semplificare questo meccanismo dichiarando direttamente sull'injection token la factory del nostro valore da fornire:
import { Component, InjectionToken, OnInit } from '@angular/core';
interface Config {
someProp: number;
someOtherProp: string;
}
const config: Config = {
someProp: 1,
someOtherProp: 'hello'
};
const Config = new InjectionToken<Config>('config', { factory: () => config });
@Component({
selector: 'app-root',
standalone: true,
imports: [],
templateUrl: './app.component.html',
styleUrl: './app.component.css'
})
export class AppComponent implements OnInit {
constructor(private config: Config) { }
ngOnInit(): void { console.log(this.config) }
}
In questo caso abbiamo passato al costruttore InjectionToken anche un oggetto con la factory che fornisce il nostro oggetto.
Prima di procedere, facciamo un paio di specificazioni su quello che si definisce contesto di iniezione. Angular tratta ogni @Component o @NgModule come un contesto per un certo set di dipendenze; questo significa due cose:
Angular tratta ogni ng-module e ogni component che dichiara dei providers come un contesto a sé stante, e possiamo sfruttare questa cosa per avere diversi contesti e cicli di vita per diverse dipendenze; se, ad esempio, dichiariamo una dipendenza in AppComponent, questa sarà condivisa da componenti, direttive e pipe attraverso l'intera applicazione, ma se un elenco di componenti dichiara ognuno le sue dipendenze, ogni componente lavorerà con la sua istanza individuale in contesti isolati;
Le dipendenze dichiarate da un componente o modulo sono condivise e disponibili attraverso l'intera alberatura sottostante, permettendoci di avere diverse istanze di una dipendenza (ad esempio un service) per diversi contesti; questo è specialmente utile se vogliamo condividere una serie di informazioni a partire da un certo componente o modulo giù per tutta la sua alberatura, in modo da evitare di dover propagare le input property nei template e nelle component class.
4.4
Lifecycle Hooks in Angular
Nel corso di questa guida abbiamo più volte menzionato e utilizzato l'interfaccia OnInit e il metodo ngOnInit ad essa associato. In questa sezione della guida approfondiremo i cosiddetti lifecycle hooks (letteralmente ganci del ciclo di vita o agganci al ciclo di vita) e vedremo esattamente a cosa servono e come usarli al meglio.
Se ci fermassimo al solo ngOnInit, avremmo una certa sovrapposizione di scopo tra questo metodo e il costruttore della classe componente (o direttiva, o pipe); in realtà l'unica cosa in cui questi due "metodi" si somigliano è il fatto di essere chiamati all'inizio del ciclo di vita, ma ci sono comunque delle importanti differenze tra i due rispetto al loro rapporto con il framework.
Tanto per cominciare, il costruttore viene eseguito una sola volta, mentre nulla ci vieta di chiamare ngOnInit da codice per reinizializzare lo stato del componente; inoltre, poiché Angular deve inizializzare le input property in base agli attributi popolati nel template del componente padre, il metodo ngOnInit ha anche l'importante funzione di fornire un entrypoint del componente completo di tutti gli input:
import { Component, Input } from '@angular/core';
@Component({
selector: 'app-input',
standalone: true,
template: 'Works'
})
export class InputComponent {
@Input() input!: string;
constructor() { console.log(this.input) } // undefined
ngOnInit() { console.log(this.input) } // hello
}
@Component({
selector: 'app-root',
standalone: true,
imports: [InputComponent],
template: '<app-input input="hello" />',
})
export class AppComponent {}
In questo esempio InputComponent logga in console prima undefined, poi hello; dunque, è meglio usare il costruttore solo per inizializzare campi con valori statici o per dichiarare le dipendenze del componente, oppure non usarlo affatto, dichiarando le proprietà in fase di dichiarazione e usando l'utility inject per ottenere le dipendenze.
Notiamo anche che in questo esempio il metodo ngOnInit è stato utilizzato senza implementare esplicitamente l'interfaccia OnInit; in realtà, poiché le interfacce esistono solo a livello TypeScript, dichiarare l'interfaccia associata ad ogni lifecycle hook è una buona pratica ma non un obbligo. Di seguito, faremo riferimento agli hook parlando dei metodi e non delle interfacce ad essi associate, che comunque seguono tutte la stessa nomenclatura.
Il gancio opposto rispetto a on init è ovviamente on destroy; questo hook viene chiamato ogni volta che un componente viene rimosso dalla pagina, per esempio per via di un blocco @if oppure perché l'app ha navigato in un'altra vista.
Lo scopo di ngOnDestroy è quello di consentirci di chiudere eventuali sottoscrizioni a eventi o Observables, o di arrestare eventuali interval o timeout necessari per il funzionamento del componente.
Prendiamo il seguente esempio:
import { Component, OnDestroy, OnInit } from '@angular/core';
const listener = (e: MouseEvent) => console.log('mouse event', e);
@Component({
selector: 'app-log-click',
standalone: true,
template: ''
})
export class LogClickComponent implements OnInit, OnDestroy {
ngOnInit(): void { document.addEventListener('click', listener) }
ngOnDestroy(): void { /* document.removeEventListener('click', listener)*/ }
}
@Component({
selector: 'app-root',
standalone: true,
imports: [LogClickComponent],
template: `
@if (logClick) { <app-log-click /> }
<pre>{{logClick}}</pre>
<button (click)="toggle()">Toggle</button>
`,
})
export class AppComponent {
logClick = false;
toggle() { this.logClick = !this.logClick }
}
Abbiamo un componente LogClickComponent che non ha alcun template ma si occupa di registrare un event listener su document, cioè sull'intera pagina. AppComponent mostra o nasconde il componente nel suo template in base al booleano logClick. Poiché la logica dentro ngOnDestroy è commentata, anche quando rimuoviamo il componente la pagina continua a loggare i nostri click.
Dal momento che nei moderni linguaggi di programmazione non siamo abituati a preoccuparci di ripulire manualmente la memoria, errori come questo sono molto frequenti e possono causare comportamenti indesiderati e grossi impatti sulle performance della nostra app. In generale, dovunque c'è un OnInit dovremmo chiederci se è anche necessario un OnDestroy; l'unico caso in cui possiamo essere indulgenti al riguardo è proprio in AppComponent che, rappresentando l'intera applicazione, viene distrutto insieme alla finestra del browser che la esegue.
Abbiamo detto che ngOnInit viene chiamato dopo l'inizializzazione delle input properties; nel processo di inizializzazione del componente vi sono altri due potenziali passaggi, vale a dire l'inizializzazione di ciò che è rappresentato nel template del componente (view) e di ciò che è rappresentato come figlio del tag del componente e proiettato attraverso ng-content (content).
Questi due passaggi sono rappresentati dai ganci ngAfterViewInit e ngAfterContentInit.
Questi due hook sono specialmente utili se usati insieme ai decoratori @ViewChild/@ViewChildren e @ContentChild/@ContentChildren, come nell'esempio:
import { AfterContentInit, AfterViewInit, Component, ContentChild, ElementRef, ViewChild } from '@angular/core';
@Component({
selector: 'app-sample',
standalone: true,
template: '<p #view>View</p><ng-content />'
})
export class SampleComponent implements AfterViewInit, AfterContentInit {
@ViewChild('view') view!: ElementRef<HTMLParagraphElement>;
@ContentChild('content') content!: ElementRef<HTMLParagraphElement>;
ngAfterViewInit(): void { console.log('view init', this.view) }
ngAfterContentInit(): void { console.log('content init', this.content) }
}
@Component({
selector: 'app-root',
standalone: true,
imports: [SampleComponent],
template: '<app-sample><p #content>Content</p></app-sample>',
})
export class AppComponent {}
Qui abbiamo usato nello stesso esempio sia view sia content, con i decoratori associati ai rispettivi hook.
Il funzionamento di @ViewChild e affini è simile a quello del nativo document.querySelector, ma invece dei soliti selettori CSS accetta come argomento il nome di una template variable oppure il tipo di una direttiva o di un componente; in questo modo Angular è in grado di fornirci direttamente l'istanza del componente a cui siamo interessati invece del semplice tag HTML.
Per quanto riguarda gli omologhi multipli @ViewChildren/@ContentChildren, questi decorano delle proprietà di tipo QueryList, delle semplici strutture dalle quali possiamo estrarre i nodi a cui siamo interessati oppure ciclare su di essi.
Gli hook visti fin qui vengono chiamati tutti una volta sola da Angular, ed eventualmente altre volte dal nostro codice; i seguenti hook vengono invece chiamati più frequentemente nel corso della vita del componente, e dunque dovremo fare attenzione a non appesantirli troppo per non impattare eccessivamente sulle performance:
AfterView/ContentChecked: omologhi degli hook appena visti, vengono chiamati ogni volta che la view e il content vengono aggiornati; si raccomanda di usare questi due hook solo se strettamente necessario poiché il loro impatto sulle prestazioni è significativo;
DoCheck: viene chiamato prima che Angular aggiorni la vista, dandoci l'opportunità di aggiungere manualmente delle logiche di aggiornamento della vista in seguito a modifiche dello stato; questo hook ha casi d'uso estremamente limitati e strettamente tecnici;
OnChanges: questo hook viene chiamato ogni volta che le input property vengono aggiornate, e gli viene passato un parametro che contiene una mappa delle modifiche; si rivela molto utile in quei componenti, direttive o pipes che hanno bisogno di far discendere delle operazioni ad ogni aggiornamento dei loro input.
4.5
Observables in Angular
Siamo finalmente giunti a uno degli argomenti più affascinanti e discussi di Angular: l'impiego di RxJS e dei suoi Observable.
La premessa da fare su questo argomento è che RxJS non è un componente di Angular ma una libreria indipendente che, per altro, è implementata e disponibile in diversi linguaggi di programmazione e su diverse piattaforme tecnologiche. Lo scopo di questa libreria è quello di creare un contesto di operazioni applicabili su flussi di eventi (in inglese event stream) in modo estendibile, in modo da poter abilitare un paradigma di programmazione parente della programmazione funzionale chiamato programmazione reattiva; questa definizione è racchiusa in sintesi nel nome stesso della libreria, che sta per "Reactive extensions for JavaScript".
Per comprendere il principio alla base della programmazione reattiva dobbiamo tornare a parlare di stato e transizioni, cioè aggiornamenti dello stato.
Immaginiamo di voler implementare un'applicazione molto comune, ma anche estremamente complessa e versatile come un foglio di calcolo; all'interno di questo foglio l'utente potrà inserire dei dati in alcune celle, oppure derivare il valore di queste da quello di altre, combinandole secondo diverse formule.
In un'applicazione del genere dovremmo tenere traccia dinamicamente di quali celle discendono da altre, e in che modo; ad ogni modifica di una cella da parte dell'utente, dovremo aggiornare secondo le formule impostate tutte le celle derivate a cascata.
In un contesto simile l'utilità della programmazione reattiva diventa particolarmente chiara: essenzialmente, anziché rappresentare lo stato di ogni cella come un valore e aggiornarlo imperativamente, lo rappresenteremo come un flusso di dati emessi nel tempo; questo flusso potrà, poi, essere una fonte primaria di eventi oppure una derivata combinando altri flussi.
In questa sezione vedremo in maniera più o meno esaustiva tutto ciò che RxJS comporta nel contesto di Angular; va precisato, però, che per scandagliare in modo approfondito tutti i tipi e le funzioni di questa libreria occorrerebbe una guida a sé stante.
Iniziamo riepilogando come ci siamo imbattuti in RxJS:
i metodi di HttpClient restituiscono observables invece che promises per rappresentare le operazioni asincrone;
tutte le API del framework basate su eventi espongono questi sotto forma di observables.
Cominciamo dando un esempio molto stringato di come nasce un Observable:
import { Observable } from 'rxjs';
const obs = new Observable<number>(subscriber => {
subscriber.next(1);
subscriber.next(2);
subscriber.next(3);
subscriber.complete();
});
In pratica abbiamo eseguito il costruttore passando una funzione che accetta un subscriber su cui vengono chiamati il metodo next per emettere valori e poi il metodo complete per chiudere lo stream.
Confrontiamo questo costruttore con quello di una promise:
const pro = new Promise<number>(resolve => resolve(1));
In pratica, un observable è come una promise in grado di emettere in modo asincrono un numero indefinito di valori prima di completare. Perciò una promise è in definitiva un caso particolare di observable; questo spiega perché gli sviluppatori di Angular hanno scelto di usare gli observable, adottando un'API uniforme per le attività asincrone e per i flussi di eventi.
Nella realtà concreta il costruttore Observable non si usa praticamente mai: la libreria mette a disposizione una incredibile quantità di factory functions pronte per generare observable a partire da eventi del DOM, timeout, interval e ogni sorta di API asincrona o basata su eventi di JavaScript.
Qui non ci interesseremo di vedere queste factory ma solo gli operatori utili per svolgere le operazioni più frequenti nel contesto di Angular.
Se un observable funziona come una promise con valori multipli, possiamo pensarlo come un array asincrono, possiamo cioè immaginare che i valori emessi nel tempo da un flusso di eventi siano come gli elementi iterati da un array o da una struttura analoga. In effetti, esiste un'altro costrutto JavaScript più vicino agli array e molto simile ad un observable: le generator functions.
Vediamone una:
const gen = function* () {
yield 1;
yield 2;
yield 3;
return;
}
for (const n of gen()) console.log(n);
La generator function di questo esempio ha un comportamento analogo a quello del primo observable che abbiamo creato: yield equivale a subscriber.next e return a subscriber.complete; l'API per sfogliare i valori emessi dal generatore è, in questo caso, sincrona e integrata nella sintassi di iterazione di JavaScript, la stessa che usiamo per gli array.
Quest'ultima differenza è cruciale per spiegare l'esigenza che sta dietro a RxJS: infatti in JavaScript esistono già costrutti sintattici utili a scandagliare diversi valori, ma il punto è che non esiste un modo per regolare alla fonte il tempismo con cui questi valori sono emessi.
La seguente tabella riassume perfettamente quanto detto finora:
Esecuzione
Valore singolo
Valori multipli
A trazione (pull)
Function
Iterator
A spinta (push)
Promise
Observable
Per esecuzione a trazione si intende che è il codice fruitore a "tirare fuori" (pull) il dato da un'espressione, come avviene chiamando funzioni sincrone oppure ciclando su un array;
per esecuzione a spinta invece si intende che è la fonte stessa del dato a determinare il tempismo con il quale il codice fruitore verrà eseguito, emettendo o "spingendo" (push) il nuovo valore come avviene con le promise native e, appunto, con gli observable.
Questa caratteristica degli observable di essere in grado di rappresentare tanti e tali casi d'uso rende la programmazione reattiva un vero e proprio stile di programmazione informatica, tale per cui ogni dato viene rappresentato come una fonte di eventi e ogni trasformazione dei dati viene rappresentata come un flusso di eventi derivato; il tutto è immutabile e tardivo (lazy), perciò solo in coda a tutte le trasformazioni troviamo una chiamata a subscribe che concretizza il tutto in un'esecuzione reale.
Siamo, dunque, pronti per vedere qualche esempio di trasformazione e qui diventerà chiaro come usare tutto questo nel contesto reale di un'applicazione Angular.
Precedentemente, parlando di routing abbiamo detto che il Router e la ActivatedRoute espongono un'API a eventi basata su proprietà di tipo Observable; come API alternativa avremmo potuto avere un metodo del tipo router.addEventListener analogo a quello usato per gli eventi del DOM, ma in quel modo questi eventi non sarebbero stati componibili così come lo sono se esposti attraverso un observable.
Prendiamo l'esempio di un componente che logga tutti gli eventi di navigazione e immaginiamo di voler appendere ad ogni evento una chiamata POST verso un'API che registri il comportamento della nostra app per fini statistici o diagnostici:
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';
import { Event, Router } from '@angular/router';
import { Observable } from 'rxjs';
@Component({
selector: 'app-root',
standalone: true,
imports: [],
template: 'Works',
})
export class AppComponent implements OnInit {
router = inject(Router);
http = inject(HttpClient);
ngOnInit(): void {
this.router.events.subscribe(event => this.trackEvent(event).subscribe()) // <-
}
trackEvent(event: Event): Observable<void> {
return this.http.post<void>('/track/navigation', { event });
}
}
Il codice in questo esempio è valido e funzionante, tuttavia chiamare più di una subscribe nello stesso stream è considerato una cattiva pratica, poiché c'è un modo più reattivo di fare questa cosa:
import { HttpClient } from '@angular/common/http';
import { Component, inject, OnInit } from '@angular/core';
import { Event, Router } from '@angular/router';
import { Observable, switchMap } from 'rxjs';
@Component({
selector: 'app-root',
standalone: true,
imports: [],
template: 'Works',
})
export class AppComponent implements OnInit {
router = inject(Router);
http = inject(HttpClient);
ngOnInit(): void {
this.router.events.pipe(
switchMap(event => this.trackEvent(event)) // <-
).subscribe();
}
trackEvent(event: Event): Observable<void> {
return this.http.post<void>('/track/navigation', { event });
}
}
Qui abbiamo rimosso la subscribe interna e l'abbiamo sostituita con un pipe(switchMap(...)). Per spiegare il funzionamento di switchMap possiamo usare l'analogia tra Observable e Array, che da un punto di vista matematico hanno in comune veramente tanto.
Prendiamo il seguente esempio:
[1, 2, 3].map(n => [n * 2]).forEach(x => console.log(x)) // [2] [4] [6]
from([1, 2, 3]).pipe(map(x => from([x * 2]))).subscribe(x => console.log(x)); // ?
Per testare queste righe ci sarà sufficiente eseguirle nel costruttore di AppComponent.
Una nota sul metodo pipe: poiché RxJS è un sistema di estensioni, è implementato nella forma di un insieme di operatori da utilizzare in una chiamata al metodo pipe. Questa è semplicemente una sintassi alternativa, più flessibile, all'implementazione di metodi direttamente sul tipo Observable.
Dentro una chiamata a pipe è possibile concatenare più operatori che verranno semplicemente eseguiti in ordine. L'observable risultante dopo una chiamata a pipe è un nuovo observable ottenuto a partire dal precedente, e nessuno dei due observable inizierà a emettere dati finché non verrà chiamata la subscribe su di lui.
Qui abbiamo usato la factory from per estrarre i valori dagli array e farli emettere uno ad uno dall'observable risultante; in questo modo la somiglianza tra i due esempi dovrebbe risultare ancora più lampante.
In entrambi i casi il risultato non è quello che ci si aspetterebbe, perché stiamo mappando da un valore estratto a uno contenuto nel suo wrapper, in un caso array e nell'altro observable.
Se vogliamo appiattire (flatten) l'estrazione dei valori, dobbiamo usare un operatore leggermente diverso in entrambi i casi:
[1, 2, 3].flatMap(n => [n * 2]).forEach(x => console.log(x)); // 2 4 6
from([1, 2, 3]).pipe(switchMap(x => from([x * 2]))).subscribe(x => console.log(x)); // 2 4 6
Notiamo come siamo passati in un caso da map a flatMap, e nell'altro caso a switchMap. In entrambi i casi l'operazione che stiamo eseguendo è: mappa dal valore estratto al valore che si estrae dal wrapper ritornato.
In questo esempio emerge anche l'analogia tra forEach che concretizza la lettura degli elementi dell'array in modo da poter generare un effetto, e subscribe, che ha esattamente lo stesso ruolo. Nel caso delle promise il parallelo diventa strano poiché then funge da map, da flatMap/switchMap e anche da subscribe; come se non bastasse, la promise è precoce (eager), nel senso che si attiva anche se then non viene chiamato affatto.
Torniamo ad Angular e vediamo come possiamo usare RxJS per creare ed esporre la nostra API a eventi, costruendo una service class che fa da storage di una parte dello stato dell'applicazione, in modo che questo possa essere fruito dai componenti dell'intera app in modo reattivo.
Per questo esempio ci sbizzarriamo con una vera e propria web app che implementa una to-do list.
La nostra app mostrerà:
un box di testo per filtrare le attività,
la lista delle attività filtrate (o tutte se il filtro è vuoto), e per ognuna:
la descrizione,
una checkbox,
un pulsante rimuovi;
un riepilogo con le attività totali, quelle fatte e quelle da fare;
un pulsante per aggiungere una nuova attività.
Useremo la programmazione reattiva per:
esporre l'API dello storage in memory,
replicare lo storage in memory sul localStorage,
realizzare un filtro della lista.
Cominciamo a vedere che aspetto ha il nostro app component:
import { Component } from '@angular/core';
import { TodoListComponent } from './todo/todo-list/todo-list.component';
import { TodoSummaryComponent } from './todo/todo-summary/todo-summary.component';
import { TodoPersistComponent } from './todo/todo-persist/todo-persist.component';
import { TodoFindComponent } from './todo/todo-find/todo-find.component';
@Component({
selector: 'app-root',
standalone: true,
imports: [
TodoPersistComponent,
TodoFindComponent,
TodoListComponent,
TodoSummaryComponent
],
template: `
<app-todo-persist />
<app-todo-find />
<app-todo-list />
<app-todo-summary />
`,
})
export class AppComponent { }
Benissimo: ci siamo limitati ad importare e dichiarare nel template 4 componenti.
Questi 4 componenti lavorano tutti su una dipendenza comune: TodoService;
nel suo modulo andiamo a dichiarare anche l'interfaccia dell'entità Todo e una semplice factory:
import { Injectable } from '@angular/core';
import { BehaviorSubject } from 'rxjs';
export interface Todo {
label: string;
done: boolean;
}
export const Todo = (label: string = ''): Todo => ({
label,
done: false
});
@Injectable({ providedIn: 'root' })
export class TodoService {
#todos = new BehaviorSubject<Todo[]>([]);
readonly changes = this.#todos.asObservable();
get todos() { return this.#todos.value }
set todos(todos: Todo[]) { this.#todos.next(todos) }
notifyChanges(): void { this.#todos.next(this.todos) }
}
Qui abbiamo decisamente un po' di cose da spiegare.
Iniziamo osservando che i nostri todo sono rappresentati da una proprietà privata che è un BehaviorSubject; questo tipo è una classe che estende Subject, che può essere usata sia come un Observable, sia come un Subscriber, cioè vi si possono chiamare sopra il metodo next e il metodo complete.
In aggiunta a Subject, BehaviorSubject prevede un valore iniziale ed espone sempre, anche in modo sincrono (tramite la proprietà value), l'ultimo valore emesso.
Poiché non vogliamo esporre direttamente il BehaviorSubject, esponiamo lo stream delle modifiche alla lista tramite changes, ricavato proprio da #todos. In questo modo abbiamo incapsulato l'API reattiva della nostra collezione.
A questo punto, incapsuliamo anche l'API sincrona con un getter e un setter.
Infine, completiamo l'API reattiva con il metodo notifyChanges, che non fa altro che riemettere l'ultima versione della lista, notificandone la modifica a chi è in ascolto su changes; questo metodo esiste specificamente per consentire la mutazione dell'array o di singoli todo sul posto (in-place) senza cioè dover riassegnare l'intera collezione.
Abbiamo detto che vogliamo anche persistere la nostra collezione replicandola su localStorage; lo facciamo con TodoPersistComponent:
import { afterNextRender, Component, inject, OnDestroy } from '@angular/core';
import { Todo, TodoService } from '../todo.service';
import { debounceTime, Observable, switchMap, tap } from 'rxjs';
@Component({
selector: 'app-todo-persist',
standalone: true,
imports: [],
template: '',
})
export class TodoPersistComponent implements OnDestroy {
$ = inject(TodoService);
#sub = this.#load().pipe(
tap(todos => { this.$.todos = todos }),
switchMap(() => this.$.changes),
debounceTime(200)
).subscribe(todos => this.#save(todos));
ngOnDestroy(): void { this.#sub.unsubscribe() }
#save(todos: Todo[]): void {
localStorage.setItem('todos', JSON.stringify(todos));
}
#load(): Observable<Todo[]> {
return new Observable(subscriber => {
afterNextRender(() => {
const todos = localStorage.getItem('todos') ?? '[]';
subscriber.next(JSON.parse(todos));
subscriber.complete();
})
});
}
}
Anche qui, un po' di spiegazioni, a partire dalla più necessaria:
perché abbiamo implementato una funzionalità che non ha niente a che vedere con la UI in un componente con template vuoto?
La ragione è che questo è a tutti gli effetti il modo più pulito ed elegante per farlo: così facendo assumiamo il controllo sul ciclo di vita del componente attraverso il template del componente padre; possiamo, ad esempio, attivare e disattivare la feature in modo semplice e dichiarativo: @if (persist) { <app-todo-persist /> }.
Inoltre, normalmente i servizi servono a esporre dati e operazioni ai componenti che ne fruiscono, ma a noi qui non serve fruire di alcunché: vogliamo solo attivare un agente che poi opera in modo autonomo in reazione a degli eventi, e un componente è il posto ideale in cui fare questo tipo di cosa.
Come regola generale rispetto a RxJS, un servizio non dovrebbe fare sottoscrizioni a observable; quello è compito dei componenti, se possibile tramite la AsyncPipe. In questo caso non è possibile poiché non stiamo presentando dati ma agganciando un'operazione ad un evento.
Andando avanti, abbiamo iniettato TodoService nella proprietà $; la scelta di questo nome è intenzionale: vogliamo far risultare estremamente sintetico l'accesso agli attributi di TodoService, quasi come se li stessimo ereditando.
La proprietà #sub è molto interessante, e viene inizializzata appena costruiamo il componente:
recupera i todo salvati a localStorage da #load sotto forma di observable (vedremo a breve perché questo è necessario);
con tap intercetta il contenuto e inizializza la collezione in memory a bordo di TodoService che verrà esposta a tutti gli altri componenti;
con switchMap, passa in ascolto sui changes, che a questo punto sono inizializzati con la collezione salvata;
con debounceTime attende 200 millisecondi senza modifiche prima di proseguire;
con subscribe applica l'azione desiderata, cioè ad ogni modifica chiama #save.
Il risultato della subscribe viene salvato in #sub, in modo che possiamo poi chiudere la sottoscrizione quando il componente viene distrutto.
Il metodo #save si spiega da solo, mentre invece su #load ci sono un po' di cose da dire. Il motivo per cui questo metodo restituisce un Observable è lo stesso per cui il componente inizializza tutto in costruzione anziché usando ngOnInit: poiché Angular fa Server Side Rendering (SSR), non è sempre possibile fare operazioni su localStorage perché il componente viene inizializzato a backend; dunque, dobbiamo chiamare afterNextRender per assicurarci che il caricamento dei todo memorizzati avvenga lato client, e afterNextRender deve necessariamente essere chiamata nel costruttore del componente. Per ovviare a questo inconveniente, costruiamo un observable e lo usiamo come una promise.
Se anziché usare localStorage avessimo una REST API a back end, avremmo usato HttpClient.get, ritrovandoci con un observable del tutto simile a questo.
Andiamo ora a implementare TodoFindService:
import { inject, Injectable } from '@angular/core';
import { TodoService } from '../todo.service';
import { BehaviorSubject, combineLatest, map } from 'rxjs';
@Injectable({ providedIn: 'root' })
export class TodoFindService {
$ = inject(TodoService);
#filter = new BehaviorSubject('');
readonly found = combineLatest([
this.$.changes,
this.#filter
]).pipe(
map(([todos, filter]) => (
todos.filter(t => t.label.includes(filter))
))
);
update(filter: string): void { this.#filter.next(filter) }
}
Anche in questo caso abbiamo TodoService come dipendenza.
Rappresentiamo ancora il nostro filtro come un BehaviorSubject e ricaviamo i todo filtrati (found) dai valori combinati di $.changes e #filter, usando combineLatest: questa factory crea un Observable che emette tuple di valori emessi da un elenco di stream; ogni volta che uno stream sorgente emette un valore, viene emessa una tupla contenente il valore nuovo insieme all'ultimo valore (da cui latest) emesso da tutti gli altri stream usati come sorgenti.
Quindi ogni volta che cambia la lista dei todo oppure il filtro, andiamo ad aggiornare la lista dei todo trovati.
Per finire, incapsuliamo l'aggiornamento del nostro filtro con il metodo update.
Il servizio appena creato ci serve per implementare TodoFindComponent:
import { Component, inject } from '@angular/core';
import { TodoFindService } from './todo-find.service';
@Component({
selector: 'app-todo-find',
standalone: true,
imports: [],
template: `
<div style="margin: 8px 0;">
<input type="text" placeholder="search..." (input)="update($event)">
</div>
`
})
export class TodoFindComponent {
$ = inject(TodoFindService);
update(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
this.$.update(target.value);
}
}
Questo componente è molto semplice e fa solo due cose:
presenta il box in cui digitare il filtro,
passa al servizio il filtro aggiornato.
Sarebbe divertente implementare un componente TodoFindPersistComponent che funziona come TodoPersistComponent ma opera sulla query string dell'URL anziché sul local storage. L'implementazione sarebbe analoga, perciò la lasciamo come esercizio.
Ora che abbiamo tutto il necessario per salvare nello storage, persistere e filtrare le nostre attività, vediamo di presentarle in una semplice lista;
se ne occupa TodoListComponent:
import { Component, inject } from '@angular/core';
import { Todo, TodoService } from '../todo.service';
import { TodoFindService } from '../todo-find/todo-find.service';
import { map } from 'rxjs';
import { AsyncPipe } from '@angular/common';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [AsyncPipe],
templateUrl: './todo-list.component.html'
})
export class TodoListComponent {
$t = inject(TodoService);
$f = inject(TodoFindService);
add(): void {
this.$t.todos = this.$t.todos.concat(Todo());
}
remove(todo: Todo): void {
this.$t.todos = this.$t.todos.filter(t => t !== todo);
}
updateDone(todo: Todo, event: Event): void {
const target = event.currentTarget as HTMLInputElement;
todo.done = target.checked;
this.$t.notifyChanges();
}
updateLabel(todo: Todo, event: Event): void {
const target = event.currentTarget as HTMLInputElement;
todo.label = target.value;
this.$t.notifyChanges();
}
}
Se abbiamo accolto la convenzione $ per le dipendenze singole, la presenza di $t e $f ci dice a colpo d'occhio che questo componente opera sia sulla lista totale, sia su quella filtrata. Qui è dove si compie il principio composition over inheritance.
Questo componente, infatti, integra una serie di responsabilità: presenta la lista filtrata ma opera su quella completa, e ne muta anche gli elementi individualmente.
Vediamo il suo template:
@for (t of $f.found | async; track $index) {
<div style="margin: 8px 0; display: flex; gap: 4px;">
<input
type="checkbox"
[checked]="t.done"
(input)="updateDone(t, $event)"
>
<input
type="text"
placeholder="add label"
[value]="t.label"
(input)="updateLabel(t, $event)"
>
<button (click)="remove(t)">Remove</button>
</div>
}
<button (click)="add()">Add</button>
Qui non c'è molto da dire, notiamo solo l'uso di un blocco @for insieme alla async pipe per presentare la lista filtrata; il resto è puro data/event binding.
Il nostro viaggio si conclude con l'implementazione del SummaryComponent:
import { Component, inject } from '@angular/core';
import { Todo, TodoService } from '../todo.service';
import { combineLatest, map } from 'rxjs';
import { AsyncPipe, JsonPipe } from '@angular/common';
import { TodoFindService } from '../todo-find/todo-find.service';
interface Summary {
total: number;
todo: number;
done: number;
}
const Summary = (todos: Todo[]): Summary => ({
total: todos.length,
todo: todos.filter(t => !t.done).length,
done: todos.filter(t => t.done).length
});
@Component({
selector: 'app-todo-summary',
standalone: true,
imports: [AsyncPipe, JsonPipe],
template: '<pre>{{summary | async | json}}</pre>'
})
export class TodoSummaryComponent {
$t = inject(TodoService);
$f = inject(TodoFindService);
summary = combineLatest([
this.$t.changes,
this.$f.found
]).pipe(
map(([todos, found]) => ({
todos: Summary(todos),
found: Summary(found)
}))
);
}
Anche qui componiamo la lista filtrata e quella completa in un oggetto Summary che rappresenta un riepilogo delle attività; questo riepilogo viene poi mostrato come JSON, ma potrebbe anche essere rappresentato con degli elementi grafici.
Al termine di questa sezione abbiamo verosimilmente collezionato un buon quantitativo di sgomento e confusione. La programmazione reattiva richiede un cambio di prospettiva per essere compresa a fondo; quando la si comprende, bisogna fare attenzione a non diventarne fanatici e cadere nel tranello di interpretare tutto come un enigma stimolante da risolvere: come ogni paradigma di programmazione, la programmazione reattiva è qui per semplificarci la vita, non per complicarcela.
Possiamo usare RxJS con successo ogni volta che abbiamo una o più fonti di dati soggette ad aggiornamenti (in questo caso le attività e il filtro) da combinare per ricavare dati derivati (come il nostro summary) oppure a cui agganciare reazioni (come la replica sul localStorage), col beneficio di poterne regolare molto comodamente il tempismo (usando gli operatori di RxJS come debounceTime). Per le normali logiche di flusso, la vecchia cara programmazione imperativa non passerà mai di moda, ed è giusto così.
4.6
Signals in Angular
Con gli observables abbiamo toccato un argomento importantissimo per tutti i framework che si occupano di UI: l'onore e l'onere di sapere quando un dato cambia nello stato per poterlo aggiornare sul DOM. Questa operazione prende il nome di change detection (rilevamento delle modifiche) e in Angular può essere eseguita con diverse strategie che offrono diversi livelli di comodità ed efficienza.
Per capire come funziona la change detection, immaginiamo di scrivere il nostro framework basato su componenti:
ogni componente ha uno stato;
lo stato dev'essere riflesso sulla UI, sia in fase di inizializzazione, sia ad ogni successivo aggiornamento;
la UI a sua volta scatena degli eventi dai quali possono seguire logiche di aggiornamento dello stato, sincrone o asincrone.
Per fare questo abbiamo essenzialmente due possibili strategie:
intercettare ogni possibile evento che può avvenire in una web app: eventi dalla UI, risposte HTTP, setInterval, setTimeout, ecc; per farlo dovremmo aggiungere degli event listeners sul DOM, ma anche sostituire tutte le API del linguaggio JavaScript che chiamano callback (come le due appena citate);
offrire un unico punto di ingresso per l'aggiornamento dello stato, obbligando il programmatore a notificare al framework che lo stato è cambiato.
La strategia di Angular corrisponde al primo metodo, che viene applicato usando una libreria chiamata Zone.js.
Come il nome suggerisce, Zone crea delle zone in cui tutto ciò che può succedere è posto sotto osservazione; ogni volta che accade qualcosa il nuovo stato viene scansito in cerca di differenze. Questo significa che quando lavoriamo con Angular abbiamo continuamente a che fare con API proxy che assomigliano in tutto e per tutto a quelle originali, ma in realtà sono state sovrascritte (questo è possibile perché in JavaScript qualunque variabile o funzione globale può essere riassegnata).
Ovviamente l'intera architettura volta a consentire ad Angular questo tipo di change detection è computazionalmente piuttosto costosa, decisamente più della seconda strategia menzionata prima; tuttavia, offre allo sviluppatore la comodità di non doversi occupare di notificare al framework che lo stato è cambiato, se non in situazioni particolarmente specifiche.
Di recente, nel mondo frontend è tornato alla ribalta il concetto di signal (segnale): un signal è un tipo di dato in grado di notificare le modifiche al framework tenendo traccia dei punti in cui il suo valore viene letto.
In pratica, quando un framework utilizza un sistema di segnali, l'onere della change detection si sposta all'interno dei segnali stessi, risparmiando al framework l'onere di dover scansire lo stato in cerca di modifiche e di identificare le parti di UI da aggiornare.
Ma come è possibile tutto questo?
Tanto per cominciare, i segnali non sono direttamente leggibili ma il loro valore è accessibile solo tramite una getter function. Il compito di questa funzione è duplice: da un lato espone il valore corrente del segnale, dall'altro traccia dove il segnale è usato.
In secondo luogo, i segnali non sono nemmeno direttamente scrivibili; a dire il vero, un segnale potrebbe anche non essere scrivibile affatto. Ovviamente, quando modifichiamo il valore di un segnale attraverso la sua setter function, stiamo anche notificando la modifica di stato al framework.
In pratica, tramite i segnali, Angular passa dalla prima alla seconda strategia usando un'architettura che a tutti gli effetti è molto simile a quella degli observable; non a caso, in Angular è presente un'API di conversione da observable a signal e viceversa.
Viste le molte somiglianze tra un'architettura basata su observable e una basata su signal, il modo migliore per esemplificare il tutto è proprio quello di reimplementare la web app della sezione precedente utilizzando proprio i segnali; questo ci darà l'opportunità di assaggiare l'API, i casi d'uso, le similitudini e l'interoperabilità tra i due approcci.
Cominciamo da TodoService:
import { Injectable, signal } from '@angular/core';
export interface Todo {
label: string;
done: boolean;
}
export const Todo = (label: string = ''): Todo => ({
label,
done: false
});
@Injectable({ providedIn: 'root' })
export class TodoService {
readonly todos = signal<Todo[]>([]);
notifyChanges(): void { this.todos.update(x => [...x]) }
}
Qui la proprietà todos non è più un BehaviorSubject ma un signal creato con l'apposita funzione. Poiché un signal non può notificare una modifica se il suo valore è identico, il metodo notifyChanges propaga una copia dell'array come nuovo valore.
Usando un signal per rappresentare il nostro stato non abbiamo più bisogno di far convivere un'API sinrona e una reattiva, perciò non abbiamo bisogno di getter e setter.
Vediamo ora come cambia TodoPersistComponent:
import { afterNextRender, Component, computed, effect, inject, OnDestroy, signal } from '@angular/core';
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
import { Todo, TodoService } from '../todo.service';
import { debounceTime, Observable, switchMap, tap } from 'rxjs';
@Component({
selector: 'app-todo-persist',
standalone: true,
imports: [],
template: '',
})
export class TodoPersistComponent {
constructor() {
const $ = inject(TodoService);
const loadedTodos = signal<Todo[] | undefined>(undefined);
const todosSignal = computed(
() => loadedTodos() ? $.todos() : undefined
);
const debouncedSignal = toSignal(
toObservable(todosSignal).pipe(debounceTime(200))
);
afterNextRender(() => {
const todos = localStorage.getItem('todos') ?? '[]';
loadedTodos.set(JSON.parse(todos));
});
effect(
() => {
const loaded = loadedTodos();
if (loaded) $.todos.set(loaded);
},
{ allowSignalWrites: true }
);
effect(() => {
const todos = debouncedSignal();
if (todos) localStorage.setItem('todos', JSON.stringify(todos));
});
}
}
Qui ce la caviamo al 100% con il solo costruttore, poiché i segnali sono automaticamente agganciati al ciclo di vita del componente e dunque non dobbiamo tenere traccia delle sottoscrizioni manualmente.
Vediamo che succede nel dettaglio.
Anzitutto iniettiamo TodoService direttamente come variabile. Dopodiché creiamo un segnale loadedTodos che inizializziamo ad undefined e che setteremo con il valore recuperato in afterNextRender; in pratica, anziché ritornare un observable che emetterebbe un valore solo quando pronto, stiamo proceduralmente creando un segnale "vuoto" che poi "riempiremo"; la reattività qui c'è ancora, perché Angular a quel punto terrà conto del fatto che il valore è cambiato, ma ora non siamo noi a gestirla.
In poche parole, loadedTodos svolge il ruolo dell'observable restituito dal metodo #load della sezione precedente.
A questo punto deriviamo da loadedTodos un altro segnale che assume valore undefined se loadedTodos non ha valore, altrimenti rappresenta i nostri todos. Qui i todos vengono estratti dal signal (notare le parentesi tonde), perciò stiamo facendo una cosa simile a quello che nell'esempio precedente abbiamo fatto con switchMap, solo che qui avviene in modo procedurale.
Ora dobbiamo aggiungere la logica di debounce e per questo ci avvaliamo ancora di RxJS. Per farlo, facciamo andata e ritorno da signal a observable usando pipe(debounceTime(200)) nel mezzo.
Va notato come queste trasformazioni consecutive fossero molto più semplici non dovendo fare avanti e indietro da un'API all'altra; probabilmente, in futuro Angular si doterà di un'API di trasformazione dei signal che non renda necessario passare da RxJS.
Una volta definiti i nostri segnali, andiamo ad applicare della logica:
afterNextRender funziona come nell'esempio reattivo, ma chiama .set anziché .next e non ha bisogno di completare;
il primo effect legge il valore di loadedTodos, quindi diventa reattivo rispetto ai suoi aggiornamenti di stato; ad ogni valore emesso, se presente, lo mette dentro i todos del nostro TodoService, normalmente un effetto non può modificare il valore di altri segnali, ma possiamo abilitare questa cosa con l'opzione allowSignalWrites;
il secondo effect reagisce a debouncedSignal salvando il suo valore a localStorage ad ogni aggiornamento.
Se ragionare in modo reattivo poteva risultare un cambio di forma mentis impegnativo, questo potrebbe risultare ancora più astruso. Evidentemente ci sono cose per cui risulta più lineare la strada dei signal e altre per cui è meglio rimanere in contesto observable; qui si voleva semplicemente esemplificare una possibile reimplementazione dell'esempio reattivo.
In entrambi i casi è utile spostare il focus della logica dal classico "se-allora" a "cosa cambia in relazione a cosa".
Ora migriamo anche TodoFindService:
import { computed, inject, Injectable, signal } from '@angular/core';
import { TodoService } from '../todo.service';
@Injectable({ providedIn: 'root' })
export class TodoFindService {
$ = inject(TodoService);
readonly filter = signal('');
readonly found = computed(() => (
this.$.todos().filter(t => t.label.includes(this.filter()))
));
}
Anche questo si è semplificato rispetto alla sua controparte reattiva: i nostro filtro è ora un semplice signal, che verrà usato direttamente dal relativo componente, e found è ricavato con la stessa logica, ma senza dover esplicitamente indicare da quali altri dati deriva; il fatto che chiamiamo i getter su this.$.todos() e this.filter() sarà sufficiente a mettere in relazione found con i due signal da cui dipende.
Passiamo a TodoFindComponent:
import { Component, inject } from '@angular/core';
import { TodoFindService } from './todo-find.service';
@Component({
selector: 'app-todo-find',
standalone: true,
imports: [],
template: `
<div style="margin: 8px 0;">
<input type="text" placeholder="search..." (input)="update($event)">
</div>
`
})
export class TodoFindComponent {
$ = inject(TodoFindService);
update(event: Event): void {
const target = event.currentTarget as HTMLInputElement;
this.$.filter.set(target.value);
}
}
Qui è cambiato veramente poco: anziché chiamare TodoFindService.update, usiamo direttamente il setter del signal $.filter.set(...) per aggiornare il filtro.
Veniamo ora al TodoListComponent che mostra il tutto:
import { Component, inject } from '@angular/core';
import { Todo, TodoService } from '../todo.service';
import { TodoFindService } from '../todo-find/todo-find.service';
@Component({
selector: 'app-todo-list',
standalone: true,
imports: [],
templateUrl: './todo-list.component.html'
})
export class TodoListComponent {
$t = inject(TodoService);
$f = inject(TodoFindService);
add(): void {
this.$t.todos.update(todos => todos.concat(Todo()));
}
remove(todo: Todo): void {
this.$t.todos.update(todos => todos.filter(t => t != todo));
}
updateDone(todo: Todo, event: Event): void {
const target = event.currentTarget as HTMLInputElement;
todo.done = target.checked;
this.$t.notifyChanges();
}
updateLabel(todo: Todo, event: Event): void {
const target = event.currentTarget as HTMLInputElement;
todo.label = target.value;
this.$t.notifyChanges();
}
}
Anche qui è cambiato veramente poco: usiamo il metodo update sui segnali per modificare il loro stato in relazione allo stato precedente, e notifyChanges per notificare un aggiornamento di stato là dove abbiamo mutato gli elementi senza riassegnare l'intero array.
@for (t of $f.found(); track $index) {
<div style="margin: 8px 0; display: flex; gap: 4px;">
<input
type="checkbox"
[checked]="t.done"
(input)="updateDone(t, $event)"
>
<input
type="text"
placeholder="add label"
[value]="t.label"
(input)="updateLabel(t, $event)"
>
<button (click)="remove(t)">Remove</button>
</div>
}
<button (click)="add()">Add</button>
In questo caso l'unica differenza è che abbiamo chiamato il getter del segnale found direttamente nel template anziché usare la AsyncPipe.
Infine il nostro SummaryComponent:
import { Component, computed, inject } from '@angular/core';
import { Todo, TodoService } from '../todo.service';
import { JsonPipe } from '@angular/common';
import { TodoFindService } from '../todo-find/todo-find.service';
interface Summary {
total: number;
todo: number;
done: number;
}
const Summary = (todos: Todo[]): Summary => ({
total: todos.length,
todo: todos.filter(t => !t.done).length,
done: todos.filter(t => t.done).length
});
@Component({
selector: 'app-todo-summary',
standalone: true,
imports: [JsonPipe],
template: '<pre>{{summary() | json}}</pre>'
})
export class TodoSummaryComponent {
$t = inject(TodoService);
$f = inject(TodoFindService);
summary = computed(() => ({
todos: Summary(this.$t.todos()),
found: Summary(this.$f.found())
}));
}
Notiamo due piccole differenze: una è che ci siamo liberati della AsyncPipe; l'altra è che al posto di combineLatest abbiamo ricavato summary da this.$t.todos() e this.$f.found() in modo sincrono, semplicemente chiamando i getter.
In definitiva, abbiamo visto come i segnali semplifichino di molto le cose quando li usiamo semplicemente al posto di normali variabili per gestire lo stato dei componenti; diventano più macchinosi quando cerchiamo di usarli per gestire gli eventi includendo la variabile tempo: in quel caso passare a RxJS diventa quasi una scelta obbligata.
Oltre alle API viste, Angular al momento fornisce in anteprima un'alternativa ai vari decoratori come @Input per lavorare esclusivamente con i signals. Vedremo come questa nuova API interagirà con le precedenti man mano che il tutto raggiunge una fase di sviluppo più consolidata.
4.7
Server Side Rendering (Universal)
Abbiamo già parlato della SPA e di come questo tipo di web app migliori l'esperienza dell'utente: rendendo la navigazione più fluida, fornendo un feedback su tempi di caricamento e migliorando l'interazione. Tuttavia questo approccio presenta anche delle limitazioni, principalmente dovute alle dimensioni del caricamento iniziale, ma anche al fatto che l'applicazione viene completamente renderizzata lato client e dunque non è possibile nemmeno vedere la prima pagina senza prima aver scaricato ed eseguito il codice JavaScript necessario.
Per superare queste limitazioni i vari framework frontend si sono dotati di logiche di server side rendering, cioè di generazione della prima pagina visitata a backend, per poi riprendere l'esecuzione della SPA a frontend in un secondo momento. In questo modo migliora anche il rapporto che la nostra web app avrà con i motori di ricerca, che sono così capaci di leggere il contenuto delle nostre pagine senza dover eseguire localmente alcun codice JavaScript.
Il SSR può essere incluso nella nostra app Angular nel momento stesso di creazione del progetto, semplicemente rispondendo yes quando richiesto dalla CLI; questa feature può essere abilitata in qualunque momento usando la CLI:
ng add @angular/ssr
Su questa feature di Angular non c'è molto da dire in termini di sviluppo: non si tratta di qualcosa che il framework mette a disposizione per implementare delle funzionalità applicative, ma più di una caratteristica tecnica delle applicazioni prodotte, della quale dobbiamo tenere conto se non vogliamo incappare in problematiche ad essa annesse.
Iniziamo elencando i file interessati:
server.ts inizializza un'istanza di Express.js e la configura per servire la prima pagina già renderizzata;
main.server.ts serve semplicemente ad indicare qual è il componente di avvio (AppComponent) e la configurazione;
app.config.server.ts è un omologo di app.config.ts che contiene soltanto la parte di configurazione dedicata al SSR.
Questi file non necessitano di modifiche o customizzazioni a meno di esigenze specifiche, ma ovviamente avendo a disposizione un'istanza completa di Express.js possiamo andare ad aggiungere routing lato server, gli endpoint di una web API, tutto quello che vogliamo.
In realtà abbiamo già visto anche come evitare di incappare in problemi quando si creano dei componenti che devono essere eseguibili potenzialmente sia lato client, sia lato server: negli esempi precedenti abbiamo implementato logiche client usando lo speciale hook afterNextRender; ebbene attraverso questo hook possiamo avere accesso alle API del browser con la certezza che queste API non verranno mai chiamate durante il render della pagina a backend.
Usando afterNextRender possiamo accedere a localStorage come abbiamo già visto, ma anche a tutte le API esposte dal browser che fanno direttamente riferimento al dispositivo che esegue l'applicazione, come window, document, navigator or location.
5
Oltre questa guida
5.1
Librerie e toolkit per creare interfacce utente con Angular
Siamo arrivati al termine di questa guida dopo un lungo viaggio partito dai fondamentali fino a sviluppare strategie e pattern di implementazione complessi. Concludiamo il nostro viaggio con una panoramica sulle più popolari librerie di componenti grafici (ma anche qualcosina di più!)
Cdk
Il Component Development Kit (CDK) è un set di behavior primitives (primitive di comportamento) per costruire componenti senza un tema grafico preimpostato; possiamo usarlo per implementare comportamenti avanzati ma molto comuni come:
finestre modali (dialog),
trascinamento (drag & drop),
virtual scrolling
..e molte altre cose! L'Angular CDK contiene tutti i componenti e direttive headless (cioè senza una UI stilizzata) che stanno alla base di Angular Material (che vediamo a breve), ma lascia a noi la libertà su come implementarne l'estetica.
Angular Material
Angular Material è una libreria sviluppata dallo stesso team di Angular, proponendosi come il design system ufficiale, costruito ad hoc per questo framework. Ovviamente lo stile grafico implementato è quello Material, sviluppato proprio in casa Google e impiegato per le applicazioni della G Suite, ma anche come tema grafico predefinito per le applicazioni native Android.
Questo toolkit oltre ad essere ricco di funzionalità utili è dunque particolarmente adatto se vogliamo dare alla nostra web app un look simil-nativo in ambito Android.
Bootstrap
Bootsrap è la libreria HTML, CSS e JavaScript più famosa al mondo e fornisce una ampia varietà di componenti funzionali e pre-stilizzati per semplificare e velocizzare lo sviluppo web. Supporta moltissimi temi grafici pre-fabbricati ed è anche completamente customizzabile.
Il modo più facile per integrare bootstrap in un'applicazione Angular è installango la libreria dedicata ng-bootstrap.
Se vuoi approfondire, abbiamo preparato per te una guida a Boostrap in italiano!
Tailwind
Avevamo già parlato nel nostro blog di Tailwind CSS: è un framework CSS in rapida ascesa negli ultimi anni. Purtroppo non esiste una libreria di componenti specificamente per Angular, ma è possibile includerlo nel nostro progetto e utilizzarlo nei template dei nostri componenti attraverso le classi CSS che espone. Sul loro sito è disponibile una guida per integrare e usare Tailwind in un progetto Angular.
Per renderti le cose più semplici, ecco i link utili:
Link ai componenti
Link integrazione Angular
Kendo UI
Kendo UI è una libreria di componenti funzionali pre-stilizzati con tre opzioni di design predefinite: standard, Material e Bootstrap; è anche possibile implementare uno stile grafico personalizzato.
Per utilizzare questa libreria è necessario, però, acquistare una licenza.
Ora hai gli strumenti per iniziare (o continuare) a costruire le tue applicazioni in Angular con maggiore consapevolezza e padronanza. Il passo successivo? Sperimentare, creare e contribuire.
Buon codice, e… buon viaggio nel mondo di Angular!
CONTENUTI GRATUITI IN EVIDENZA
Guide per aspiranti programmatori 👨🏻🚀
Vuoi muovere i primi passi nel Digital e Tech? Abbiamo preparato alcune guide per aiutarti a orientarti negli ambiti più richiesti oggi.