Les 5: HTTPRequests & Observables

Sebastiaan HenauOngeveer 15 minuten

Les 5: HTTPRequests & Observables

Reactive Extensionsopen in new window is een bibliotheek die een API definieert voor asynchrone en event-driven code via Observables. Een Observable, zoals gedefinieerd door The Gang of Fouropen in new window, 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 JavaScriptopen in new window 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 en hoe we kunnen communiceren met een externe service via HTTP en het resultaat via RxJS kunnen verwerken. We beperken ons tot het downloaden van data, maar deze kennis kan, indien nodig, gemakkelijk uitgebreid worden naar POST, PUT, PATCH, en DELETE requests. We demonstreren de mogelijkheden van RxJS aan de hand van een applicatie waarmee de data van The One APIopen in new window 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 APIopen in new window is, zoals de meeste APIs beveiligd. Om deze API te gebruiken (en het lesvoorbeeld uit te voeren), moet je dus een gratis API key aanvragen.

Voor deze les vertrekken we van startbestandenopen in new window.

Observables, Promises & de async pipe

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. We zullen hier volgende les dan ook gebruik van maken om een real-time messaging app te bouwen.

Of

Een observable gebruiken is relatief eenvoudig, via de of methode kunnen we er eenvoudig één aanmaken. De argumenten van de of methode worden één per één uitgestuurd.

Om de informatie uit deze observables te tonen in de template, kunnen we geen gebruik maken van de *ngFor, zoals we gewoon zijn.

import {Observable, of} from 'rxjs';

export class ObserversPage {
  ofDemo1: Observable<string> = of('This', 'is', 'an', 'example', 'observable');
  ofDemo2: Observable<string[]> = of(['This', 'is', 'an', 'example', 'observable']);

 constructor() {}
}

Momenteel krijgen we het onderstaande te zien als we de applicatie starten.

Figuur 1: Errors met het weergeven van Observables

Zoals uit bovenstaand screenshot duidelijk is, werkt deze code niet. In de console staat onderstaande foutmelding.

Uncaught (in promise): Error: Cannot find a differ supporting object '[object Object]' of type ' object'. NgFor only supports binding to Iterables such as Arrays.

Omdat observables asynchrone operaties voorstellen moeten we aan Angular laten weten dat de data asynchroon is zodat Angular de data correct kan verwerken. Door middel van de async pipe kunnen observables 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>









 







 








Figuur 2: Async pipe

From

Naast de of functie bestaat ook de from functie. Deze kan gebruikt worden een sequentie van elementen één per één uit te sturen. Elk element van de array wordt dus één per één uitgestuurd. Dit gaat zo snel dat we enkel het laatste element zien in de UI. We kunnen de data in deze observable opnieuw tonen in de UI via de async pipe.

import {from, Observable, of} from 'rxjs';

export class ObserversPage {
  // Niet relevante code weggelaten.  
  fromDemo1: Observable<string> = 
          from(['This', 'is', 'an', 'example', 'observable']);

  constructor() {}
}
Figuur 3: Async pipe

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 Subscriptionopen in new window object dat teruggegeven wordt door de subscribeopen in new window 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.

export class ObserversPage 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());
    }
}
 











 
 



 


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 deze 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 ObserversPage {
  // 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'];
  }
}


 



 
 
 
 

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.

Figuur 4: De async pipe in gebruik

BehaviorSubject

Stel, we willen de layout van de voorbeeldapplicatie uitbreiden zodat deze er beter uitziet op toestellen met een landscape view.

Figuur 5: Voorgestelde layout

We kunnen eenvoudig een service schrijven die bijhoud of de applicatie uitgevoerd wordt in landscape of portrait, en registreert of de oriëntatie van het scherm wijzig aan de hand van de ScreenOrientation APIopen in new window.

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 bijhoud. We kunnen deze instantievariabele echter ook vervangen met een object van de BehaviorSubjectopen in new window 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. 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 Universityopen in new window.

Een BehaviorSubject moet steeds een initiële waarde krijgen, via de next() methode kan een nieuwe waarde uitgestuurd worden 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;
  }
}












 


 
 
 

 








Deze code produceert het volgende:

Figuur 6: Wijzigingen in layout

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 (voor deze cursus) geen onderscheid maken tussen testing en production, plaatsen we dezelfde configuratie in de development en production files. Als je een commerciële app ontwikkeld, is het waarschijnlijk een goed idee om tijdens het ontwikkelen een aparte database te gebruiken. 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.

export const environment = {
    production: false,
    theOneApiKey: '6565dg5qg6qdg98'
};

HttpClient

Angular bevat een module HttpClient. Deze module bevat methodes om GET, PUT, POST, PATCH, ... requests uit te voeren. De module moet geïmporteerd worden in de app.module. De client module kan vervolgens geïnjecteerd 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 {}
 






 





CORS

Als je tijdens het bouwen van je project op problemen stoot met CORSopen in new window, kan je gebruik maken van de HTTP plug-inopen in new window 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.

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/open in new window. Deze data ophalen is zeer eenvoudig omdat we geen parameters en geen 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'
        }
      );
  }
}

Bovenstaand request 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 browseropen in new window 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]open in new window & https://the-one-api.dev/v2/book/[id]/chapteropen in new window) volgen dezelfde structuur, we krijgen pagination informatie en een array docs binnen. Deze laatste array bevat steeds andere informatie. 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 OneApiResult<T> {
  docs: T[];
  total: number;
  limit: number;
  offset: number;
  page: number;
  pages: number;
}
 
 






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<OneApiResult<Book>> {
    return this.http
      .get<OneApiResult<Book>>(
        `${this.#baseURL}/book`,
        {
          observe: 'body',
          responseType: 'json'
        }
      );
  }
}



 

 








