RxJS esprime il massimo potenziale quando utilizzato con i suoi operatori, semplici funzioni che si dividono essenzialmente in due macro categorie:
- Operatori di creazione: funzioni che possono essere utilizzate per creare Observable
- Pipeaple Operators: che in realtà si potrebbero dividere in altre sottocategorie (condizionali, trasformazione, filtri, multicast, errore, ecc.)
Questo articolo fa parte della serie “Introduzione RxJS”:
- Parte 5: Operatori e conclusione (questo articolo)
Operatori di Creazione
Come già anticipato, l’obiettivo di questi operatori è quello di generare degli Observable, a partire da qualunque tipo di scenario: forms, XHR, timers, eventi e così via.
Operatore interval
Un esempio tipico di operatore di creazione è interval
, che emette un valore incrementale, nel tempo, partendo da zero:
import { interval } from 'rxjs'; const source = interval(1000); source.subscribe( x => console.log(x) ) // output: // 0 // 1 // 2 // ... // N
Operatore of
L’operatore of
emette invece un determinato numero di valori in sequenza e poi “completa” l’observable:
import { of } from 'rxjs'; const source = of(10, 20, 30); source.subscribe( x => console.log(x), err => console.log(err), () => console.log('completed') ) // output: // 10 // 20 // 30 // completed
Operatore fromEvent
L’operatore di creazione fromEvent
converte, invece, un evento di qualsiasi tipo in un Observable
, spesso utilizzato con MouseEvent e KeyboardEvent.
Per dare un po’ di senso a questi esempi, introdurremo ora alcuni operatori “pipeable” al fine di trarre il massimo beneficio dall’utilizzo degli Observable e comprendere i benefici di un approccio funzionale rispetto ad uno imperativo.
Degli operatori “pipeable” ne parleremo più in dettaglio nella seconda parte dell’articolo.
Nel prossimo snippet utilizziamo, quindi, anche l’operatore debounceTime
che ci permetterà di ignorare i valori emessi in un lasso di tempo inferiore a quello specificato.
Questo operatore è utilizzato di frequente con i form, quando, ad esempio, non abbiamo la necessità di invocare una determinata operazione, in questo caso la chiamata ad una API REST, ad ogni evento ‘input’ di tastiera ma solo dopo un determinato lasso di tempo dall’ultima digitazione, ovvero quando l’utente ha terminato di digitare tutto il testo.
In questo modo eviteremo di invocare l’API ad ogni pressione di un tasto della tastiera, ma solo al termine.
Immaginiamo di avere un template HTML che contenga un semplice campo di input:
<input id="myInput">
Invece di utilizzare il classico metodo addEventListener
per registrare un evento, utilizzeremo l’operatore di creazione fromEvent
per effettuare la stessa operazione (ottenendo però un Observable) e potremo quindi sfruttare gli operatori “pipeable” di RxJS per gestire il flusso dati, in questo caso applicando semplicemente un effetto “debounce”:
import { fromEvent } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; fromEvent(document.getElementById('myInput'), 'input') .pipe( debounceTime(1000), ) .subscribe((event: KeyboardEvent) => { console.log('call your REST API:'+ event.target.value) })
Ma possiamo fare anche di più, come nell’esempio seguente:
- dato che a noi interessa solo il valore del campo di input e non l’evento di per sè, possiamo far in modo che la funzione
next
dell’observer riceva direttamente ilvalue
e non l’evento, trasformando quindievent.target.value
inevent
(tramite l’utilizzo dell’operatoremap
) - applicare filtri per evitare sia emesso un valore se la parola digitata non contiene almeno 3 caratteri (operatore
filter
), - evitare che sia emesso un valore se identico al precedente (
distinctUntilChanged
) - e così via…
import { fromEvent } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; fromEvent(document.getElementById('myInput'), 'input') .pipe( map(event => event.target.value ), filter(text => text.length >= 3 ), debounceTime(1000), distinctUntilChanged() ) .subscribe(text: string) => { console.log('call your REST API:'+ text) })
Senza un approccio funzionale (o RxJS) avremmo dovuto inserire delle condizioni (if) per sostituire le operazioni di filter
e distinctUntilChanged
, usare un timer per il debounce e probabilmente creare qualche variabile locale al posto del map
.
Ora provate ad immaginare quante righe di codice potreste risparmiare in un’applicazione real-world e quanto il vostro codice potrebbe diventare conciso ed espressivo con questo approccio. Penso non serva aggiungere altro.
NOTA per gli sviluppatori Angular: lo stesso approccio può essere utilizzato con i Reactive Forms, tramite i quali possiamo utilizzare il paradigma reattivo non solo per gestire singoli campi di input ma anche interi form, porzioni di form (FormGroup) e così via. Lo stesso approccio potrà comunque essere utilizzato anche per gestire eventi del router, HTTP interceptor, guardie del router, event emitter dei componenti e molto altro, dato che il framework è totalmente basato su RxJS, dipendenza fondamentale del framework.
Pipeable Operators
Questi operatori sono funzioni pure che prendono un Observable come input e restituiscono un altro Observable come output.
Come abbiamo già visto in alcuni esempi dove abbiamo utilizzato filter
, map
, scan
, take
, debounceTime
, ecc. abbiamo a disposizione decine di operatori pipeable a nostra disposizione, che possono essere usati singolarmente oppure concatenati con la funzione pipe()
:
obs.pipe( op1(), op2(), op3(), op3(), )
Per l’elenco completo degli operatori disponibili potete consultare la reference di RxJS
Operatori filter
, map
, take
Con un approccio molto simile a quello utilizzato per la gestione dell’input del paragrafo precedente, utilizziamo ora l’operatore di creazione interval
e diversi operatori per eseguire diverse operazioni:
filter
: non emette i valori 0 e 1 ma comincia da 2map
: trasforma il valore emesso dall’observable moltiplicandolo per 10take
: prende solo i primi 5 valori emessi e poi l’esecuzione viene completata
import { interval } from 'rxjs'; import { map, filter, take } from 'rxjs/operators'; const observable = interval(1000) .pipe( filter(x => x > 2), map(x => x * 10), take(5), ) observable.subscribe( x => console.log(x), err => console.log(err), () => console.log('complete') ) // Output (i primi due valori, 10 e 20 non vengono emessi) // 30 // 40 // 50 // 60 // 70 // complete
Imperative vs Reactive programming
Grazie all’utilizzo di funzioni pure, lo stato che si sta manipolando è isolato, rendendo il codice meno soggetto ad errori e a fattori esterni, dato che il codice strutturato con questo paradigma non prevede l’influenza da effetti esterni (“side effects”) come cambi di route, salvataggi su localstorage, utilizzo di servizi e/o variabili esterne.
Un esempio di funzione “impura”:
let count = 1; document.addEventListener('click', () => { if (count > 2) { console.log(Math.random()) } count++; });
Gli operatori sono invece un validissimo strumento per controllare il flusso di eventi, manipolare i valori emessi dagli observables o passare, ad esempio, da un tipo di observable ad un altro.
Nel prossimo esempio sfruttiamo gli operatori scan
e filter
, molto simili rispettivamente alle funzioni reduce
e filter
disponibili per gli array in Javascript, per ottenere lo stesso risultato del precedente script utilizzando un approccio idiomatico con RxJS:
scan
: accumula valori, partendo da0
e incrementando il valore di1
ad ogni clickfilter
: emette un valore solo quando l’espressione fornita restituiscetrue
map
: applica una funzione di proiezione ad ogni valore emesso dall’observable
import { fromEvent } from 'rxjs'; import { scan, map, filter } from 'rxjs/operators'; fromEvent(document, 'click') .pipe( scan(acc => acc + 1, 0), filter(count => count >2), map(event => Math.random()) ) .subscribe(res => { console.log(res); })
AJAX
Per proporvi un esempio “meno teorico” utilizzeremo l’operatore di creazione ajax
che ci permetterà di invocare delle API REST.
In questo esempio incrementiamo un counter da 1 a N ad ogni click dell’utente ed effettuiamo una chiamata REST passando tale counter come parametro della request. Gli operatori coinvolti in questo esempio sono:
scan
: incrementa un counter di1
, partendo da0
takeWhile
: emette un valore solo quando l’espressione fornita restituiscetrue
e COMPLETA l’observable quando èfalse
switchMap
: lo scopo di questo operatore è quello di “passare” da un observable (outer, in questo caso generato dafromEvent
) ad un nuovo inner observable, ovvero la chiamata AJAX, completare eventuali precedenti inner observable, se in pending, ed emettere dei valori dal nuovo (inner) observable. Per il momento, questa definizione potrebbe sembrarvi davvero complicata da comprendere ma possiamo semplificarla in “Passare (switch) da un Observable ad un altro”ajax
: crea un observable per effettuare una richiesta XHR
import { fromEvent } from 'rxjs'; import { ajax } from 'rxjs/ajax'; import { scan, map, switchMap, takeWhile } from 'rxjs/operators'; fromEvent(document, 'click') .pipe( scan(acc => acc + 1, 0), takeWhile(value => value <= 3), switchMap(value => ajax(`https://jsonplaceholder.typicode.com/users/${value}`)), map(res => res.response), ) .subscribe(res => { console.log(res); })
switchMap
,mergeMap
,concatMap
eexhaustMap
sono tra gli operatori RxJS più utilizzati ma richiedono un po’ di studio e tempo per essere digeriti. Le differenze tra questi 4 operatori sono davvero minime ma fondamentali da comprendere per gestire nel modo corretto, ad esempio, sequenze di operazioni CRUD asincrone. La scelta di un operatore errato potrebbe compromettere totalmente il funzionamento dell’applicazione.
Il mio consiglio è semplicemente quello di leggervi più articoli, guide e tutorial su RxJS e su questi (e altri) operatori e semplicemente sperimentare molto.
Conclusione
È ovvio che questa serie di articoli non rappresenta una guida esaustiva all’utilizzo di RxJS ma ha il solo obiettivo di avvicinarvi alla programmazione funzionale reattiva.
Le motivazioni per le quali ritengo RxJS una libreria fantastica (quasi indispensabile oggi giorno non solo nel mondo front-end ma anche back-end) le ho descritte nel primo articolo di questa serie, tuttavia, vorrei evidenziare alcuni aspetti che ritengo interessanti riguardo questo approccio, prendendo spunto da alcune slide sulla programmazione funzionale pubblicate da Mattia Natali.
Innanzitutto mi è piaciuto il titolo delle slide perchè rende subito l’idea:
Write Code for Humans! Not for Machines
Altri due concetti fondamentali sono rappresentati benissimo in queste due slides:
E concludo traducendo alcune porzioni di slide che riassumono benissimo dei concetti che condivido:
Perché gli essere umani preferiscono la programmazione funzionale?
- Si focalizza su COSA vuoi ottenere come risultato
- Riduce la quantità di codice
- Nasconde i dettagli dell’implementazione
Perché le macchine preferiscono la programmazione imperativa?
- Si focalizza su COME vuoi ottenere il risultato
- Si scrive il codice necessario a far compiere alla macchina delle operazioni
- Performance (non sempre)
Quando scrivi codice devi ricordare che:
- Qualcuno revisionerà il tuo codice
- I vostri attuali e futuri colleghi devono essere in grado di comprendere il tuo codice
- Qualcuno potrebbe ispirarsi al tuo codice
- … o creare qualcosa che si basa su di esso
Hardware is Cheap, Developers are Expensive
Spero che questa breve serie di articoli ti sia piaciuta.
Per rimanere aggiornato sui materiali che pubblico, sui corsi e sugli eventi che organizzo seguimi sulla mia pagina Facebook o LinkedIn.
Leave A Comment