Kurs Angular · Moduł 7: RxJS
Operatory RxJS - Techniki Ninja
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. Jaka jest główna różnica między switchMap a mergeMap?
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(...))