Om het request te gebruiken, kunnen we vervolgens een instantievariabele aanmaken in een TypeScript file en deze gebruiken in een asynchrone pipe in de bijhorende 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 leiden of andere problemen leiden.

export class BooksPage {
  books = this.apiService.getBooks();

  constructor(public apiService: ApiService) {}
}

 



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. Deze array is echter niet altijd gedefinieerd omdat de API niet noodzakelijk afgewerkt is als we de array proberen uit te lezen. 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 een methode pipeopen in new window die opgeroepen kan worden op een Observable (en alle overervende klassen, zoals BehaviorSubject). In deze methode kunnen we RxJS operatorenopen in new window gebruiken om data te filteren, te bewerken, en om optimalisaties, zoals debouncing, door te voeren.

Gekende functionele array methodes, zoals map, filter, en reduce, zijn beschikbaar om de Observable datastream aan te passen. 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<Book[]> {
    return this.http
      .get<OneApiResult<Book>>(
        `${this.#baseURL}/book`,
        {
          observe: 'body',
          responseType: 'json'
        }
      )
      .pipe(
        map<OneApiResult<Book>, Book[]>(o => o.docs)
      );
  }
}



 








 
 
 


Figuur 7: De boeken opgehaald, via een HTTP GET request

Authenticatie via Headers

De andere endpoints op The One API zijn beveilig met een API key, deze moet meegestuurd worden in de headers van het request. De HttpClient bevat een configuratieparameter header, deze neemt een object met headers als argument. De klasse Character is te vinden in de startbestanden.

export class ApiService {
  // Niet relevante code weggelaten.

  getCharacters(): Observable<Character[]> {
    return this.http
      .get<OneApiResult<Character>>(
        `${this.#baseURL}/character`,
        {
          observe: 'body',
          headers: new HttpHeaders({
            authorization: `Bearer ${this.#apiKey}`
          })
        }
      ).pipe(
        map<OneApiResult<Character>, Character[]>(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 bied 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.

export class ApiService {
  // Niet relevante code weggelaten.

  getCharacters(): Observable<Character[] | null> {
    return this.http
      .get<OneApiResult<Character>>(
        `${this.#baseURL}/character`,
        {
          observe: 'body',
          headers: new HttpHeaders({
            authorization: `Bearer ${this.#apiKey}`
          })
        }
      ).pipe(
        map<OneApiResult<Character>, Character[]>(x => x.docs),
        catchError(err => {
          console.log(err);
          return of(null);
        }),
        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>open in new window, waarmee we gemakkelijk extra data kunnen toevoegen als de gebruiker het einde bereikt heeft. Ook de APIopen in new window 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 laad.

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 foor RxJS.

We schrijven een methode die de nieuwe pagina ophaalt en deze toevoegt aan de lokale cache. We moeten deze methode dan natuurlijk wel oproepen tijdens het construction process, anders wordt er geen data getoond.

import {firstValueFrom} from 'rxjs';

export class CharactersPage {
  characters: Character[] = [];
  #currentPage = 1;
  // The total number of pages in the data.
  #pages: = 1;

  constructor(private apiService: ApiService) {
    this.retrievePage();
  }

  async #retrievePage(): Promise<void> {
    const result = await 
        firstValueFrom(this.apiService.getCharacters(this.#currentPage, this.searchText));
        
    if (!result) {
      this.#pages = this.#currentPage;
      return;
    }
    
    this.#currentPage++;
    this.#pages = result.pages;
    this.characters.push(...result.docs);
  }
}
 











 

 











Het request kan nu aangepast worden zodat de currentPage meegegeven wordt en dat er pagination gebruikt wordt. Dit gebeurt via parameters in de URL waarop we de API raadplegen. Deze kunnen opnieuw als een JSON-object meegegeven worden. De documentatieopen in new window van The One Api geeft duidelijk aan welke parameters nodig zijn. We kunnen vervolgens <ion-infinite-scroll> implementeren in de template.

export class ApiService {
  // Niet relevante code weggelaten.
    
  getCharacters(page: number): Observable<OneApiResult<Character> | null> {
    return this.http
      .get<OneApiResult<Character>>(
        `${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)
      );
  }
}



 








 
 
 
 











De event handler voor het ionInfinite event moet de volgende pagina laden en, zodra alle pagina's geladen zijn, niets meer doen. 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 CharactersPage {
    @ViewChild(IonInfiniteScroll) infiniteScroll: IonInfiniteScroll;
    #currentPage = 1;
    #pages: number = undefined;

    async loadData(event): Promise<void> {
        await this.#retrievePage();
        event.target.complete();

        // App logic to determine if all data is loaded
        // and disable the infinite scroll
        if (this.#currentPage === this.#pages) {
            event.target.disabled = true;
        }
    }
}







 








Figuur 8: Infinite scroll

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 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 kunnen we debouncing als volgt activeren. 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.

<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>







 
 



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. Deze matcht elke string 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<OneApiResult<Character> | null> {
    return this.http
      .get<OneApiResult<Character>>(
        `${this.#baseURL}/character`,
        {
          observe: 'body',
          headers: new HttpHeaders({
            authorization: `Bearer ${this.#apiKey}`
          }),
          params: {
            limit: 50,
            page,
            name: `/^${filter}.*/i`
          }
        }
      ).pipe(
        catchError(err => {
          console.log(err);
          return of(null);
        }),
        retry(3)
      );
  }
}



 











 











Figuur 9: Zoeken, met een debounce tijd van 500ms

Voorbeeldcode & samenvatting

Volledig uitgewerkte lesvoorbeelden met commentaaropen in new window

Laatst geüpdate:
Bijdragers: Sebastiaan Henau