27 cze 2023
5 min

Współdziałanie sygnałów i RxJS w Angularze na praktycznym przykładzie

Współdziałanie sygnałów i RxJS w Angularze na praktycznym przykładzie

Sygnały w Angularze to nowy reactive primitive, który usprawni sposób w jaki tworzymy aplikacje w angularze ora zpoprawi Developer Experience. Znacząco również wpłynie na mechanizm detekcji zmian.

W tym artykule wyjaśnię, jak utworzyć komponent oparty na sygnałach. Dzięki praktycznemu przykładowi dowiesz się: 

  • Jak przekształcić RxJS observable na sygnał
  • Jak przekształcić sygnał na observable

Wszyscy lubimy wizualizacje, a więc zobaczmy, co zamierzamy stworzyć.

Poniższy kod jest naszym punktem wyjścia. Usprawnimy go wprowadzając koncepcję
sygnałów.

<div class="page-container">
  <mat-form-field class="page-container--form-field">
    <mat-label>Enter User Id (Empty will fetch all)</mat-label>
    <input matInput type="text" [matAutocomplete]="autoComplete" />

    <mat-spinner
      *ngIf="false"
      matSuffix
      class="page-container--spinner"
    ></mat-spinner>

    <mat-autocomplete #autoComplete="matAutocomplete">
      <mat-option> Option 1 </mat-option>
      <mat-option> Option 2 </mat-option>
      <mat-option> Option 3 </mat-option>
    </mat-autocomplete>
  </mat-form-field>
</div>

Konwersja z RxJS observable na sygnał

Zacznijmy od pobrania danych z serwisu, przekształcenia go do sygnału i przeiterowania
przez elementy tworzące opcje autocomplete’a. 

import { HttpClient } from '@angular/common/http';
import { Injectable, inject } from '@angular/core';
import { Observable, delay, throwError } from 'rxjs';
import { Post } from './post.type';

@Injectable({
  providedIn: 'root',
})
export class PostsService {
  private http = inject(HttpClient);

  get(userId?: number): Observable<Post[]> {
    if (userId == 100) {
      return throwError(() => new Error('User not found'));
    }
    return this.http
      .get<Post[]>('https://jsonplaceholder.typicode.com/posts', {
        params: {
          ...(userId ? { userId: userId.toString() } : {}),
        },
      })
      .pipe(delay(2000));
  }
}

Metoda  <span style="font-weight: 400;">get</span><span style="font-weight: 400;">()</span>

  • Przyjmuje userId jako argument
  • Wyrzuca błąd jeżeli userId wynosi 100
    (to jest tylko manualny sposób na rzucenie błędu abyśmy mogli zobaczyć jak zaaplikować podstawową obsługę błędów)
  • Stosuje sztuczny delay, aby umożliwić wyświetlenie loader’a

Tak jak wspomniałem powyżej, przejdziemy przez to wszystko krok po kroku.

W poniższym kodzie, wykorzystujemy metodę toSignal, która przyjmuje observable i zwraca
sygnał oparty na wartościach otrzymanych z observable. Warto zaznaczyć, że subskrypcja do observable zarządzania
jest automatyczna, oraz jest czyszczona kiedy injection context jest niszczony. 

@Component({...})
export class PostsComponent {
  private postsService = inject(PostsService);
  posts = toSignal(this.postsService.get());
}

Przeiterujmy teraz po obiektach Post w szablonie HTML. Zauważ, że musimy użyć nawiasów,
aby uzyskać wartości z sygnału. Może się to wydawać złym podejściem gdyż uczyliśmy się, że metody w szablonie
wywoływane są przy każdym cyklu detekcji zmian, jednakże nie dotyczy to sygnałów.

<mat-autocomplete #autoComplete="matAutocomplete">
  <mat-option *ngFor="let post of posts()" [value]="post.title">
    {{ post.title }}
  </mat-option>
</mat-autocomplete>

Jak dotąd udało nam się wykonać pierwszy krok wykorzystując jedynie metodę toSignal. Spróbujmy teraz uzyskać dane wejściowe użytkownika i przesłać je do serwisu.

Konwersja sygnału na observable

