Come creare un gioco di abbinamento di carte usando Angular e RxJS
Pubblicato: 2022-03-10Oggi vorrei concentrarmi sui flussi di dati risultanti da eventi di clic sull'interfaccia utente. L'elaborazione di tali flussi di clic è particolarmente utile per le applicazioni con un'interazione utente intensiva in cui devono essere elaborati molti eventi. Vorrei anche presentarvi un po' di più RxJS; è una libreria JavaScript che può essere utilizzata per esprimere le routine di gestione degli eventi in modo compatto e conciso in uno stile reattivo.
Cosa stiamo costruendo?
I giochi di apprendimento e i quiz di conoscenza sono popolari sia per gli utenti più giovani che per quelli più anziani. Un esempio è il gioco "pair matching", in cui l'utente deve trovare coppie correlate in una combinazione di immagini e/o frammenti di testo.
L'animazione di seguito mostra una versione semplice del gioco: l'utente seleziona due elementi sul lato sinistro e destro del campo di gioco uno dopo l'altro e in qualsiasi ordine. Le coppie correttamente abbinate vengono spostate in un'area separata del campo di gioco, mentre eventuali assegnazioni errate vengono immediatamente sciolte in modo che l'utente debba effettuare una nuova selezione.
In questo tutorial, costruiremo un tale gioco di apprendimento passo dopo passo. Nella prima parte, costruiremo un componente Angular che mostra solo il campo di gioco del gioco. Il nostro obiettivo è che il componente possa essere configurato per diversi casi d'uso e gruppi target, da un quiz sugli animali fino a un trainer di vocabolario in un'app per l'apprendimento delle lingue. A tale scopo Angular propone il concetto di proiezione di contenuti con template personalizzabili, di cui faremo uso. Per illustrare il principio, costruirò due versioni del gioco ("game1" e "game2") con layout diversi.
Nella seconda parte del tutorial, ci concentreremo sulla programmazione reattiva. Ogni volta che una coppia viene abbinata, l'utente deve ottenere una sorta di feedback dall'app; è questa gestione degli eventi che viene realizzata con l'aiuto della libreria RxJS.
- Requisiti
Per seguire questo tutorial, è necessario installare Angular CLI. - Codice sorgente
Il codice sorgente di questo tutorial può essere trovato qui (14 KB).
1. Costruire un componente angolare per il gioco di apprendimento
Come creare la struttura di base
Per prima cosa, creiamo un nuovo progetto chiamato "learning-app". Con Angular CLI, puoi farlo con il comando ng new learning-app
. Nel file app.component.html , sostituisco il codice sorgente pregenerato come segue:
<div> <h1>Learning is fun!</h1> </div>
Nella fase successiva, viene creato il componente per il gioco di apprendimento. L'ho chiamato "matching-game" e ho usato il comando ng generate component matching-game
. Questo creerà una sottocartella separata per il componente del gioco con i file HTML, CSS e Typescript richiesti.
Come già accennato, il gioco educativo deve essere configurabile per diversi scopi. Per dimostrarlo, creo due componenti aggiuntivi ( game1
e game2
) usando lo stesso comando. Aggiungo il componente di gioco come componente figlio sostituendo il codice pregenerato nel file game1.component.html o game2.component.html con il seguente tag:
<app-matching-game></app-matching-game>
All'inizio utilizzo solo il componente game1
. Per assicurarmi che il gioco 1 venga visualizzato subito dopo l'avvio dell'applicazione, aggiungo questo tag al file app.component.html :
<app-game1></app-game1>
Quando si avvia l'applicazione con ng serve --open
, il browser visualizzerà il messaggio "matching-game funziona". (Questo è attualmente l'unico contenuto di matching-game.component.html .)
Ora, dobbiamo testare i dati. Nella cartella /app
creo un file chiamato pair.ts dove definisco la classe Pair
:
export class Pair { leftpart: string; rightpart: string; id: number; }
Un oggetto coppia comprende due testi correlati ( leftpart
e rightpart
) e un ID.
Il primo gioco dovrebbe essere un quiz sulle specie in cui le specie (es. dog
) devono essere assegnate alla classe animale appropriata (es. mammal
).
Nel file animals.ts definisco un array con dati di test:
import { Pair } from './pair'; export const ANIMALS: Pair[] = [ { id: 1, leftpart: 'dog', rightpart: 'mammal'}, { id: 2, leftpart: 'blickbird', rightpart: 'bird'}, { id: 3, leftpart: 'spider', rightpart: 'insect'}, { id: 4, leftpart: 'turtle', rightpart: 'reptile' }, { id: 5, leftpart: 'guppy', rightpart: 'fish'}, ];
Il componente game1
bisogno di accedere ai nostri dati di test. Sono custoditi nella proprietà animals
. Il file game1.component.ts ora ha il seguente contenuto:
import { Component, OnInit } from '@angular/core'; import { ANIMALS } from '../animals'; @Component({ selector: 'app-game1', templateUrl: './game1.component.html', styleUrls: ['./game1.component.css'] }) export class Game1Component implements OnInit { animals = ANIMALS; constructor() { } ngOnInit() { } }
La prima versione del componente di gioco
Il nostro prossimo obiettivo: il componente matching-game
deve accettare i dati di gioco dal componente genitore (es. game1
) come input. L'input è un array di oggetti "coppia". L'interfaccia utente del gioco deve essere inizializzata con gli oggetti passati all'avvio dell'applicazione.
A tal fine, dobbiamo procedere come segue:
- Aggiungi le
pairs
di proprietà al componente di gioco usando il decoratore@Input
. - Aggiungi gli array
solvedPairs
eunsolvedPairs
come proprietà private aggiuntive del componente. (È necessario distinguere tra coppie già "risolte" e "non ancora risolte".) - All'avvio dell'applicazione (vedi funzione
ngOnInit
) tutte le coppie sono ancora “non risolte” e vengono quindi spostate nell'arrayunsolvedPairs
.
import { Component, OnInit, Input } from '@angular/core'; import { Pair } from '../pair'; @Component({ selector: 'app-matching-game', templateUrl: './matching-game.component.html', styleUrls: ['./matching-game.component.css'] }) export class MatchingGameComponent implements OnInit { @Input() pairs: Pair[]; private solvedPairs: Pair[] = []; private unsolvedPairs: Pair[] = []; constructor() { } ngOnInit() { for(let i=0; i<this.pairs.length; i++){ this.unsolvedPairs.push(this.pairs[i]); } } }
Inoltre, definisco il template HTML del componente matching-game
. Ci sono contenitori per le coppie irrisolte e risolte. La direttiva ngIf
garantisce che il rispettivo contenitore venga visualizzato solo se esiste almeno una coppia non risolta o risolta.
Nel contenitore per le coppie irrisolte (class container unsolved
), sono elencate prima tutte le componenti a left
(vedi il riquadro sinistro nella GIF sopra) e poi tutte a right
(vedi il riquadro a destra nella GIF) delle coppie. (Uso la direttiva ngFor
per elencare le coppie.) Al momento, un semplice pulsante è sufficiente come modello.
Con l'espressione modello {{{pair.leftpart}}
e { {{pair.rightpart}}}
, i valori delle proprietà leftpart
e rightpart
dei singoli oggetti coppia vengono interrogati durante l'iterazione dell'array di pair
. Sono usati come etichette per i pulsanti generati.
Le coppie assegnate sono elencate nel secondo contenitore ( container solved
). Una barra verde ( connector
di classe ) indica che appartengono insieme.
Il codice CSS corrispondente del file matching-game.component.css si trova nel codice sorgente all'inizio dell'articolo.
<div> <div class="container unsolved" *ngIf="unsolvedPairs.length>0"> <div class="pair_items left"> <button *ngFor="let pair of unsolvedPairs" class="item"> {{pair.leftpart}} </button> </div> <div class="pair_items right"> <button *ngFor="let pair of unsolvedPairs" class="item"> {{pair.rightpart}} </button> </div> </div> <div class="container solved" *ngIf="solvedPairs.length>0"> <div *ngFor="let pair of solvedPairs" class="pair"> <button>{{pair.leftpart}}</button> <div class="connector"></div> <button>{{pair.rightpart}}</button> </div> </div> </div>
Nel componente game1
, l'array animals
è ora associato alla proprietà pairs
del componente matching-game
(associazione dati unidirezionale).
<app-matching-game [pairs]="animals"></app-matching-game>
Il risultato è mostrato nell'immagine qui sotto.
Ovviamente, il nostro gioco di abbinamento non è ancora troppo difficile, perché le parti sinistra e destra delle coppie sono direttamente l'una di fronte all'altra. Affinché l'abbinamento non sia troppo banale, le parti giuste dovrebbero essere mescolate. Risolvo il problema con un pipe shuffle
auto-definito, che applico all'array unsolvedPairs
sul lato destro (il test
del parametro è necessario in seguito per forzare l'aggiornamento del pipe):
... <div class="pair_items right"> <button *ngFor="let pair of unsolvedPairs | shuffle:test" class="item"> {{pair.rightpart}} </button> </div> ...
Il codice sorgente della pipe è memorizzato nel file shuffle.pipe.ts nella cartella dell'app (vedi codice sorgente ad inizio articolo). Nota anche il file app.module.ts , dove la pipe deve essere importata ed elencata nelle dichiarazioni del modulo. Ora la vista desiderata appare nel browser.
Versione estesa: utilizzo di modelli personalizzabili per consentire un design individuale del gioco
Invece di un pulsante, dovrebbe essere possibile specificare frammenti di modello arbitrari per personalizzare il gioco. Nel file matching-game.component.html sostituisco il modello del pulsante per il lato sinistro e destro del gioco con un tag ng-template
. Assegno quindi il nome di un riferimento al modello alla proprietà ngTemplateOutlet
. Questo mi dà due segnaposto, che vengono sostituiti dal contenuto del rispettivo riferimento al modello durante il rendering della vista.
Abbiamo qui a che fare con il concetto di proiezione del contenuto : alcune parti del template del componente sono date dall'esterno e sono “proiettate” nel template nelle posizioni contrassegnate.
Durante la generazione della vista, Angular deve inserire i dati di gioco nel modello. Con il parametro ngTemplateOutletContext
dico ad Angular che una variabile contextPair
viene utilizzata all'interno del modello, a cui dovrebbe essere assegnato il valore corrente della variabile pair
dalla direttiva ngFor
.
L'elenco seguente mostra la sostituzione del contenitore unsolved
. Nel contenitore solved
, anche i pulsanti devono essere sostituiti dai tag ng-template
.
<div class="container unsolved" *ngIf="unsolvedPairs.length>0"> <div class="pair_items left"> <div *ngFor="let pair of unsolvedPairs" class="item"> <ng-template [ngTemplateOutlet]="leftpart_temp" [ngTemplateOutletContext]="{contextPair: pair}"> </ng-template> </div> </div> <div class="pair_items right"> <div *ngFor="let pair of unsolvedPairs | shuffle:test" class="item"> <ng-template [ngTemplateOutlet]="leftpart_temp" [ngTemplateOutletContext]="{contextPair: pair}"> </ng-template> </div> </div> </div> ...
Nel file matching-game.component.ts , devono essere dichiarate le variabili di entrambi i riferimenti al modello ( leftpart_temp
e rightpart_temp
). Il decoratore @ContentChild
indica che si tratta di una proiezione di contenuto, ovvero Angular ora si aspetta che i due frammenti di modello con il rispettivo selettore ( leftpart
o rightpart
) siano forniti nel componente padre tra i tag <app-matching-game></app-matching-game>
dell'elemento host (vedi @ViewChild
).
@ContentChild('leftpart', {static: false}) leftpart_temp: TemplateRef<any>; @ContentChild('rightpart', {static: false}) rightpart_temp: TemplateRef<any>;
Non dimenticare: i tipi ContentChild
e TemplateRef
devono essere importati dal pacchetto principale.
Nel componente principale game1
, i due frammenti di modello richiesti con i selettori parte leftpart
e rightpart
sono ora inseriti.
Per semplicità, riutilizzerò di nuovo i pulsanti qui:
<app-matching-game [pairs]="animals"> <ng-template #leftpart let-animalPair="contextPair"> <button>{{animalPair.leftpart}}</button> </ng-template> <ng-template #rightpart let-animalPair="contextPair"> <button>{{animalPair.rightpart}}</button> </ng-template> </app-matching-game>
L'attributo let-animalPair="contextPair"
viene utilizzato per specificare che la variabile di contesto contextPair
viene utilizzata nello snippet del modello con il nome animalPair
.
I frammenti di modello ora possono essere modificati secondo i tuoi gusti. Per dimostrarlo utilizzo il componente game2
. Il file game2.component.ts ottiene lo stesso contenuto di game1.component.ts . In game2.component.html utilizzo un elemento div
progettato individualmente invece di un pulsante. Le classi CSS sono memorizzate nel file game2.component.css .
<app-matching-game [pairs]="animals"> <ng-template #leftpart let-animalPair="contextPair"> <div class="myAnimal left">{{animalPair.leftpart}}</div> </ng-template> <ng-template #rightpart let-animalPair="contextPair"> <div class="myAnimal right">{{animalPair.rightpart}}</div> </ng-template> </app-matching-game>
Dopo aver aggiunto i tag <app-game2></app-game2>
nella home page app.component.html , all'avvio dell'applicazione viene visualizzata la seconda versione del gioco:
Le possibilità di progettazione ora sono quasi illimitate. Sarebbe possibile, ad esempio, definire una sottoclasse di Pair
che contenga proprietà aggiuntive. Ad esempio, gli indirizzi delle immagini possono essere memorizzati per le parti sinistra e/o destra. Le immagini possono essere visualizzate nel modello insieme al testo o al posto del testo.
2. Controllo dell'interazione dell'utente con RxJS
Vantaggi della programmazione reattiva con RxJS
Per trasformare l'applicazione in un gioco interattivo, è necessario elaborare gli eventi (ad es. eventi di clic del mouse) che vengono attivati sull'interfaccia utente. Nella programmazione reattiva si considerano sequenze continue di eventi, i cosiddetti “stream”. Uno stream può essere osservato (è un “osservabile”), cioè possono esserci uno o più “osservatori” o “abbonati” che si iscrivono allo stream. Vengono informati (di solito in modo asincrono) di ogni nuovo valore nel flusso e possono reagire ad esso in un certo modo.
Con questo approccio è possibile ottenere un basso livello di accoppiamento tra le parti di un'applicazione. Gli osservatori e gli osservabili esistenti sono indipendenti l'uno dall'altro e il loro accoppiamento può essere variato in fase di esecuzione.
La libreria JavaScript RxJS fornisce un'implementazione matura del modello di progettazione Observer. Inoltre, RxJS contiene numerosi operatori per convertire flussi (es. filter, map) o combinarli in nuovi flussi (es. merge, concat). Gli operatori sono “funzioni pure” nel senso di programmazione funzionale: non producono effetti collaterali e sono indipendenti dallo stato esterno alla funzione. Una logica di programma composta solo da chiamate a funzioni pure non necessita di variabili ausiliarie globali o locali per memorizzare stati intermedi. Questo, a sua volta, promuove la creazione di blocchi di codice stateless e liberamente accoppiati. È quindi desiderabile realizzare gran parte della gestione dell'evento da un'intelligente combinazione di operatori di flusso. Esempi di questo sono forniti nella sezione successiva, in base al nostro gioco di abbinamento.
Integrazione di RxJS nella gestione degli eventi di un componente angolare
Il framework Angular funziona con le classi della libreria RxJS. RxJS viene quindi installato automaticamente quando viene installato Angular.
L'immagine seguente mostra le principali classi e funzioni che giocano un ruolo nelle nostre considerazioni:
Nome della classe | Funzione |
---|---|
Osservabile (RxJS) | Classe base che rappresenta un flusso; in altre parole, una sequenza continua di dati. È possibile sottoscrivere un osservabile. La funzione pipe viene utilizzata per applicare una o più funzioni dell'operatore all'istanza osservabile. |
Oggetto (RxJS) | La sottoclasse di osservabile fornisce la funzione successiva per pubblicare nuovi dati nel flusso. |
Emettitore di eventi (angolare) | Questa è una sottoclasse specifica per l'angolo che di solito viene utilizzata solo insieme al decoratore @Output per definire un output del componente. Come la funzione successiva, la funzione di emit viene utilizzata per inviare dati agli abbonati. |
Abbonamento (RxJS) | La funzione di subscribe di un osservabile restituisce un'istanza di sottoscrizione. È necessario annullare l'abbonamento dopo aver utilizzato il componente. |
Con l'aiuto di queste classi, vogliamo implementare l'interazione dell'utente nel nostro gioco. Il primo passo è assicurarsi che un elemento selezionato dall'utente sul lato sinistro o destro sia evidenziato visivamente.
La rappresentazione visiva degli elementi è controllata dai due frammenti di modello nel componente padre. La decisione su come visualizzarli nello stato selezionato dovrebbe quindi essere lasciata anche al componente principale. Dovrebbe ricevere segnali appropriati non appena viene effettuata una selezione sul lato sinistro o destro o non appena una selezione deve essere annullata.
A questo scopo, definisco quattro valori di output di tipo EventEmitter
nel file matching-game.component.ts . I tipi Output
ed EventEmitter
devono essere importati dal pacchetto principale.
@Output() leftpartSelected = new EventEmitter<number>(); @Output() rightpartSelected = new EventEmitter<number>(); @Output() leftpartUnselected = new EventEmitter(); @Output() rightpartUnselected = new EventEmitter();
Nel modello matching-game.component.html , reagisco all'evento mousedown
sul lato sinistro e destro, quindi invio l'ID dell'elemento selezionato a tutti i ricevitori.
<div *ngFor="let pair of unsolvedPairs" class="item" (mousedown)="leftpartSelected.emit(pair.id)"> ... <div *ngFor="let pair of unsolvedPairs | shuffle:test" class="item" (mousedown)="rightpartSelected.emit(pair.id)">
Nel nostro caso, i ricevitori sono i componenti game1
e game2
. Qui è ora possibile definire la gestione degli eventi per gli eventi leftpartSelected
, rightpartSelected
, leftpartUnselected
e rightpartUnselected
. La variabile $event
rappresenta il valore di output emesso, nel nostro caso l'ID. Di seguito puoi vedere l'elenco per game1.component.html , per game2.component.html si applicano le stesse modifiche.
<app-matching-game [pairs]="animals" (leftpartSelected)="onLeftpartSelected($event)" (rightpartSelected)="onRightpartSelected($event)" (leftpartUnselected)="onLeftpartUnselected()" (rightpartUnselected)="onRightpartUnselected()"> <ng-template #leftpart let-animalPair="contextPair"> <button [class.selected]="leftpartSelectedId==animalPair.id"> {{animalPair.leftpart}} </button> </ng-template> <ng-template #rightpart let-animalPair="contextPair"> <button [class.selected]="rightpartSelectedId==animalPair.id"> {{animalPair.rightpart}} </button> </ng-template> </app-matching-game>
In game1.component.ts (e similmente in game2.component.ts ), le funzioni del gestore di event
sono ora implementate. Memorizzo gli ID degli elementi selezionati. Nel modello HTML (vedi sopra), a questi elementi viene assegnata la classe selected
. Il file CSS game1.component.css definisce quali modifiche visive apporterà questa classe (ad es. modifiche al colore o al carattere). La reimpostazione della selezione (deseleziona) si basa sul presupposto che gli oggetti coppia abbiano sempre ID positivi.
onLeftpartSelected(id:number):void{ this.leftpartSelectedId = id; } onRightpartSelected(id:number):void{ this.rightpartSelectedId = id; } onLeftpartUnselected():void{ this.leftpartSelectedId = -1; } onRightpartUnselected():void{ this.rightpartSelectedId = -1; }
Nella fase successiva, è richiesta la gestione degli eventi nel componente di gioco corrispondente. È necessario determinare se un'assegnazione è corretta, ovvero se l'elemento selezionato a sinistra corrisponde all'elemento selezionato a destra. In questo caso, la coppia assegnata può essere spostata nel contenitore per le coppie risolte.
Vorrei formulare la logica di valutazione utilizzando gli operatori RxJS (vedere la sezione successiva). Per la preparazione, creo un assignmentStream
in matching-game.component.ts . Dovrebbe emettere gli elementi selezionati dall'utente sul lato sinistro o destro. L'obiettivo è utilizzare gli operatori RxJS per modificare e dividere il flusso in modo tale da ottenere due nuovi flussi: uno stream solvedStream
che fornisce le coppie assegnate correttamente e un secondo stream failedStream
che fornisce le assegnazioni errate. Vorrei iscrivermi a questi due flussi con subscribe
per poter eseguire la gestione degli eventi appropriata in ogni caso.
Ho anche bisogno di un riferimento agli oggetti di abbonamento creati, in modo da poter annullare gli abbonamenti con "unsubscribe" quando esco dal gioco (vedi ngOnDestroy
). Le classi Subject
e Subscription
devono essere importate dal pacchetto “rxjs”.
private assignmentStream = new Subject<{pair:Pair, side:string}>(); private solvedStream = new Observable<Pair>(); private failedStream = new Observable<string>(); private s_Subscription: Subscription; private f_Subscription: Subscription; ngOnInit(){ ... //TODO: apply stream-operators on //assignmentStream this.s_Subscription = this.solvedStream.subscribe(pair => handleSolvedAssignment(pair)); this.f_Subscription = this.failedStream.subscribe(() => handleFailedAssignment()); } ngOnDestroy() { this.s_Subscription.unsubscribe(); this.f_Subscription.unsubscribe(); }
Se l'assegnazione è corretta, vengono eseguiti i seguenti passaggi:
- La coppia assegnata viene spostata nel contenitore delle coppie risolte.
- Gli eventi
leftpartUnselected
erightpartUnselected
vengono inviati al componente padre.
Nessuna coppia viene spostata se l'assegnazione non è corretta. Se l'assegnazione errata è stata eseguita da sinistra a destra ( side1
ha il valore left
), la selezione dell'elemento sul lato sinistro dovrebbe essere annullata (vedi la GIF all'inizio dell'articolo). Se un'assegnazione viene eseguita da destra a sinistra, la selezione viene annullata per l'elemento sul lato destro. Ciò significa che l'ultimo elemento su cui è stato fatto clic rimane in uno stato selezionato.
Per entrambi i casi, preparo le funzioni del gestore corrispondenti handleSolvedAssignment
e handleFailedAssignment
(funzione di rimozione: vedere il codice sorgente alla fine di questo articolo):
private handleSolvedAssignment(pair: Pair):void{ this.solvedPairs.push(pair); this.remove(this.unsolvedPairs, pair); this.leftpartUnselected.emit(); this.rightpartUnselected.emit(); //workaround to force update of the shuffle pipe this.test = Math.random() * 10; } private handleFailedAssignment(side1: string):void{ if(side1=="left"){ this.leftpartUnselected.emit(); }else{ this.rightpartUnselected.emit(); } }
Ora dobbiamo cambiare il punto di vista dal consumatore che sottoscrive i dati al produttore che genera i dati. Nel file matching-game.component.html , mi assicuro che quando si fa clic su un elemento, l'oggetto coppia associato venga inserito nello assignmentStream
. Ha senso utilizzare un flusso comune per il lato sinistro e destro perché l'ordine dell'assegnazione non è importante per noi.
<div *ngFor="let pair of unsolvedPairs" class="item" (mousedown)="leftpartSelected.emit(pair.id)" (click)="assignmentStream.next({pair: pair, side: 'left'})"> ... <div *ngFor="let pair of unsolvedPairs | shuffle:test" class="item" (mousedown)="rightpartSelected.emit(pair.id)" (click)="assignmentStream.next({pair: pair, side: 'right'})">
Progettazione dell'interazione di gioco con gli operatori RxJS
Non resta che convertire lo solvedStream
assignmentStream
failedStream
. Applico i seguenti operatori in sequenza:
pairwise
Ci sono sempre due coppie in un compito. L'operatore pairwise
preleva i dati a coppie dal flusso. Il valore corrente e il valore precedente vengono combinati in una coppia.
Dal seguente stream...
„{pair1, left}, {pair3, right}, {pair2, left}, {pair2, right}, {pair1, left}, {pair1, right}“
...risulta questo nuovo flusso:
„({pair1, left}, {pair3, right}), ({pair3, right}, {pair2, left}), ({pair2, left}, {pair2, right}), ({pair2, right}, {pair1, left}), ({pair1, left}, {pair1, right})“
Ad esempio, otteniamo la combinazione ({pair1, left}, {pair3, right})
quando l'utente seleziona dog
(id=1) sul lato sinistro e insect
(id=3) sul lato destro (vedi array ANIMALS
in inizio articolo). Queste e le altre combinazioni risultano dalla sequenza di gioco mostrata nella GIF sopra.
filter
Devi rimuovere tutte le combinazioni dallo stream che sono state create sullo stesso lato del campo di gioco come ({pair1, left}, {pair1, left})
o ({pair1, left}, {pair4, left})
.
La condizione del filtro per un comb
combinato è quindi comb[0].side != comb[1].side
.
partition
Questo operatore prende un flusso e una condizione e crea due flussi da questo. Il primo flusso contiene i dati che soddisfano la condizione e il secondo flusso contiene i dati rimanenti. Nel nostro caso, gli stream dovrebbero contenere assegnazioni corrette o errate. Quindi la condizione per un comb
combinato è comb[0].pair===comb[1].pair
.
L'esempio produce un flusso "corretto" con
({pair2, left}, {pair2, right}), ({pair1, left}, {pair1, right})
e un flusso "sbagliato" con
({pair1, left}, {pair3, right}), ({pair3, right}, {pair2, left}), ({pair2, right}, {pair1, left})
map
Per l'ulteriore elaborazione di un'assegnazione corretta, ad es. pair2
, è necessario solo l'oggetto della singola coppia. L'operatore map può essere utilizzato per esprimere che la combinazione comb
deve essere mappata su comb[0].pair
. Se l'assegnazione non è corretta, la combinazione di comb
viene mappata sullo string comb[0].side
perché la selezione deve essere ripristinata sul lato specificato da side
.
La funzione pipe
viene utilizzata per concatenare gli operatori precedenti. Gli operatori pairwise
, filter
, partition
, map
devono essere importati dal pacchetto rxjs/operators
.
ngOnInit() { ... const stream = this.assignmentStream.pipe( pairwise(), filter(comb => comb[0].side != comb[1].side) ); //pipe notation leads to an error message (Angular 8.2.2, RxJS 6.4.0) const [stream1, stream2] = partition(comb => comb[0].pair === comb[1].pair)(stream); this.solvedStream = stream1.pipe( map(comb => comb[0].pair) ); this.failedStream = stream2.pipe( map(comb => comb[0].side) ); this.s_Subscription = this.solvedStream.subscribe(pair => this.handleSolvedAssignment(pair)); this.f_Subscription = this.failedStream.subscribe(side => this.handleFailedAssignment(side)); }
Ora il gioco funziona già!
Utilizzando gli operatori, la logica del gioco potrebbe essere descritta in modo dichiarativo. Abbiamo solo descritto le proprietà dei nostri due flussi di destinazione (combinati in coppie, filtrati, partizionati, rimappati) e non dovevamo preoccuparci dell'implementazione di queste operazioni. Se li avessimo implementati noi stessi, avremmo dovuto memorizzare anche stati intermedi nel componente (es. riferimenti agli ultimi elementi cliccati sul lato sinistro e destro). Invece, gli operatori RxJS incapsulano la logica di implementazione e gli stati richiesti per noi e quindi elevano la programmazione a un livello di astrazione più elevato.
Conclusione
Utilizzando un semplice gioco di apprendimento come esempio, abbiamo testato l'uso di RxJS in un componente Angular. L'approccio reattivo è adatto per elaborare gli eventi che si verificano sull'interfaccia utente. Con RxJS, i dati necessari per la gestione degli eventi possono essere organizzati comodamente come flussi. Numerosi operatori, come filter
, map
o partition
sono disponibili per trasformare i flussi. I flussi risultanti contengono dati che vengono preparati nella sua forma finale e possono essere sottoscritti direttamente. Richiede un po' di abilità ed esperienza per selezionare gli operatori appropriati per il rispettivo caso e collegarli in modo efficiente. Questo articolo dovrebbe fornire un'introduzione a questo.
Ulteriori risorse
- "L'introduzione alla programmazione reattiva che ti sei perso", scritto da Andre Staltz
Letture correlate su SmashingMag:
- Gestione dei punti di interruzione dell'immagine con Angular
- Styling di un'applicazione angolare con Bootstrap
- Come creare e distribuire un'applicazione materiale angolare