Як поєднати RxJS із сигналами в Angular

💡 Усі статті, обговорення, новини про Front-end — в одному місці. Приєднуйтесь до Front-end спільноти!

Привіт! Мене звати Євген Русаков, я займаюся розробкою Front-end рішень у компанії Сільпо.

Після релізу сигналів багато досвідчених розробників Angular зустріли їх з певним скептицизмом.

Поспілкувавшись із кількома розробниками, я дізнався їхні думки та аргументи щодо небажання переходити на сигнали. Вони вважають цей підхід зайвим, оскільки є RxJS. Мовляв, що ще треба?

Розмова з ними зазвичай починається з таких аргументів:

У нас вже є Subject чи BehaviorSubject для збереження стану, а також pipe для декларативної обробки стрімів. Що такого роблять ваші сигнали, чого RxJS не вміє?

І вони мають рацію! RxJS — це надпотужна бібліотека, яка охоплює майже всі аспекти роботи з реактивним програмуванням. Сигнали не можуть зрівнятися з її функціональністю чи масштабністю.

Я хочу поділитися трохи іншим підходом — розповісти про взаємодію між RxJS і сигналами. Мета цього підходу — досягти справжнього симбіозу в роботі із застосунком, використовуючи сильні сторони обох інструментів.

Погнали!

Окреслімо межі: для яких задач ми використовуватимемо RxJS, а де краще застосовувати сигнали. Оскільки RxJS надає надзвичайні можливості для роботи зі стрімами, саме в цих сценаріях ми й будемо її використовувати.

Уявімо, що у нас є метод в сервісі, який робить запит для отримання даних, і для цього йому необхідні параметри — addressта profileId:

@Injectable({
  providedIn: 'root',
})
export class DataAccess {
  private readonly addressService = inject(AddressService);
  private readonly profileService = inject(ProfileService);
  private readonly dataService = inject(DataService);

  getDataByAddressAndProfileId(): Observable<DataInterface> {
    return combineLatest([
      this.addressService.address$,
      this.profileService.profileId$,
    ]).pipe(
      filter(([address, profileId]) => !!(address && profileId)),
      switchMap(([address, profileId]) =>
        this.dataService.fetchData({ address, profileId }).pipe(
          map(({ items }) => ({ items }))
        )
      )
    );
  }
}

Виглядає лаконічно і декларативно. На сигналах ми навряд досягнемо кращого результату, адже вони синхронні.

Тепер ці дані передамо у наш компонент:

<app-product-details [data]="data | async"/>

Розглянемо наш компонент, він реалізований повністю за допомоги сигналів:

@Component({
  selector: 'app-product-details',
  template: `
    @if (data()?.items) {
      <h1>Product Details</h1>
      <input [ngModel]="searchInput()" (ngModelChange)="search($event)" placeholder="Пошук">
      <ul>
        @for (let item of paginatedAndFilteredData()?.items; track item.id) {
          <li>
            <h3>{{ item.name }}</h3>
            <p>{{ item.description }}</p>
            <p><strong>Price: </strong>{{ item.price | currency }}</p>
          </li>
        }
      </ul>
      <button (click)="goToPrevPage()">Previous</button>
      page. {{ currentPage() }}
      <button (click)="goToNextPage()">Next</button>
    } @else {
      <p>No data available.</p>
    }
  `,
})
export class ProductDetailsComponent {
  readonly firstPage = 1;
  readonly itemsPerPage = 5;

  // Сигнал для відстеження поточної сторінки
  readonly currentPage = signal(this.firstPage);

  // Сигнал для зберігання введеного тексту пошуку
  readonly searchInput = signal('');

  // Дані, які очікуються з інпута
  readonly data = input.required<DataInterface>();

  // Обчислені дані з фільтрацією та пагінацією
  readonly paginatedAndFilteredData = computed(() => {
    const searchQuery = this.searchInput().toLowerCase();
    const startIndex = (this.currentPage() - 1) * this.itemsPerPage;
    const endIndex = startIndex + this.itemsPerPage;
  
    return this.data()
      .filter(item => item.name.toLowerCase().includes(searchQuery))
      .slice(startIndex, endIndex);
  });
  
  // Метод для оновлення тексту пошуку
  search(searchText: string): void {
    this.searchInput.set(searchText);
    // Якщо поточна сторінка більше за першу, скидаємо її на першу
    if (this.currentPage() > this.firstPage) {
      this.currentPage.set(this.firstPage);
    }
  }

