Як поєднати RxJS із сигналами в Angular
Привіт! Мене звати Євген Русаков, я займаюся розробкою 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, і нескладна логіка, то краще обрати саме сигнали для реалізації.
Сподіваюся, вам сподобалась моя стаття :)
1 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарів