Як поєднати 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 коментар
Додати коментар Підписатись на коментаріВідписатись від коментарівваша реалізація RxJs vs Signals не ідентична:
— distinctUntilChanged натякає на оптимізацію перформансу, в сигналах такої немає
— в rxjs версіі this.data.filter(...) якщо зміниться набір даних ( @Input data ) то на результатах фільраціі це не відобразиться так як цей обзервабл не буде про це знати...
отже картина може буде трохи інша, але все ж на користь сигналів в цьому випадку