  // Метод для переходу на попередню сторінку
  goToPrevPage(): void {
    this.currentPage.update((currentPage) => Math.max(currentPage - 1, 1));
  }

  // Метод для переходу на наступну сторінку
  goToNextPage(): void {
    this.currentPage.update((currentPage) =>
      Math.min(currentPage + 1, this.itemsPerPage + 1)
    );
  }
}

Порівняймо, як би ми реалізували деякі частини за допомогою RxJS і як ми це реалізували з використанням сигналів:

// RxJS
readonly firstPage = 1;
readonly itemsPerPage = 5;
readonly searchInput$ = new BehaviorSubject('');
readonly currentPage$ = new BehaviorSubject(this.firstPage);

readonly paginatedAndFilteredData$ = combineLatest([
  this.currentPage$.pipe(distinctUntilChanged()),
  this.searchInput$.pipe(
    distinctUntilChanged(),
    map((searchText) =>
      this.data.filter((item) =>
        item.name.toLowerCase().includes(searchText.toLowerCase())
      )
    )
  ),
]).pipe(
  map(([currentPage, filteredData]) => {
    const startIndex = (currentPage - 1) * this.itemsPerPage;
    const endIndex = startIndex + this.itemsPerPage;
    return filteredData.slice(startIndex, endIndex);
  })
);

Тепер реалізація на сигналах:

// Signals
readonly firstPage = 1;
readonly itemsPerPage = 2;
readonly searchInput = signal('');
readonly currentPage = signal(this.firstPage);

readonly paginatedAndFilteredData = computed(() => {
  const searchQuery = this.searchInput().toLowerCase();
  const startIndex = (this.currentPage() - 1) * this.itemsPerPage;
  const endIndex = startIndex + this.itemsPerPage;
  return this.data()
    .filter(item => item.name.toLowerCase().includes(searchQuery))
    .slice(startIndex, endIndex);
});

Міксування

Можна запитати: а що робити, якщо нам потрібно покращити, скажімо, обробку запитів з нашого пошуку?

На допомогу приходить toObservavle :

import { toObservable } from ’@angular/core/rxjs-interop’

private readonly products$ = toObservable(this.searchInput).pipe(
  debounceTime(500),
  distinctUntilChanged(),
  filter(Boolean),
  switchMap((query: string) => this.dataService.fetchProductsByQuery(query))
);

Ми отримали продукти, які шукали за допомогою пошуку, і тепер їх треба вивести у View, але ми не хочемо це робити за допомогою пайпа async. Навіщо? Ми хочемо зробити все на сигналах, тому просто конвертуємо наш потік у сигнал за допомогою toSignal:

import { toSignal } from '@angular/core/rxjs-interop'

@Component({
  selector: 'app-product-details',
  template: `
     //...
      <h1>Product Search</h1>
      <input [ngModel]="searchInput()" (ngModelChange)="search($event)" placeholder="Пошук">
      <ul>
        @for (let product of products()?.items; track product.id) {
          <li>
            <h3>{{ product.name }}</h3>
            <p>{{ product.description }}</p>
            <p><strong>Price: </strong>{{ product.price | currency }}</p>
          </li>
        }
      </ul>
    //...
  `,
})
export class ProductDetailsComponent {
//...
private readonly products = toSignal(this.products$)
//...
}

Висновок

Я мушу визнати, що мені дуже подобається, як сигнали не лише замінюють, а й спрощують код. Тепер щодо концепції використання сигналів та RxJS: на мою думку, якщо у вас є складна логіка, яка працює зі стрімами, то найкраще використати для цього RxJS. Але якщо ви створюєте UI-компоненти, де є кілька станів, inputs, outputs, і нескладна логіка, то краще обрати саме сигнали для реалізації.

Сподіваюся, вам сподобалась моя стаття :)

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному2
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter

ваша реалізація RxJs vs Signals не ідентична:
— distinctUntilChanged натякає на оптимізацію перформансу, в сигналах такої немає
— в rxjs версіі this.data.filter(...) якщо зміниться набір даних ( @Input data ) то на результатах фільраціі це не відобразиться так як цей обзервабл не буде про це знати...

отже картина може буде трохи інша, але все ж на користь сигналів в цьому випадку

Підписатись на коментарі