Kurs Angular · Moduł 7: RxJS

RxJS w Angular - Zwiadowcy Ninja

2 min czytania ·
W tej lekcji4

RxJS (Reactive Extensions for JavaScript) to biblioteka do programowania reaktywnego - jak sieć ninja zwiadowców obserwujących teren i natychmiast raportujących zdarzenia do centrali.

Czym jest Programowanie Reaktywne?

To paradygmat oparty na strumieniach danych i propagacji zmian:

  • Strumienie - sekwencje zdarzeń rozłożone w czasie
  • Obserwatorzy - reagują na każde zdarzenie w strumieniu
  • Operatory - transformują, filtrują, łączą strumienie

Observable - Strumień Danych

Observable to "obserwowalna" sekwencja wartości - jak ninja obserwujący zamek i raportujący każdy ruch:

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

// Tworzenie Observable z wartości
const numbers$ = of(1, 2, 3, 4, 5);  // Emituje wartości i kończy

// Z tablicy
const fromArray$ = from([1, 2, 3]);

// Timer - emituje co sekundę
const timer$ = interval(1000);  // 0, 1, 2, 3...

// Ze zdarzeń DOM
const clicks$ = fromEvent(document, 'click');

// Własny Observable
const custom$ = new Observable<number>(subscriber => {
  subscriber.next(1);
  subscriber.next(2);
  setTimeout(() => {
    subscriber.next(3);
    subscriber.complete();
  }, 1000);
});

Subskrypcja - Nasłuchiwanie

// Pełna forma subskrypcji
const subscription = numbers$.subscribe({
  next: value => console.log('Wartość:', value),
  error: err => console.error('Błąd:', err),
  complete: () => console.log('Zakończono')
});

// Skrócona forma - tylko next
numbers$.subscribe(value => console.log(value));

// Anulowanie subskrypcji
subscription.unsubscribe();

Konwencja nazewnictwa

W Angular używamy sufiksu $ dla Observable:

samurai$: Observable<Samurai[]>;
loading$: Observable<boolean>;
error$: Observable<string | null>;
Kod do tej lekcji: src/app/app.component.ts
import { Component, signal, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, interval, fromEvent, merge, BehaviorSubject, Subscription } from 'rxjs';
import { map, filter, takeUntil, debounceTime, distinctUntilChanged, switchMap, tap } from 'rxjs/operators';
import { toSignal } from '@angular/core/rxjs-interop';

@Component({
  selector: 'app-root',
  standalone: true,
  imports: [CommonModule],
  template: `
    <div class="ninja-hq">
      <h1>Kwatera Ninja - RxJS</h1>

      <section class="scouts-section">
        <h2>Zwiadowcy (Observables)</h2>

        <div class="scout-report">
          <h3>Raport czasowy</h3>
          <p>Sekundy od startu: {{ timeElapsed() }}</p>
          <p>Parzysty czas: {{ evenTime() ? 'Tak' : 'Nie' }}</p>
        </div>

        <div class="scout-report">
          <h3>Śledzenie myszy</h3>
          <p>Pozycja X: {{ mouseX() }}</p>
          <p>Pozycja Y: {{ mouseY() }}</p>
          <p>Strefa: {{ mouseZone() }}</p>
        </div>
      </section>

      <section class="messages-section">
        <h2>Wiadomości (Subject)</h2>

        <div class="message-input">
          <input
            #messageInput
            type="text"
            placeholder="Wpisz wiadomość..."
            (input)="onMessageInput(messageInput.value)"
          >
          <button (click)="sendMessage(messageInput.value); messageInput.value = ''">
            Wyślij
          </button>
        </div>

        <div class="messages-log">
          <h3>Historia wiadomości:</h3>
          @for (msg of messages(); track $index) {
            <div class="message" [class.urgent]="msg.urgent">
              <span class="time">{{ msg.time }}</span>
              <span class="text">{{ msg.text }}</span>
              @if (msg.urgent) {
                <span class="badge">PILNE</span>
              }
            </div>
          }
        </div>
      </section>

      <section class="operators-section">
        <h2>Operatory RxJS</h2>

        <div class="search-demo">
          <h3>Wyszukiwanie (debounce + distinctUntilChanged)</h3>
          <input
            type="text"
            placeholder="Szukaj samuraja..."
            (input)="onSearchInput($event)"
          >
          <p>Wyszukiwana fraza: "{{ searchTerm() }}"</p>
          <p>Liczba wyszukań: {{ searchCount() }}</p>
        </div>
      </section>
    </div>
  `,
  styles: [`
    .ninja-hq {
      font-family: 'Segoe UI', sans-serif;
      padding: 20px;
      background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
      min-height: 100vh;
      color: #eee;
    }

    h1 {
      text-align: center;
      color: #e94560;
      text-shadow: 2px 2px 4px rgba(0,0,0,0.5);
    }

    h2 {
      color: #0f3460;
      background: #e94560;
      padding: 10px;
      border-radius: 5px;
    }

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

    .scout-report {
      background: rgba(0,0,0,0.3);
      padding: 10px;
      border-radius: 5px;
      margin: 10px 0;
      border-left: 3px solid #e94560;
    }

    .scout-report h3 {
      margin: 0 0 10px 0;
      color: #e94560;
    }

    .message-input {
      display: flex;
      gap: 10px;
      margin-bottom: 15px;
    }

    .message-input input {
      flex: 1;
      padding: 10px;
      border: none;
      border-radius: 5px;
      background: #0f3460;
      color: white;
    }

    .message-input button {
      padding: 10px 20px;
      background: #e94560;
      color: white;
      border: none;
      border-radius: 5px;
      cursor: pointer;
    }

    .message-input button:hover {
      background: #ff6b6b;
    }

    .messages-log {
      max-height: 200px;
      overflow-y: auto;
    }

    .message {
      background: rgba(0,0,0,0.3);
      padding: 8px;
      margin: 5px 0;
      border-radius: 5px;
      display: flex;
      gap: 10px;
      align-items: center;
    }

    .message.urgent {
      border: 2px solid #ff6b6b;
      background: rgba(255,107,107,0.2);
    }

    .message .time {
      color: #888;
      font-size: 0.8em;
    }

    .message .badge {
      margin-left: auto;
    }

    .search-demo input {
      width: 100%;
      padding: 10px;
      border: none;
      border-radius: 5px;
      background: #0f3460;
      color: white;
      margin: 10px 0;
    }
  `]
})
export class AppComponent implements OnInit, OnDestroy {
  private destroy$ = new Subject<void>();
  private messageSubject = new BehaviorSubject<Message[]>([]);
  private searchSubject = new Subject<string>();

