Kurs Angular · Moduł 7: RxJS

Operatory RxJS - Techniki Ninja

2 min czytania ·
W tej lekcji3

Operatory to czyste funkcje transformujące strumienie - jak różne techniki ninja zmieniające sposób przekazywania informacji.

Operatory Transformacji

import { map, switchMap, mergeMap, concatMap, exhaustMap } from 'rxjs/operators';

// map - transformuje każdy element
source$.pipe(
  map(value => value * 2)
);

// switchMap - przełącza na nowy strumień, anuluje poprzedni
// Idealne dla wyszukiwarki (anuluj poprzednie zapytanie)
searchInput$.pipe(
  switchMap(query => this.http.get(`/search?q=${query}`))
);

// mergeMap - równoległe wykonanie wszystkich
// Dobre gdy kolejność nie ma znaczenia
ids$.pipe(
  mergeMap(id => this.http.get(`/item/${id}`))
);

// concatMap - sekwencyjne, zachowuje kolejność
// Gdy kolejność jest ważna
actions$.pipe(
  concatMap(action => this.saveAction(action))
);

// exhaustMap - ignoruje nowe do zakończenia bieżącego
// Idealne dla przycisków zapisu (zapobieganie podwójnym kliknięciom)
saveButton$.pipe(
  exhaustMap(() => this.saveData())
);

Operatory Filtrujące

import { filter, take, takeUntil, takeWhile, distinctUntilChanged, debounceTime } from 'rxjs/operators';

// filter - przepuszcza tylko pasujące
numbers$.pipe(
  filter(n => n > 5)
);

// take - bierze n pierwszych elementów
numbers$.pipe(
  take(3)  // Tylko pierwsze 3
);

// distinctUntilChanged - usuwa powtórzenia z rzędu
values$.pipe(
  distinctUntilChanged()  // [1,1,2,2,3,1] -> [1,2,3,1]
);

// debounceTime - czeka na pauzę w emisjach
searchInput$.pipe(
  debounceTime(300)  // Czeka 300ms bez nowych wartości
);

Operatory Kombinujące

import { combineLatest, forkJoin, merge, concat, zip } from 'rxjs';

// combineLatest - emituje gdy którykolwiek emituje (po pierwszych emisjach)
combineLatest([user$, settings$]).subscribe(([user, settings]) => {
  console.log(user, settings);
});

// forkJoin - czeka na zakończenie wszystkich (jak Promise.all)
forkJoin([
  this.http.get('/api/samurai'),
  this.http.get('/api/weapons')
]).subscribe(([samurai, weapons]) => {
  // Oba zakończone
});

// merge - łączy strumienie równolegle
merge(clicks$, keyups$).subscribe(event => {
  console.log('Zdarzenie:', event);
});
Kod do tej lekcji: src/app/app.component.ts
import { Component, signal, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Observable, Subject, of, from, timer, combineLatest, forkJoin } from 'rxjs';
import { map, delay, takeUntil, mergeMap, switchMap, concatMap, exhaustMap, catchError } from 'rxjs/operators';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="operators-dojo">
      <h1>Dojo Operatorów RxJS</h1>

      <section class="transformation">
        <h2>Transformacja (map, mergeMap, switchMap)</h2>

        <div class="demo-box">
          <h3>Map - transformacja wartości</h3>
          <button (click)="demoMap()">Uruchom Map</button>
          <div class="result">{{ mapResult() }}</div>
        </div>

        <div class="demo-box">
          <h3>MergeMap - wszystkie równolegle</h3>
          <button (click)="demoMergeMap()">Uruchom MergeMap</button>
          <div class="result">
            @for (item of mergeMapResults(); track $index) {
              <span class="tag">{{ item }}</span>
            }
          </div>
        </div>

        <div class="demo-box">
          <h3>SwitchMap - anuluj poprzedni</h3>
          <button (click)="demoSwitchMap()">Kliknij szybko!</button>
          <div class="result">
            Kliknięcia: {{ switchClickCount() }}<br>
            Wynik: {{ switchMapResult() }}
          </div>
        </div>
      </section>

      <section class="combination">
        <h2>Kombinacja (combineLatest, forkJoin)</h2>

        <div class="demo-box">
          <h3>CombineLatest - ostatnie z każdego</h3>
          <div class="sliders">
            <label>
              Siła: {{ strength() }}
              <input type="range" min="1" max="100" [value]="strength()"
                     (input)="onStrengthChange($event)">
            </label>
            <label>
              Zręczność: {{ agility() }}
              <input type="range" min="1" max="100" [value]="agility()"
                     (input)="onAgilityChange($event)">
            </label>
          </div>
          <div class="result">
            Łączna moc: {{ combinedPower() }}
          </div>
        </div>

        <div class="demo-box">
          <h3>ForkJoin - czekaj na wszystkie</h3>
          <button (click)="demoForkJoin()" [disabled]="forkJoinLoading()">
            {{ forkJoinLoading() ? 'Ładowanie...' : 'Pobierz wszystkie dane' }}
          </button>
          <div class="result">
            @if (forkJoinResult()) {
              <div>Samuraje: {{ forkJoinResult()?.samurai }}</div>
              <div>Broń: {{ forkJoinResult()?.weapons }}</div>
              <div>Misje: {{ forkJoinResult()?.missions }}</div>
            }
          </div>
        </div>
      </section>

      <section class="error-handling">
        <h2>Obsługa błędów (catchError)</h2>

        <div class="demo-box">
          <h3>CatchError - obrona przed błędami</h3>
          <button (click)="demoWithError()">Symuluj błąd API</button>
          <div class="result" [class.error]="hasError()">
            {{ errorResult() }}
          </div>
        </div>
      </section>
    </div>
  `,
  styles: [`
    .operators-dojo {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #2d1b4e 0%, #1a1a2e 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #a855f7;
    }

    h2 {
      background: #a855f7;
      color: white;
      padding: 10px;
      border-radius: 5px;
    }

    section {
      margin: 20px 0;
    }

    .demo-box {
      background: rgba(255,255,255,0.1);
      padding: 15px;
      border-radius: 10px;
      margin: 10px 0;
    }

    .demo-box h3 {
      margin-top: 0;
      color: #a855f7;
    }

    button {
      padding: 10px 20px;
      background: #a855f7;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
      margin: 5px 0;
    }

    button:hover:not(:disabled) {
      background: #c084fc;
    }

    button:disabled {
      opacity: 0.5;
      cursor: not-allowed;
    }

    .result {
      background: rgba(0,0,0,0.3);
      padding: 10px;
      border-radius: 5px;
      margin-top: 10px;
      min-height: 30px;
    }

    .result.error {
      border: 2px solid #ef4444;
      color: #fca5a5;
    }

    .tag {
      display: inline-block;
      background: #a855f7;
      padding: 3px 8px;
      border-radius: 3px;
      margin: 2px;
      font-size: 0.9em;
    }

    .sliders {
      display: flex;
      flex-direction: column;
      gap: 10px;
    }

    .sliders label {
      display: flex;
      align-items: center;
      gap: 10px;
    }

    .sliders input[type="range"] {
      flex: 1;
    }
  `]
})
export class AppComponent implements OnDestroy {
  private destroy$ = new Subject<void>();
  private switchClick$ = new Subject<void>();

  // Map demo
  mapResult = signal('');

  // MergeMap demo
  mergeMapResults = signal<string[]>([]);

  // SwitchMap demo
  switchClickCount = signal(0);
  switchMapResult = signal('');

  // CombineLatest demo
  strength = signal(50);
  agility = signal(50);
  combinedPower = signal(100);

  // ForkJoin demo
  forkJoinLoading = signal(false);
  forkJoinResult = signal<any>(null);

  // Error handling demo
  hasError = signal(false);
  errorResult = signal('Kliknij przycisk, aby przetestować');

  constructor() {
    // SwitchMap - anuluje poprzednie zapytanie
    this.switchClick$.pipe(
      takeUntil(this.destroy$),
      switchMap(() => {
        const clickNum = this.switchClickCount();
        return timer(2000).pipe(
          map(() => `Ukończono dla kliknięcia #${clickNum}`)
        );
      })
    ).subscribe(result => this.switchMapResult.set(result));
  }

  ngOnDestroy(): void {
    this.destroy$.next();
    this.destroy$.complete();
  }

  demoMap(): void {
    const samurai = ['Musashi', 'Kojiro', 'Jubei'];

    from(samurai).pipe(
      map(name => `Samuraj: ${name.toUpperCase()}`)
    ).subscribe({
      next: val => this.mapResult.set(val),
      complete: () => this.mapResult.update(v => v + '')
    });
  }

  demoMergeMap(): void {
    this.mergeMapResults.set([]);

    const missions = ['Zwiad', 'Obrona', 'Atak'];

    from(missions).pipe(
      mergeMap(mission =>
        of(`${mission} - zakończono`).pipe(
          delay(Math.random() * 2000)
        )
      )
    ).subscribe(result => {
      this.mergeMapResults.update(arr => [...arr, result]);
    });
  }

  demoSwitchMap(): void {
    this.switchClickCount.update(c => c + 1);
    this.switchMapResult.set('Przetwarzanie...');
    this.switchClick$.next();
  }

  onStrengthChange(event: Event): void {
    const value = +(event.target as HTMLInputElement).value;
    this.strength.set(value);
    this.updateCombinedPower();
  }

  onAgilityChange(event: Event): void {
    const value = +(event.target as HTMLInputElement).value;
    this.agility.set(value);
    this.updateCombinedPower();
  }

  private updateCombinedPower(): void {
    this.combinedPower.set(this.strength() + this.agility());
  }

  demoForkJoin(): void {
    this.forkJoinLoading.set(true);
    this.forkJoinResult.set(null);

    forkJoin({
      samurai: of(5).pipe(delay(1000)),
      weapons: of(12).pipe(delay(1500)),
      missions: of(3).pipe(delay(800))
    }).subscribe(result => {
      this.forkJoinResult.set(result);
      this.forkJoinLoading.set(false);
    });
  }

  demoWithError(): void {
    this.hasError.set(false);
    this.errorResult.set('Wysyłanie zapytania...');

    const failingApi$ = new Observable(subscriber => {
      setTimeout(() => {
        subscriber.error(new Error('Serwer niedostępny!'));
      }, 1000);
    });

    failingApi$.pipe(
      catchError(error => {
        this.hasError.set(true);
        return of(`Błąd przechwycony: ${error.message} - używam danych zastępczych`);
      })
    ).subscribe(result => this.errorResult.set(result as string));
  }
}

Widzisz błąd w tej lekcji?

Sprawdź się

Odpowiedz na pytania z tej lekcji. Wybierz odpowiedź, a od razu zobaczysz, czy jest poprawna.

  1. 1. Jaka jest główna różnica między switchMap a mergeMap?

  2. 2. Co robi operator map() w RxJS?

To 2 z 5 pytań do tej lekcji. Pozostałe rozwiążesz w grze.

Zadania praktyczne w grze

  • Układanie w poziomie

    Ułóż import operatorów transformacji z pakietu operatorów rxjs:

  • Układanie w pionie

    Ułóż linie łańcucha operatorów wyszukiwania w kolejności, w jakiej zapisujemy je wewnątrz pipe():

  • Edytor kodu

    Uzupełnij pipe strumienia wyszukiwania tak, by przepuszczał tylko frazy mające co najmniej 3 znaki.

  • Klikanie w kolejności

    Ułóż wywołanie operatora na strumieniu: source$.pipe(map(...))

Przydatne artykuły