Протягом багатьох років RxJS був основою реактивності в Angular. Однак однією з його основних проблем у контексті синхронізації даних і вигляду є його безстейтова природа. За своєю суттю, обсервабель (Observable) більше орієнтовані на обробку подій, а не на керування даними. У відповідь команда Angular представила нову примітиву — Signals (Сигнали). Але як тепер працювати з RxJS?
Як згадувалося раніше, основна концепція обсервабель зосереджена на сповіщеннях. Потоки (streams) та їх оператори добре підходять для опису складної логіки на основі подій, особливо при роботі з імперативними API в браузерному середовищі. Тепер, коли Angular вводить інструменти для безшовної взаємодії між сигналами та обсервабелями, у нас з’являється надзвичайно потужна синергія.
У цій статті я покажу кілька прикладів коду, де ці два концепти доповнюють один одного.
Давайте дотримуватись простого правила
Більше не використовуємо обсервабелі (Observables) в шаблонах.
Бувай | async 👋
До введення сигналів (Signals), реактивність в Angular ґрунтувалась на підписці на обсервабелі (Observables) в шаблонах за допомогою AsyncPipe
. Цей pipe був розроблений для вирішення двох основних задач: позначення вигляду як брудного для виявлення змін (що необхідно при використанні стратегії OnPush
) і інтеграції з життєвим циклом компонента для скасування підписки на обсервабель, коли його вигляд знищується.
Однак він не вирішував основну проблему, яку я згадував раніше: обсервабелі не гарантують стан при підписці, через що Angular був змушений типізувати повернуте значення pipe як об’єднання з null
.
Це змусило розробників додавати додаткові перевірки, що виявилось досить набридливим.
Тепер настав час повністю відмовитися від використання обсервабелів (Observables) у шаблонах і перейти до сигналів (Signals), які за своєю природою є станними (stateful) та пропонують додаткові переваги, про які ми поговоримо в прикладах.
⚠️ Увага: Приклади коду використовують RxJS Interop API, яке наразі знаходиться на стадії попереднього перегляду для розробників.
📋 Кнопка копіювання
Розв’яжемо просту задачу: коли користувач натискає кнопку копіювання, ми хочемо показати йому зворотний зв'язок, змінивши іконку та текст на кілька секунд, а потім повернути їх до початкового стану:
readonly copied = toSignal(
fromEvent(inject(ElementRef).nativeElement, 'click').pipe(
exhaustMap(() =>
timer(2000).pipe(
map(() => false),
startWith(true)
)
)
),
{ initialValue: false }
);
Результат (stackblitz):
Використовуючи оператори RxJS, ми декларативно описали, як змінюється стан copied
:
- Якщо користувач натискає кнопку кілька разів,
exhaustMap
гарантує, що поточнийtimer
завершиться коректно перед тим, як скинути стан наfalse
- Логіка синхронізації значень, що емитуються, з сигналом (signal) та відписки від джерела обсервабеля (Observable) обробляється за допомогою toSignal, що усуває необхідність вручну підписуватись на обсервабель
💡 Повертаючись до зауваження з попереднього розділу про використання AsyncPipe
, ми можемо застосовувати нову альтернативу для потоків у вигляді toSignal
.
✨ Підсвітлення якоря
Тепер перейдемо до трохи складнішого завдання.
Наша сторінка містить якорі (anchor links), які повинні підсвічуватися, коли користувач переходить за URL з відповідним фрагментом.
Оскільки секція, що містить посилання, може бути поза межами видимої частини екрану під час навігації, підсвічування має бути застосовано лише після завершення автоматичного прокручування:
private readonly id = inject(new HostAttributeToken('id'));
private readonly route = inject(ActivatedRoute);
readonly highlighted = toSignal(
this.route.fragment.pipe(
startWith(this.route.snapshot.fragment),
filter(fragment => this.id === fragment),
switchMap(() =>
concat(
fromEvent(window, 'scroll').pipe(
startWith(true),
debounceTime(100),
take(1)
),
timer(2000).pipe(map(() => false))
)
)
),
{ initialValue: false }
);
До речі, ще один недолік обсервабель (observables) при керуванні станом — це неможливість безпосереднього прив’язування даних до елементу хоста.
Signals вирішують цю проблему, дозволяючи нам робити ось так:
host: {
'[class.highlighted]': 'highlighted()'
}
Результат (stackblitz):
RxJS знову допомогла нам уникнути каскаду імперативного коду:
debounceTime
разом зtake
дозволяє нам дочекатися завершення автоматичного прокручування перед тим, як оновити стан наtrue
timer
скидає стан до початкового значення- Порядок підписок керується за допомогою
concat
, який підписується на таймер скидання тільки після того, як перший обсервабл завершиться
👻 Автоматичне закриття
Тепер давайте вирішимо задачу з реалізацією компонента сповіщень з автоматичним закриттям. Він приймає тривалість показу як вхідний параметр та випромінює подію, коли його потрібно закрити.
Крім того, є ще одна вимога: якщо користувач наводить курсор на сповіщення, таймер має бути зупинений і скинутий, щоб запобігти закриттю сповіщення, поки курсор не покине елемент:
private readonly el = inject(ElementRef).nativeElement;
readonly duration = input(Infinity);
readonly close = outputFromObservable(
toObservable(this.duration).pipe(
switchMap(value =\> Number.isFinite(value) ? timer(value) : EMPTY),
takeUntil(fromEvent(this.el, 'mouseenter')),
repeat({ delay: () =\> fromEvent(this.el, 'mouseleave') })
)
);
Результат (stackblitz):
У цьому завданні ми використали дві утилітні функції для роботи з оновленими API input
та output
в Angular:
toObservable
: перетворює наш вхід на основі сигналів в потік (stream).
Angular чудово вдосконалив свою систему реактивності, зробивши властивості input директив та компонентів справжньо реактивними. Тепер ці властивості можна використовувати як у сценаріях керування станом (наприклад, з властивостями для обчислень), так і для створення логіки, яка базується на взаємодії з обсерваціями (observables).
⚠️ Важливо зауважити, що сигнали (signals) за своєю суттю не мають глюків і ніколи не передають зміни синхронно. Якщо значення сигналу оновлюється синхронно кілька разів, підписник (subscriber) буде повідомлений лише про останнє значення після того, як сигнал стабілізується під час процесу виявлення змін.
Важливо врахувати, якщо ви плануєте створювати реактивні ланцюги, використовуючи toObservable.
outputFromObservable
: створює новий output з обсервації (observable). З введенням нових властивостей input, Angular було необхідно відмовитись від EventEmitter, який раніше був розширений від RxJS Subject. Хоча тут немає нічого революційного, зараз output використовує екземпляр нового класу OutputEmitterRef за кулісами.
Цей клас фактично слідує тій самій ментальній моделі, що й Subject, але в більш легковагому варіанті.
📏 Відстеження розмірів елемента
Коли мова йде про API у браузерному середовищі, їх використання в декларативному стилі може бути незручним. Більшість з цих API засновані на зворотних викликах (callbacks), а деякі також містять логіку очищення, схожу на те, що в світі RxJS відоме як TeardownLogic.
Однак, хоча RxJS забезпечує єдиний механізм для побудови реактивних ланцюгів, робота з імперативними API часто вимагає написання більш об'ємних і узгоджених інструкцій.
Оскільки сигнали (signals) безперешкодно інтегруються з обсерваціями (observables), легко розглядати імперативні API як потоки.
Наприклад, давайте визначимо власний оператор, щоб перетворити ResizeObserver на обсервацію (observable):
export function fromResizeObserver(
target: Element | SVGElement,
options?: ResizeObserverOptions,
): Observable\ {
return new Observable(subscriber =\> {
const ro = new ResizeObserver(entries =\> subscriber.next(entries));
ro.observe(target, options);
return () =\> ro.disconnect();
});
}
💡 Цей оператор можна повторно використовувати в коді. Наприклад, ми можемо використовувати його для створення реактивного стану для width
хост-елемента:
private readonly el = inject(ElementRef).nativeElement;
readonly width = toSignal(
fromResizeObserver(this.el).pipe(map(() =\> this.el.offsetWidth)),
{ initialValue: 0 }
);
Точне відстеження ширини елемента часто вимагає більше, ніж просто ResizeObserver.
Країні випадки можуть вимагати використання додаткових спостерігачів, таких як MutationObserver. Ось де RxJS справді розкривається: використовуючи оператори об'єднання, ми можемо декларативно комбінувати події, щоб надійно виявляти зміни ширини елемента:
private readonly el = inject(ElementRef).nativeElement;
readonly width = toSignal(
merge(
fromResizeObserver(this.el),
fromMutationObserver(this.el, {
childList: true,
subtree: true,
characterData: true,
})
// ...і будь-які інші крайні випадки
).pipe(
map(() =\> this.el.offsetWidth),
distinctUntilChanged()
),
{ initialValue: 0 }
);
🔋 Підвищення потужності Angular API
У попередньому прикладі ми створили власний оператор для обгортання специфічного для браузера API.
Схожий підхід можна застосувати для побудови реактивних взаємодій з деякими API Angular, створюючи на їх основі обсервабли (observables).
У деяких з попередніх фрагментів коду я використовував глобальні об'єкти, доступні лише в середовищі браузера (наприклад, window
). Однак, щоб гарантувати правильне виконання коду на сервері (SSG / SSR), нам потрібно переконатися, що певна логіка ініціалізується тільки під час клієнтського рендерингу.
У таких випадках ми можемо використати API afterNextRender
(⚠️ ще в розробці, попередній перегляд), яке базується на зворотному виклику (callback), і перетворити його на обсервабл (observable):
export function fromAfterNextRender(options?: AfterRenderOptions): Observable\ {
if (!options?.injector) {
assertInInjectionContext(fromAfterNextRender);
}
return new Observable(subscriber =\> {
const ref = afterNextRender(() =\> {
subscriber.next();
subscriber.complete();
}, options);
return () =\> ref.destroy();
});
}
Тоді ми можемо використати його для обчислення стану виключно на клієнтській стороні:
readonly state = toSignal(
fromAfterNextRender().pipe(
switchMap(() =\> {
// тут ми знаходимося в контексті браузера
}),
),
);
💡 Іншим прикладом може бути створення власного оператора на основі частих операцій для зменшення шаблонного коду та покращення читабельності.
У одному з моїх проєктів було багато логіки застосунку, пов’язаної з подіями Router
, тому замість того, щоб писати так:
private readonly router = inject(Router);
constructor() {
this.events.subscribe(e =\> {
if (e instanceof NavigationEnd) {
// Обмеження типу `e` доступне тільки в цьому блоці
}
})
}
Набагато зручніше написати так:
/\*
Тепер ми можемо використовувати обсервабл з типізованим значенням
відповідної події в будь-який спосіб, який нам потрібен ✨
Повертає Observable\
\*/
fromRouterEvent(NavigationEnd);
Для досягнення цього достатньо написати тривіальний оператор, використовуючи вже існуючий оператор filter
та предикат типу, щоб забезпечити звуження типу:
import { Event, Router } from '@angular/router';
export function fromRouterEvent\(
event: { new (...args: any[]): T },
options?: { injector: Injector },
): Observable\ {
let router: Router;
if (!options?.injector) {
assertInInjectionContext(fromRouterEvent);
router = inject(Router);
} else {
router = options.injector.get(Router);
}
return router.events.pipe(filter((e: unknown): e is T =\> e instanceof event));
}
Іншими словами, ми маємо можливість адаптувати необхідні API фреймворку та часті операції для роботи з потоками (streams), значно зменшуючи шаблонний код і роблячи його більш декларативним.
Висновок
Приклади, описані вище, — це практичні завдання з реальних проєктів.
Як показано в їхніх рішеннях, RxJS чудово підходить для обробки подій, що базуються на подіях. Крім того, реактивна бібліотека залишатиметься інтегрованою в екосистему Angular на рівні публічних API (наприклад, у @angular/{router,forms,common/http,cdk}
).
🌗 Синергія між сигналами (signals) та обсерваблами (observables) може стати важливим кроком вперед у реактивності Angular, пропонуючи більш структурований підхід, де сигнали зосереджуються на керуванні станом, а обсервабли обробляють події.
🫡 Слідкуйте за наступною статтею, де ми розглянемо ще більш складні теми, такі як оптимізація продуктивності за допомогою сигналів (signals) та використання RxJS для складних випадків використання!
Перекладено з: RxSignals: The most powerful synergy in the history of Angular