  // Signals from Observables
  timeElapsed = signal(0);
  evenTime = signal(false);
  mouseX = signal(0);
  mouseY = signal(0);
  mouseZone = signal('Centrum');
  messages = signal<Message[]>([]);
  searchTerm = signal('');
  searchCount = signal(0);

  ngOnInit(): void {
    // Timer Observable - co sekundę
    interval(1000).pipe(
      takeUntil(this.destroy$),
      tap(value => {
        this.timeElapsed.set(value);
        this.evenTime.set(value % 2 === 0);
      })
    ).subscribe();

    // Mouse Observable
    fromEvent<MouseEvent>(document, 'mousemove').pipe(
      takeUntil(this.destroy$),
      map(event => ({ x: event.clientX, y: event.clientY })),
      tap(pos => {
        this.mouseX.set(pos.x);
        this.mouseY.set(pos.y);
        this.mouseZone.set(this.getZone(pos.x, pos.y));
      })
    ).subscribe();

    // Messages BehaviorSubject
    this.messageSubject.pipe(
      takeUntil(this.destroy$)
    ).subscribe(msgs => this.messages.set(msgs));

    // Search with debounce
    this.searchSubject.pipe(
      takeUntil(this.destroy$),
      debounceTime(300),
      distinctUntilChanged(),
      tap(term => {
        this.searchTerm.set(term);
        this.searchCount.update(c => c + 1);
      })
    ).subscribe();
  }

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

  private getZone(x: number, y: number): string {
    if (x < 200 && y < 200) return '↖ Północny-Zachód';
    if (x > 400 && y < 200) return '↗ Północny-Wschód';
    if (x < 200 && y > 400) return '↙ Południowy-Zachód';
    if (x > 400 && y > 400) return '↘ Południowy-Wschód';
    return 'Centrum';
  }

  onMessageInput(value: string): void {
    // Could add real-time typing indicator here
  }

  sendMessage(text: string): void {
    if (!text.trim()) return;

    const newMessage: Message = {
      text,
      time: new Date().toLocaleTimeString(),
      urgent: text.includes('!')
    };

    const current = this.messageSubject.getValue();
    this.messageSubject.next([...current, newMessage]);
  }

  onSearchInput(event: Event): void {
    const input = event.target as HTMLInputElement;
    this.searchSubject.next(input.value);
  }
}

interface Message {
  text: string;
  time: string;
  urgent: boolean;
}

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. Która funkcja RxJS tworzy Observable emitujący wartości co określony interwał czasowy?

  2. 2. Co robi funkcja of() w RxJS?

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

Zadania praktyczne w grze

  • Klikanie w kolejności

    Ułóż deklarację zmiennej Observable zgodnie z konwencją nazewnictwa w Angular: samurai$: Observable<Samurai[]>;

  • Edytor kodu

    Uzupełnij kod tworzący Observable z podanych wartości i subskrybujący go.

  • Układanie w poziomie

    Ułóż import funkcji tworzących Observable z biblioteki rxjs:

  • Układanie w pionie

    Ułóż callbacki obiektu obserwatora w kolejności, w jakiej deklarujemy je w subscribe():

  • Klikanie w kolejności

    Ułóż skróconą subskrypcję strumienia: numbers$.subscribe(fn)

Przydatne artykuły