Powiązanie Binding do inputa również będzie oparty na sygnałach. Nazwiemy go userId, którego
wartość początkowa wynosić będzie undefined.

export class PostsComponent {
  private postsService = inject(PostsService);
  userId = signal<number | undefined>(undefined);
  posts = toSignal(this.postsService.get());
}

Musimy zastosować two-way data binding, ale jako że jeszcze nie możemy wykorzystać jego
składni, oddzielimy property binding wykorzystując event binding. (Podczas pisania artykułu wersja Angulara wynosi 16.0.4)

<input
  [ngModel]="userId()"
  (ngModelChange)="userId.set($event)"
  matInput
  type="text"
  [matAutocomplete]="autoComplete"
/>

Aby wysłać żądanie HTTP za każdym razem, kiedy wartość userID się zmieni, możemy wykorzystać kombinacje efektu oraz BehaviourSubject, tak jak widać poniżej.

import { effect, signal } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { BehaviorSubject } from 'rxjs';

@Component({...})
export class PostsComponent {
  private postsService = inject(PostsService);
  userId = signal<number | undefined>(undefined);
  userId$ = new BehaviorSubject<number | undefined>(undefined);

  constructor() {
    this.userId$
      .pipe(
        // Do something here,
        takeUntilDestroyed()
      )
      .subscribe();

    effect(() => {
      this.userId$.next(this.userId());
    });
  }
}

Podejście to działa prawidłowo, jednak wymaga od dewelopera zarządzania subskrypcją. 

Wydaje się jednak, że jest to wzorzec, którego możemy użyć za każdym razem, gdy łączymy sygnał z niektórymi operatorami RxJS. Z tego względu zespół Angulara stworzył metodę toObservable, która przekształca sygnał do observable oraz automatycznie zarządza jego subskrypcją. Dzięki tej metodzie, kod znacząco się skróci.

export class PostsComponent {
  private postsService = inject(PostsService);
  userId = signal<number | undefined>(undefined);
  private posts$ = toObservable(this.userId).pipe(
    switchMap((userId) => this.postsService.get(userId))
  );
  posts = toSignal(this.posts$);
}

W powyższym kodzie mamy pole posts$, który ma bardzo krótki cykl życia, gdyż jest przekształcany na sygnał za pomocą metody  toSignal. Poprawmy to trochę i użyjmy operatora debounceTime.

export class PostsComponent {
  private postsService = inject(PostsService);
  userId = signal<number | undefined>(undefined);
  posts = toSignal(
    toObservable(this.userId).pipe(
      debounceTime(500),
      switchMap((userId) => this.postsService.get(userId))
    )
  );
}

Jako że przekonwertowaliśmy sygnał do observable, możemy zastosować operatory RxJS do obsługi stanu ładowania wraz z obsługą błędów. Stan ładowania obsługujemy za pomocą sygnału isLoading signal<boolean>.

export class PostsComponent {
  private postsService = inject(PostsService);
  isLoading = signal<boolean>(false);
  userId = signal<number | undefined>(undefined);
  posts = toSignal(
    toObservable(this.userId).pipe(
      debounceTime(500),
      tap(() => this.isLoading.set(true)),
      switchMap((userId) =>
        this.postsService.get(userId).pipe(catchError(() => of([])))
      ),
      tap(() => this.isLoading.set(false))
    )
  );
}

I to wszystko!

Na koniec podzielę się kilkoma sugestiami: 

  • Postaraj się nie wykorzystywać async pipe’a w szablonie HTML, zwiększy to ilość cykli detekcji zmian
  • Spróbuj przekształcić stan twojego komponentu do sygnału
  • Nie obawiaj się korzystania z operatorów RxJS
  • Postaraj się wykorzystywać efekty w celu tworzenia logów bądź przy manipulacji DOM.

Możesz również obejrzeć film opisujący przedstawiony przykład na moim kanałe Youtube: 

Learn Angular Signals RxJS Interop From a Practical Example

Dziękuję za przeczytanie mojego artykułu.

Podziel się artykułem

Zapisz się na nasz newsletter

Dołącz do community Angular.love i bądź na bieżąco z trendami.