4. HTTPRequests & Observables
4. HTTPRequests & Observables
Reactive Extensions is een bibliotheek die een API definieert voor asynchrone en event-driven code via Observables. Een Observable, zoals gedefinieerd door The Gang of Four, is een object dat bepaalde data bevat en bij elke wijziging in deze data alle geabonneerde objecten verwittigt. Een object kan zich abonneren op wijzigingen in de Observable, in dat geval wordt dit object een Observer genoemd.
De Reactive Extensions bibliotheek is geïmplementeerd in verschillende programmeertalen, waaronder JavaScript. Deze implementatie is beschikbaar onder de naam Reactive Extensions for JavaScript of RxJS. Angular gebruikt deze bibliotheek onderliggend voor een aanzienlijk aandeel van de framework features.
Tijdens deze les bekijken hoe we Observables kunnen gebruiken om asynchrone data weer te geven die we via HTTP van een externe service halen en hoe die data via RxJS verwerkt kan worden. We beperken ons tot het downloaden van data, maar deze kennis kan, indien nodig, gemakkelijk uitgebreid worden naar POST, PUT, PATCH, UPDATE, en DELETE requests. We demonstreren de mogelijkheden van RxJS aan de hand van een applicatie waarmee de data van The One API gelezen kan worden. De RxJS library is heel groot en het is dus onmogelijk om deze volledig te bespreken. We beperken ons hier tot de essentie.
Informatie
The One API is, zoals de meeste API's beveiligd. Om deze API te gebruiken (en het lesvoorbeeld uit te voeren), moet je dus een gratis API key aanvragen.
Startbestanden
Observables, Promises en de async pipe
Begrip: Observables
Een observable geeft het resultaat van een asynchrone operatie terug, in dit opzicht, is er geen verschil met een Promise. Het grote verschil is dat een promise één resultaat teruggeeft en dat een observable een stream van data kan teruggeven. Omdat deze data in een stream teruggegeven wordt, zijn observables ideaal voor applicaties die real-time data gebruiken via websockets.
Of
Een observable gebruiken is relatief eenvoudig, via de of methode kunnen we er een eenvoudige observable aanmaken. De argumenten van deze methode worden één per één uitgestuurd, dit gaat zodanig snel dat je in onderstaand voorbeeld enkel de laatste waarde als je de observable toont in de UI.
Om de informatie uit deze observables te tonen in de template, kunnen we geen gebruik maken van de *ngFor, zoals we gewoon zijn. Als je dit probeert krijg je onderstaande foutmelding.
import {Observable, of} from 'rxjs'
export class ObservablePage {
ofDemo1: Observable<string> = of('This', 'is', 'an', 'example', 'observable')
ofDemo2: Observable<string[]> = of(['This', 'is', 'an', 'example', 'observable'])
constructor() {}
}<ion-content [fullscreen]="true">
<!--Niet relevante code weggelaten-->
<ion-card>
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col>
<ol>
<li>{{ofDemo1}}</li>
</ol>
</ion-col>
</ion-row>
<ion-row>
<ion-col>
<ol>
<li *ngFor="let x of ofDemo2">{{x}}</li>
</ol>
</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
</ion-content>Omdat observables asynchrone operaties voorstellen moeten we aan Angular laten weten dat de data asynchroon is zodat deze correct verwerkt kan worden. Door middel van de async pipe kunnen we deze problemen oplossen.
<ion-content [fullscreen]="true">
<!--Niet relevante code weggelaten-->
<ion-card>
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col>
<ol>
<li>{{ofDemo1 | async}}</li>
</ol>
</ion-col>
</ion-row>
<ion-row>
<ion-col>
<ol>
<li *ngFor="let x of ofDemo2 | async">{{x}}</li>
</ol>
</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
</ion-content>
From
Naast de of functie bestaat ook de from functie. Deze kan gebruikt worden om een sequentie van elementen één per één uit te sturen, in tegenstelling tot de of functie moeten de argumenten in een array meegeven worden en wordt elk element in de array één per één uitgestuurd in de plaats van allemaal tegelijkertijd. We kunnen de data in deze observable opnieuw tonen in de UI via de async pipe.
import {from, Observable, of} from 'rxjs'
export class ObservablePage {
// Niet relevante code weggelaten.
fromDemo1: Observable<string> = from(['This', 'is', 'an', 'example', 'observable'])
constructor() {}
}<ion-content [fullscreen]="true">
<!--Niet relevante code weggelaten-->
<ion-card>
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col>
<ol>
<li>{{fromDemo1 | async}}</li>
</ol>
</ion-col>
</ion-row>
</ion-card-content>
</ion-card>
</ion-content>
Subscriptions
In bovenstaand screenshot is duidelijk te zien dat enkel het laatste element dat uitgestuurd is zichtbaar is. In het geval dat elk element zichtbaar moet zijn, kunnen we abonneren op elke waarde die door de observable uitgestuurd wordt. We kunnen elke uitgezonden waarde dan toekennen aan een array die we lokaal cachen in the TypeScript file.
Een abonnement (subscription) moet altijd geannuleerd worden als deze niet langer nodig is. Doen we dit niet, dan kan dit memory leaks veroorzaken en zal de applicatie (en computer) trager gaan werken. Een subscription annuleren kan via het Subscription object dat teruggegeven wordt door de subscribe methode die op elke observable beschikbaar is. Een subscription kan op elk moment geannuleerd worden, als de subscription doorheen de volledige levensduur van de component moet blijven bestaan, dan kan de subscription best geannuleerd worden in de ngOnDestroy lifecycle hook. Om deze hook te gebruiken moet de component de interface OnDestroy implementeren, we bespreken deze en andere lifecycle hook in detail in de laatste les.
import {from, Observable, of, Subscription} from 'rxjs'
export class ObservablePage implements OnInit, OnDestroy {
// Niet relevante code weggelaten.
fromDemo1: Observable<string> = from(['This', 'is', 'an', 'example', 'observable'])
fromDemo2: string[] = []
#subscriptions: Subscription[] = []
constructor() {}
ngOnInit(): void {
const s = from(['This', 'is', 'an', 'example', 'observable'])
.subscribe(x => this.fromDemo2.push(x))
this.#subscriptions.push(s)
}
ngOnDestroy(): void {
this.#subscriptions.forEach(s => s.unsubscribe())
}
}<ion-content [fullscreen]="true">
<!--Niet relevante code weggelaten-->
<ion-card>
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col>
<ol>
<li *ngFor="let x of fromDemo2">{{x}}</li>
</ol>
</ion-col>
</ion-row>
</ion-card-content>
</ion-card>
</ion-content>Promises
De async pipe is niet enkel nuttig voor observables, maar ook promises kunnen automatisch verwerkt worden door de async pipe.
In onderstaand voorbeeld gebruiken we de createPromiseDemo() methode om een promise aan te maken. Deze promise wordt bewaard in een instantievariabele en kan vervolgens via de async pipe gebruikt worden in de template.
Async pipe & methodes
Let op, gebruik een methode als createPromiseDemo() nooit rechtstreeks in de template. Dit veroorzaakt een oneindige lus. Zodra de promise resolved is, wordt de template opnieuw gerenderd. Omdat de methode rechtstreeks in de template opgeroepen wordt, wordt deze tijdens het re-renderen opnieuw uitgevoerd. Zodra de promise opnieuw resolved is, wordt de template opnieuw gerenderd en wordt de methode opnieuw uitgevoerd. Dit blijft zich tot in het oneindige herhalen.
export class ObservablePage {
// Niet relevante code weggelaten.
promiseDemo = this.createPromiseDemo()
constructor() {}
async createPromiseDemo(): Promise<string[]> {
await new Promise(resolve => setTimeout(resolve, 10000))
return ['This', 'was', 'returned', 'after', '10', 'seconds', 'by', 'a', 'promise']
}
}<ion-content [fullscreen]="true">
<ion-card>
<ion-card-content>
<ol>
<li *ngFor="let x of promiseDemo | async">{{x}}</li>
</ol>
</ion-card-content>
</ion-card>
</ion-content>Hoewel Observables bedoeld zijn voor asynchrone operaties, verschijnt de data in bovenstaande voorbeelden wel onmiddellijk in de view. Dit is te verwachten, aangezien de Observables enkel data definiëren en door geen enkele asynchrone operatie beïnvloed worden. Voor de Promise is dit niet het geval, hier zit een timeout in, en zoals in onderstaande video getoond, wordt de data pas na 10 seconden weergegeven in de template.
BehaviorSubject
Stel, we willen de layout van de voorbeeldapplicatie uitbreiden zodat deze er beter uitziet op toestellen met een landscape view.

We kunnen eenvoudig een service schrijven die bijhoudt of de applicatie uitgevoerd wordt in landscape of portrait en registreert of de oriëntatie van het scherm wijzigt aan de hand van de ScreenOrientation API.
Browser support
Let op, dit voorbeeld werkt enkel in Chrome. In FireFox wordt de gewijzigde oriëntatie niet gedetecteerd.
We kunnen natuurlijk een instantievariabele toevoegen die de oriëntatie bijhoudt. We kunnen deze instantievariabele echter implementeren met een object van de BehaviorSubject klasse. Deze klasse kan gebruikt worden om een Observable te creëren waarmee elke wijziging gepusht kan worden naar alle subscribers, i.e. componenten. Zo'n object wordt gebruikt om data weer te geven die doorheen de tijd kan veranderen. Natuurlijk voldoet de meeste data aan deze vereisten, het is aan te raden om gebruik te maken van een BehaviorSubject in services, zo wordt het makkelijker om de snelheid van de app te optimaliseren, voor variabelen in een component is dit minder belangrijk, maar ook aan te raden voor variabelen die in de UI uitgelezen worden. Een observable verwittigd alle geabonneerde klassen wanneer de data wijzigt, dit betekent dat Angular niet constant moet pollen of een variabele gewijzigd is. De specifieke details van deze optimalisatie vallen echter buiten de scope van de cursus, voor meer informatie verwijzen we je naar de Angular University.
Een BehaviorSubject moet steeds een initiële waarde krijgen. Via de next() methode kan deze initiële waarde overschreven worden en wordt de nieuwe waarde uitgestuurd naar alle geabonneerde componenten. In de template kunnen we dan eenvoudig abonneren op het BehaviorSubject (na de service the injecteren).
import {Injectable} from '@angular/core';
import {BehaviorSubject} from 'rxjs';
export enum ScreenOrientation {
portrait,
landscape
}
@Injectable({
providedIn: 'root'
})
export class ScreenService {
screenStatus: BehaviorSubject<ScreenOrientation>;
constructor() {
this.screenStatus = new BehaviorSubject<ScreenOrientation>(
ScreenService.getOrientation()
)
screen.orientation.onchange = (_ => {
this.screenStatus.next(ScreenService.getOrientation())
})
}
private static getOrientation(): ScreenOrientation {
return screen.orientation.type.includes('portrait') ?
ScreenOrientation.portrait : ScreenOrientation.landscape
}
}<ion-content [fullscreen]="true">
<ion-card>
<!--Niet relevant code weggelaten of vervangen met ...-->
<ion-card-content>
<ion-grid>
<ion-row>
<ion-col [size]="(screenService.screenStatus | async) === 0 ? 12 : 6">
...
</ion-col>
<ion-col [size]="(screenService.screenStatus | async) === 0 ? 12 : 6">
...
</ion-col>
</ion-row>
<ion-row>
<ion-col [size]="(screenService.screenStatus | async) === 0 ? 12 : 6">
...
</ion-col>
<ion-col [size]="(screenService.screenStatus | async) === 0 ? 12 : 6">
...
</ion-col>
</ion-row>
</ion-grid>
</ion-card-content>
</ion-card>
</ion-content>Deze code produceert het volgende:
Environment variables
De API key voor The One Api moet natuurlijk beschikbaar gemaakt worden in het Angular project, hiervoor gebruiken we de environment files. Een Angular project bevat, in de map src, twee environment files, één voor production en één voor development. Aangezien we de key zowel in de development als production versie willen gebruiken, voegen we deze toe aan beide environment files. Het ionic serve en ionic build commando zullen gebruik maken van de development file, het ionic build --prod commando zal dan gebruik maken van de production file.
Waarschuwing
Kopieer onderstaande API key niet in jouw project, je moet zelf een API key aanvragen. Onderstaande keys dienen slechts ter illustratie.
Waarschuwing
Zoals beschreven in les 4 van frontend frameworks, is het geen goed idee om elke API key client-side te bewaren. Enkel bepaalde keys die hiervoor bedoeld zijn mogen client-side beschikbaar zijn, andere keys plaats je best op de server.
export const environment = {
production: false,
theOneApiKey: '6565dg5qg6qdg98',
}export const environment = {
production: true,
theOneApiKey: '6565dg5qg6qdg98',
}HttpClient
Angular bevat een module HttpClient die methodes bevat om GET, PUT, POST, PATCH, ... requests uit te voeren. De module moet geïmporteerd worden in de app.module waarna de HTTPClient geïnjecteerd kan worden in een nieuwe ApiService. Merk op dat we de API key hier inlezen vanuit de environment files.
import {HttpClientModule} from "@angular/common/http"
@NgModule({
declarations: [AppComponent],
entryComponents: [],
imports: [
BrowserModule, IonicModule.forRoot(), AppRoutingModule,
HttpClientModule
],
providers: [{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy }],
bootstrap: [AppComponent],
})
export class AppModule {}import {inject, Injectable} from '@angular/core'
import {environment} from '../../environments/environment'
import {HttpClient} from '@angular/common/http'
@Injectable({
providedIn: 'root'
})
export class ApiService {
readonly #apiKey = environment.theOneApiKey
readonly #baseURL = 'https://the-one-api.dev/v2'
#http = inject(HttpClient)
constructor() { }
}CORS
Als je tijdens het bouwen van je project op problemen stoot met CORS, kan je gebruik maken van de HTTP plug-in om alle request die via de HTTPClient verstuurd worden te onderscheppen en af te handelen op de Native layer. Dit is natuurlijk geen oplossing voor de webbrowser, omdat het bouwen van een proxy server waarmee je CORS-problemen kan verhelpen buiten de scope van de cursus valt, mag je browser plug-ins gebruiken om CORS lokaal af te zetten. Hier verlies je geen punten mee zolang het request wel werkt op de native app.
We verwijzen de geïnteresseerde lezer door naar de cursus mobile development voor meer informatie over CORS en het bouwen van een proxy server.
Get request
We beginnen met de data van het endpoint '/books' op te halen en te tonen in de view. Dit endpoint is publiek beschikbaar en kan bekeken worden op https://the-one-api.dev/v2/book/. Deze data ophalen is zeer eenvoudig omdat we geen parameters of authenticatie nodig hebben. In het onderstaande definiëren we het responseType als json, dit is de default en wordt in de volgende voorbeelden niet altijd toegevoegd. De mogelijke waarden voor de responseType optie zijn:
arrayBuffer: Datatype voor bewerkbare binaire data.blob: Immutable datatype voor binaire data.text: Plain text.json: JSON objecten.
Via de observe parameter kunnen we aangeven waarin we geïnteresseerd zijn, we zullen in deze cursus uitsluitend gebruik maken van de body optie. De mogelijke opties zijn
body: Enkel de body van het request wordt meegegeven, headers worden genegeerd.response: Het volledige antwoord dat van de server ontvangen is, inclusief headers.events: Een observable stream van alle stappen in het request process, i.e. sent, header response, body response en progress.
export class ApiService {
// Niet relevante code weggelaten.
getBooks() {
return this.#http
.get(
`${this.#baseURL}/book`,
{
observe: 'body',
responseType: 'json',
},
)
}
}Bovenstaande methode werkt, maar we missen nog een return type, om dit toe te voegen moeten we eerst weten hoe de data teruggegeven wordt uit de API. Als we het endpoint bezoeken in de browser zien we onderstaand resultaat.
{
"docs": [
{
"_id": "5cf5805fb53e011a64671582",
"name": "The Fellowship Of The Ring"
},
{
"_id": "5cf58077b53e011a64671583",
"name": "The Two Towers"
},
{
"_id": "5cf58080b53e011a64671584",
"name": "The Return Of The King"
}
],
"total": 3,
"limit": 1000,
"offset": 0,
"page": 1,
"pages": 1
}Ook de twee andere publieke routes (https://the-one-api.dev/v2/book/[id] & https://the-one-api.dev/v2/book/[id]/chapter) volgen dezelfde structuur, we krijgen pagination informatie en een array docs binnen. Deze laatste array bevat steeds andere informatie die we aan de gebruiker willen tonen. Om het returntype van de methode getBooks te definiëren moeten we eerst zelf een nieuw datatype aanmaken. Aangezien de informatie in de docs array steeds een andere structuur heeft, maken we dit type generisch. Dit datatype is eigen aan de gebruikte API, niet elke API gebruikt deze structuur of vereist generische datatypes. Natuurlijk hebben we ook een type nodig dat de boeken weergeeft.
export interface IOneApiResult<T> {
docs: T[]
total: number
limit: number
offset: number
page: number
pages: number
}export interface IBook {
_id: string
name: string
}Vervolgens kunnen we de methode getBooks aanpassen, zodat dit type gebruikt wordt. Merk op, we geven niet enkel een return type mee, maar voegen ook types toe aan de get methode uit de HttpClient. Dit is enkel mogelijk als de parameter responseType de waarde json krijgt.
export class ApiService {
// Niet relevante code weggelaten.
getBooks(): Observable<IOneApiResult<IBook>> {
return this.#http
.get<IOneApiResult<IBook>>(
`${this.#baseURL}/book`,
{
observe: 'body',
responseType: 'json',
},
)
}
}Vervolgens maken we een instantievariabele aan in de BookPage klasse waaraan we het resultaat van de getBooks methode koppelen. Deze variabele kan dan, via een asynchrone pipe, uitgelezen worden in de template.
Waarschuwing
Roep een methode die een API request uitvoert nooit rechtstreeks op in een template. Omwille van de manier waarop Angular change detection werkt, zal dit leiden tot een oneindige lus. Je roept de API zo, heel snel, heel veel keren aan. Voor een betalende API, of een API met een gelimiteerd aantal calls per dag, kan dit tot een grootte rekening of andere problemen leiden.
export class BookPage {
apiService = inject(ApiService)
books = this.apiService.getBooks()
constructor() {}
}Deze property kan niet zomaar gebruikt worden in een async pipe, *ngFor is bedoeld voor arrays, het resultaat van de API call is, zoals hierboven te lezen, een object. Binnen dit object zijn we geïnteresseerd in de docs array. We moeten deze array dus aanspreken in de template, maar omdat de API-call niet noodzakelijk afgewerkt is als we de data properen uit te lezen in de template, kan het zijn dat de array undefined is. Om dit probleem op te lossen kunnen we gebruikt maken van optional chaining.
<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Books</ion-title>
</ion-toolbar>
</ion-header>
<ion-list>
<ion-item *ngFor="let book of (books | async)?.docs">
<ion-label>{{book.name}}</ion-label>
</ion-item>
</ion-list>
</ion-content>RxJS pipes
RxJS voorziet de pipe methode op alle klasse die overerven van de Observable klassen. In deze methode kunnen we één of meer RxJS operatoren gebruiken om data te filteren, te bewerken en om optimalisaties zoals debouncing door te voeren.
Gekende functionele methodes, zoals map, filter, en reduce, zijn beschikbaar om de Observable datastream aan te passen. Alhoewel deze methodes dezelfde naam hebben als de methodes uit Array.prototype, komen deze niet 100% overeen.
We kunnen de pipe functie gebruiken in combinatie met de map operator, om de elementen van het type OneApiResult<Book> om te vormen naar een array van Book elementen. Merk op dat de map operator ook generisch is, we kunnen dus uitdrukkelijk aangeven welke conversies er uitgevoerd worden. De code in de template kan nu veel leesbaarder geschreven worden.
export class ApiService {
// Niet relevante code weggelaten.
getBooks(): Observable<IBook[]> {
return this.#http
.get<IOneApiResult<IBook>>(
`${this.#baseURL}/book`,
{
observe: 'body',
responseType: 'json',
},
).pipe(
map<IOneApiResult<IBook>, IBook[]>(o => o.docs),
)
}
}<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Books</ion-title>
</ion-toolbar>
</ion-header>
<ion-list>
<ion-item *ngFor="let book of books | async">
<ion-label>{{book.name}}</ion-label>
</ion-item>
</ion-list>
</ion-content>
Authenticatie via Headers
De andere endpoints op The One API zijn beveiligd met een API key die meegestuurd moet worden in de headers van het request. De HttpClient bevat een configuratieparameter header die een object met headers verwacht als waarde. De klasse ICharacter is te vinden in de startbestanden.
import {HttpClient, HttpHeaders} from '@angular/common/http'
export class ApiService {
// Niet relevante code weggelaten.
getCharacters(): Observable<ICharacter[]> {
return this.#http
.get<IOneApiResult<ICharacter>>(
`${this.#baseURL}/character`,
{
observe: 'body',
headers: new HttpHeaders({
authorization: `Bearer ${this.#apiKey}`,
}),
},
).pipe(
map<IOneApiResult<ICharacter>, ICharacter[]>(x => x.docs),
)
}
}Stel er loop hier iets mis en we willen deze error uitprinten, dan kunnen we natuurlijk een try-catch gebruiken. Maar ook voor deze situatie biedt RxJS een oplossingen. Via de catchError operator kunnen we een fout opvangen en een alternatieve returnwaarde toevoegen.
Of wat als we met een slechte verbinding te maken krijgen en we even niet meer verbonden zijn met het internet? In zulke situaties kunnen we de retry operator gebruiken om het request een aantal keren opnieuw uit te voeren.
import {catchError, map, Observable, of, retry} from 'rxjs'
export class ApiService {
// Niet relevante code weggelaten.
getCharacters(): Observable<ICharacter[]> {
return this.#http
.get<IOneApiResult<ICharacter>>(
`${this.#baseURL}/character`,
{
observe: 'body',
headers: new HttpHeaders({
authorization: `Bearer ${this.#apiKey}`,
}),
},
).pipe(
map<IOneApiResult<ICharacter>, ICharacter[]>(x => x.docs),
catchError(err => {
console.log(err)
return of([])
}),
retry(3),
)
}
}Parameters
De API bevat een overzicht van 933 personages, dit is relatief veel om in één keer op te halen. Als je grotere hoeveelheden data ophaalt, is het, zeker op een mobiele app, een goed idee om pagination te gebruiken. We bouwen de view zo dat er 50 personages per pagina teruggeven worden. Natuurlijk is een echte pagina, waartussen gebladerd kan worden, niet ideaal voor een mobiele applicaties. Gelukkig voorziet Ionic een component <ion-infinite-scroll>, waarmee we gemakkelijk extra data kunnen toevoegen als de gebruiker het einde bereikt heeft. Ook de API bevat parameters om pagination toe te passen.
Dit brengt echter wel een probleem met zich mee, de Observable kan niet meer rechtstreeks gebruikt worden in de async pipe, dit zou tenslotte betekenen dat we de vorige pagina steeds overschrijven. In de plaats gebruiken we een instantievariabele waarin we een array van personages bewaren. Deze wordt dan steeds uitgebreid met personages als de gebruiker de volgende pagina laadt.
Om de datastream uit een Observable te halen, kunnen we natuurlijk gebruik maken van de subscribe methode. Dit betekent wel dat we weer met potentiële memory leaks zitten. Als alternatief kunnen we de Observable converteren naar een Promise. Dit kan door de Observable mee te geven als argument aan de firstValueFrom methode die geëxporteerd wordt door RxJS.
Het request moet natuurlijk aangepast worden zodat de verwachte pagina meegegeven wordt. Dit gebeurt via parameters in de URL waarop we de API raadplegen. De documentatie van The One Api geeft duidelijk aan welke parameters nodig zijn.
Vervolgens schrijven een methode die de nieuwe pagina ophaalt en deze toevoegt aan de lokale cache, deze methode moet dan wel opgeroepen worden tijdens het construction process.
export class ApiService {
// Niet relevante code weggelaten.
getCharacters(page: number): Observable<IOneApiResult<ICharacter> | null> {
return this.#http
.get<IOneApiResult<ICharacter>>(
`${this.#baseURL}/character`,
{
observe: 'body',
headers: new HttpHeaders({
Authorization: `Bearer ${this.#apiKey}`,
}),
params: {
limit: 50,
page,
},
},
)
.pipe(
catchError(error => {
console.error(error)
return of(null)
}),
retry(3),
)
}
}import {firstValueFrom} from 'rxjs'
export class CharacterPage {
characters: Character[] = [];
#currentPage = 1;
// The total number of pages in the data.
#pages: number = undefined;
constructor(private apiService: ApiService) {
this.#retrievePage().then()
}
async #retrievePage(): Promise<void> {
const result = await
firstValueFrom(this.apiService.getCharacters(this.#currentPage))
if (result) {
this.#currentPage++
this.#pages = result.pages
this.characters.push(...result.docs)
}
}
}De event handler voor het ionInfinite event moet de volgende pagina laden en, zodra alle pagina's geladen zijn, niets meer doen. We voegen dus nog een methode toe aan dde CharacterPage klasse die dit doet. Let op, het is hier belangrijk dat de complete methode opgeroepen worden, anders blijft de loading animatie zichtbaar en kan je slechts één keer een nieuwe pagina laden.
export class CharacterPage {
@ViewChild(IonInfiniteScroll) infiniteScroll?: IonInfiniteScroll
characters: ICharacter[] = []
#currentPage = 1
#pages?: number = undefined
async loadData(event: any): Promise<void> {
await this.#retrievePage()
event.target.complete()
// Logic to determine if all data is loaded
// and disable the infinite scroll
if (this.#currentPage === this.#pages) {
event.target.disabled = true
}
}
}<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Characters</ion-title>
</ion-toolbar>
</ion-header>
<ion-list>
<ion-item *ngFor="let c of characters">
<ion-avatar slot="start">
<img src="/assets/avatar.svg">
</ion-avatar>
<ion-label>
<h2>{{c.name}}</h2>
<p *ngIf="c.realm !== ''">{{c.race}}</p>
<p *ngIf="c.realm === ''">{{c.race}}</p>
</ion-label>
</ion-item>
</ion-list>
<ion-infinite-scroll threshold="100px" (ionInfinite)="loadData($event)">
<ion-infinite-scroll-content loadingSpinner="bubbles"
loadingText="Loading more characters...">
</ion-infinite-scroll-content>
</ion-infinite-scroll>
</ion-content>Notitie
Infinite scroll op zich is niet voldoende om de app performant te maken, het is ook nodig om virtual scrolling te gebruiken zodat DOM-elementen gerecycleerd worden en er geen duizenden elementen in de DOM zitten. We verwijzen de geïnteresseerde lezer door naar de Ionic documentatie.
Debouncing
Om de personages te filteren door middel van een zoekbalk kunnen we natuurlijk na elk onChange een nieuw request uitvoeren. Dit is echter heel inefficient. Het onChange event wordt uitgevoerd na elke wijziging, ook als er maar één karakter bijkomt. Als de API slechts een beperkt aantal calls aanbied, is dit allesbehalve een goed idee. Het voor de hand liggende alternatief, een knop waarop de gebruiker moet drukken om de zoekactie te starten, is natuurlijk ook niet ideaal vanuit een UX-oogpunt.
Debouncing kan eenvoudig via Ionic geïmplementeerd worden, elk Ionic form element heeft een attribuut debounce dat de tijd in milliseconden aangeeft voordat een ionChange event afgevuurd wordt. Deze timer wordt gereset elke keer dat er een wijziging gebeurt in het formulier, zo wordt een change event slechts één keer afgevuurd, na de laatste wijziging. Dit attribuut heeft eveneens invloed op ngModel bindings. Debouncing kan ook via RxJS geïmplementeerd worden, maar aangezien dit heel wat complexer is, en er een eenvoudiger alternatief bestaat, kiezen we voor Ionic.
In de template activeren we debouncing eenvoudig via de debounce property. Vervolgens kunnen we in de TypeScript file een instantievariabele searchText en een change handler searchChangeHandler toevoegen en de retrievePage methode uitbreiden met een optionele parameter die op true gezet wordt als een nieuwe zoekterm gebruikt wordt. Als deze parameter op true staat moet de pagina gereset worden en moeten de zoekresultaten vervangen worden in de plaats van uitgebreid.
<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Characters</ion-title>
</ion-toolbar>
</ion-header>
<ion-searchbar [value]="searchText"
debounce="500" (ionChange)="searchChangeHandler($event)">
</ion-searchbar>
<!-- Niet relevante code weggelaten -->
</ion-content>export class CharacterPage {
characters: Character[] = []
#currentPage = 1
#pages?: number = undefined
searchText = ''
// Niet relevante code weggelaten.
async searchChangeHandler(event: any): Promise<void> {
this.searchText = event.target.value
await this.#retrievePage(true)
}
async #retrievePage(reset = false): Promise<void> {
if (reset) {
this.#currentPage = 1
}
const result = await
firstValueFrom(this.apiService.getCharacters(this.#currentPage, this.searchText))
if (result) {
this.#currentPage++
this.#pages = result.pages
this.characters = reset ? result.docs : [...this.characters, ...result.docs]
}
}
}Tenslotte moeten we de APIService aanpassen zodat er een zoekterm meegegeven kan worden. We stellen deze standaard in op een lege string, zo kan de methode ook zonder zoekterm gebruikt worden. De constructie op lijn 16 is een reguliere expressie die elke string matcht die begint met de filter en daarna nog een willekeurig aantal andere karakters bevat.
export class ApiService {
// Niet relevante code weggelaten.
getCharacters(page: number, filter: string = ''): Observable<IOneApiResult<ICharacter> | null> {
return this.#http
.get<IOneApiResult<ICharacter>>(
`${this.#baseURL}/character`,
{
observe: 'body',
headers: new HttpHeaders({
Authorization: `Bearer ${this.#apiKey}`,
}),
params: {
limit: 50,
page,
name: `/^${filter}.*/i`,
},
},
)
.pipe(
catchError(error => {
console.error(error)
return of(null)
}),
retry(3),
)
}
}