Skip to main content

Chris Opstrup

Professional software engineer

  • RxJS

Vis en forsinket loading-spinner med RxJS

Publiceret

1. maj 2020

Rør

Foto af Samuel Sianipar på Unsplash.

I dag på arbejdet skulle jeg lave en loading-spinner, der først vises efter en kort forsinkelse. Ideen er enkel: Hvis en HTTP-request tager mere end fx xx ms, skal spinneren vises. Hvis requesten er hurtigere, skal spinneren slet ikke nå at dukke op. Da opgaven var i Angular, valgte jeg at bruge RxJS til implementeringen. Eksemplet her bruger dog kun RxJS, så det kan genbruges i alle setups, hvor du arbejder med observables.

Først skal vi have en observable, der styrer om spinneren skal være synlig. Her bruger jeg et Subject, så vi selv kan skubbe nye værdier ind. Det kunne lige så godt være en observable fra dit foretrukne state management-bibliotek.

const loading$ = new Subject();
// vis spinner
loading$.next(true);
// skjul spinner
loading$.next(false);

Derefter skal vi reagere på værdierne fra loading$:

loading$.subscribe((loading) => {
  if (loading) {
    showSpinner();
  } else {
    hideSpinner();
  }
});

Det virker, men vi mangler selve forsinkelsen. Koden viser og skjuler spinneren med det samme, hver gang loading$ emitterer.

Brug af delay-operatoren

Det er i sig selv nemt at forsinke en emission. RxJS har en delay-operator til formålet.

const delayedLoading$ = loading$.pipe(delay(900));
delayedLoading$.subscribe((loading) => {
  if (loading) {
    showSpinner();
  } else {
    hideSpinner();
  }
});

Nu har vi en observable, der emitterer 900 ms efter loading$. Problemet er, at forsinkelsen gælder både true og false. Det betyder, at spinneren stadig kan nå at blive vist, selv hvis loading bliver false, før de 900 ms er gået. I praksis kan du få et kort “blink”. Hvis vi kun vil forsinke visningen, skal vi kunne annullere handlingen. Lad os først se den klassiske løsning med setTimeout og clearTimeout.

let timerId;
loading$.subscribe((loading) => {
  if (loading) {
    // Forsink visning af spinner efter 900 ms
    timerId = setTimeout(() => {
      showSpinner();
    }, 900);
  } else {
    // Ryd timeout, dette vil effektivt forhindre showSpinner i at blive kaldt, hvis mindre end 900 ms er gået.
    clearTimeout(timerId);
    // Skjul spinner, hvis mere end 900 ms er gået, skal denne metode kunne håndtere at blive kaldt, selvom spinner ikke er synlig
    hideSpinner();
  }
});

Ovenstående virker, men det er ikke særlig elegant. Vi blander observables med setTimeout, og vi introducerer en variabel uden for det sted, hvor logikken egentlig hører hjemme.

Heldigvis har observables en indbygget måde at “annullere” igangværende arbejde på. I praksis gør vi det ved at skifte til en ny observable, og her er switchMap perfekt. switchMap modtager værdien fra source-observable og returnerer en ny observable. Hvis der kommer en ny værdi undervejs, afmeldes den tidligere strøm automatisk. Se eksemplet:

const delayedLoading$ = loading$.pipe(
  switchMap((loading) => {
    if (loading) {
      // Opret en ny observable, der udsender efter 900 ms
      return of(true).pipe(delay(900));
    }
    // Opret en ny observable, der udsender øjeblikkeligt
    return of(false);
  }),
);
delayedLoading$.subscribe((loading) => {
  if (loading) {
    showSpinner();
  } else {
    hideSpinner();
  }
});

Det er allerede langt bedre end setTimeout-versionen: ingen variabler i ydre scope og kun observables hele vejen. Vi kan gøre den en smule mere kompakt ved at bruge den betingede operator iif i stedet for if/else.

const delayedLoading$ = loading$.pipe(
  switchMap((loading) =>
    iif(() => loading, of(loading).pipe(delay(900)), of(loading)),
  ),
);
delayedLoading$.subscribe((loading) => {
  if (loading) {
    showSpinner();
  } else {
    hideSpinner();
  }
});

Brug af debounceTime-operatoren

Der er også en alternativ løsning med debounceTime. Jeg tager den til sidst, fordi den har en lille bagside, men for mange er det måske helt fint. Implementeringen er enklere, men både visning og skjuling bliver debounced. Det vil sige, at skjul også forsinkes. Jeg foretrækker ofte at skjule med det samme, men hvis du vil holde løsningen enkel, kan det stadig være et godt valg:

const debouncedLoading$ = loading$.pipe(debounceTime(900));
debouncedLoading$.subscribe((loading) => {
  if (loading) {
    showSpinner();
  } else {
    hideSpinner();
  }
});

Her bruger vi kun debounceTime og undgår både switchMap og if/else. Men som nævnt betyder det også, at hvis spinneren allerede er synlig, går der 900 ms, før hideSpinner kaldes.

Begge tilgange løser problemet, men med forskellige trade-offs. delay + switchMap giver mere kontrol, men er lidt mere kompleks. debounceTime er meget enkel, men mindre fleksibel. Vælg den løsning, der passer bedst til dit behov.

Kontakt mig

Har du et projekt i tankerne, eller vil du bare tale tech? Skriv til mig via formularen herunder.

Felter markeret med * er obligatoriske