Міграція на сигнали в Angular-проєктах
Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу поділитися з вами досвідом використання сигналів у Angular-проєктах. Це все ще досить нова фіча, яка з’явилася в Angular 17, і досі постійно розвивається та просувається розробниками цього фреймворку. Тому я хочу розповісти про неї більш детально і описати особливості та проблеми при міграції проєктів на Signal API. Сподіваюся, що ця стаття буде корисна для всіх, хто хоче дізнатися більше про нові фічі в Angular і особливості їх використання.
Переваги сигналів
Про сигнали я вже писав тут, тому в цьому розділі лише наведу переваги сигналів, виходячи з досвіду їх використання. Чим сигнали кращі за звичайне оголошення властивостей в компонентах?
export class AppComponent { counter = 0;
По-перше, Angular engine ніяк не може дізнатися, що ви змінили це поле. Він може лише відстежити, що викликалася деяка подія для вашого компонента, яка може змінити його стан. По-друге, не всі поля відображаються в темплейтах, багато які є внутрішніми і відстежувати їх зміну немає потреби. Ситуація ускладнюється тим, що в темплейтах можуть бути використані функції і це ще більше заплутує логіку оновлення:
{{getCounter()}}
Здавалося б, вихід є — RxJS та Observable:
counter$: Observable<number>;
Тим більше, що є зручна pipe async:
{{counter$ | async}}
Але у Observable є свої обмеження та недоліки:
- Observable використовується для генерації та обробки подій, а не для прив’язки до стану. Тут не можна вказати початковий стан або отримати поточний стан, а тільки отримати повідомлення про зміну стану.
- Фактично вони read-only, як тільки ви отримали Observable, ви можете тільки читати з нього дані у вашому компоненті.
- RxJS пропонує більше 100 найрізноманітніших команд для управління, конвертації та генерації подій, які не так просто вивчити і запам’ятати. Хотілося б мати більш простий API для читання/запису даних.
- Для того, щоб читати дані з Observable, потрібно підписатися на нього (а потім не забути відписатися).
Всі ці недоліки враховані при розробці сигналів, які з’явилися спочатку в Angular 17. Ви можете оголосити read-only сигнал:
logged: Signal<boolean>;
Та читати з нього поточний стан:
{{logged()}}
А можете вказати, що writable сигнал (причому, вказавши початковий стан):
logged = signal (false);
Такий поділ дозволяє вам реалізувати інкапсуляцію для юніту. Ви оголошуєте в ньому writable сигнал, а потім, якщо це необхідно, перетворюєте його в read-only варіант і видаєте назовні:
return this.logged.asReadonly();
Після першого знайомства може здатися, що така функціональність вже реалізована в RxJS, тільки не як Observable, а як BehaviorSubject:
loggedSubject = new BehaviorSubject<boolean>(true);
І ви також можете прочитати поточне значення:
const value = this.loggedSubject.getValue();
І ви будете праві, але у сигналів є дві переваги:
- Простіший та інтуїтивніший API для роботи.
- Перетворення BehaviorSubject повертає вам вже Observable, тоді як при конвертації сигналів ви знову отримуєте на виходи сигнали.
Більш того, Angular rendering engine відстежує зміни тільки в тих сигналах, які використовуються в темплейтах. Для зручності використання нової фітчі ви також можете застосувати:
- Обчислювані (computed) read-only сигнали.
- Ефекти (effects), що дозволяють підписуватися на зміну стану сигналу і виконувати потрібні вам дії.
У наступних версіях Angular 17.1 і 17.2 була додана підтримка сигналів для оголошення:
- Вхідні параметри елементів (input signals).
- Моделі, яка дозволяє організувати двостороннє зв’язування дочірнього та батьківського компонентів (model inputs).
А в Angular 19 розробники пішли ще далі та додали:
- Зв’язані сигнали (linked signals).
- Асинхронний resource API (resource).
Для того, щоб краще розібратися у всіх особливостях нової функціональності, спробуємо перекласти на неї один із компонентів невеликого проєкту.
Починаємо міграцію
Отже, у нас є невеликий компонент CitySelectionComponent, де все ще використовуються Observable, а також кілька вхідних параметрів:
export class CitySelectionComponent implements OnInit { @Input() cities: City[]; @Input() cityPrompt: string; @Input() cityControl: FormControl<string>; filteredCities$: Observable<string[]>;
Даний компонент відображає список населених пунктів (filteredCities$) для списку, в якому користувач може вводити перші символи цих пунктів і відповідно фільтрувати вміст drop-down. Вхідний параметр cityControl потрібен тут для того, щоб можна було відстежувати зміни в ньому і для зв’язування в темплейті з input елементом.
Ось як ініціалізується поле filteredCities$:
ngOnInit() { this.filteredCities$ = this.cityControl.valueChanges.pipe(map(value => this.filter(value))); }
Спробуємо визначити його як сигнал:
filteredCities: Signal<string[]>;
Для перетворення Observable в сигнал Angular є функція toSignal, яка повертає read-only Signal. Відомо, що Observable можуть генерувати три типи повідомлень:
- Дані.
- Помилки.
- Завершення роботи.
Як це все працюватиме із сигналом? Події з даними оновлять його поточний стан, повідомлення про завершення роботи ніяк не позначиться на сигналі, а помилка буде повертатися при виклику сигналу-функції (при чому при кожному виклику).
Спробуємо ініціалізувати filtredCities в ngOnInit за допомогою функції toSignal:
ngOnInit() { this.filteredCities = toSignal(this.cityControl.valueChanges.pipe(map(value => this.filter(value)))); }
І отримуємо помилку в run-time:
ERROR Error: NG0203: toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Ми можемо використовувати toSignal у конструкторі або при оголошенні поля, але не в ngOnInit. І справді, якщо подивитися на реалізацію функції toSignal(), то можна побачити, що вона обов’язково використовує injection:
const cleanupRef = requiresCleanup ? (options?.injector?.get(DestroyRef) ?? inject(DestroyRef)) : null;
Щоб отримати посилання на DestroyRef і потім автоматично відписатися від Observable при знищенні поточного компонента:
cleanupRef?.onDestroy(sub.unsubscribe.bind(sub));
У цьому полягає плюс сигналів, оскільки за використання чистого RxJs це відбувається. Але це накладає певні обмеження. Зрозуміло, можна обернути весь код у функцію runInInjectionContext, але це певною мірою workaround, який доведеться використовувати по всьому проєкту, а цікавіше обійтися стандартними засобами, без будь-яких хитрощів.
Перенести цей код з ngOnInit в конструктор компонента не вийде, тому що ми залежимо від поля CityControl, яке є вхідним параметром і буде ініціалізоване тільки в ngOnInit:
@Input() cityControl: FormControl<string>;
Замкнене коло? Поки що так, але ж ми маємо таку фічу як input signals. Вона дозволяє перевести взаємодію між компонентами на Signal API. Змінюємо декларацію cityControl:
cityControl = input<FormControl<string>>();
Більше того, оскільки це обов’язковий параметр, зазначимо це у декларації:
cityControl = input.required<FormControl<string>>();
На жаль, форми в Angular досі не використовують сигнали, а лише Observable, хоча вже є намір змінити це. Тому ми просто сконвертуємо Observable у сигнал:
filteredCities = toSignal(this.cityControl().valueChanges.pipe(map(value => this.filter(value))));
Але тепер ми отримаємо іншу помилку в runtime:
ERROR TypeError: this.cityControl() is undefined
Оскільки початкове значення cityControl — undefined. І тут варто згадати, що тепер fil-teredCitites — це сигнал, а значить можна використовувати функцію effect:
filteredCities = signal([]).asReadonly(); constructor() { effect(() => { if (this.cityControl()) { this.filteredCities = toSignal(this.cityControl()?.valueChanges.pipe(map(value => this.filter(value))), {initialValue: []}); } }); }
Тут ми відстежуємо значення вхідного параметра cityControl, і як тільки воно буде ініціалізовано (змінено), то викличеться даний callback і присвоїть поле filteredCities.
І тут отримуємо ще одну помилку:
Error: NG0602: toSignal() cannot be called from within a reactive context. Invoking `toSignal` causes new subscriptions every time. Consider moving `toSignal` outside of the reactive context and read the signal value where needed.
Ця помилка добре відома і говорить про те, що ми можемо створювати сигнали всередині функцій computed() чи effect(), а лише використовувати існуючі сигнали. І в цьому полягає одна з відмінностей сигналів від RxJS, де ви можете створювати в run-time Observable будь-яким доступним чином і об’єднувати їх, як ви цього хочете.
Спробуємо ще один варіант, пов’язаний з тим, що сигнали вхідні підтримують трансформацію даних. Ви можете прямо в їх декларації вказати, що значення сигналу має автоматично перетворюватися на інший тип. Спробуємо у властивості transform вказати, що хочемо отримувати з вхідного сигналу не FormControl, а сигнал:
cityControl = input.required({transform: (formControl: FormControl<string>) => toSignal(formControl.valueChanges)});
Але й тут отримуємо звичну помилку, пов’язану з тим, що конвертація Observable у сигнал за допомогою toSignal відбувається вже після створення компонента:
Error: NG0203: toSignal() can only be used within an injection context such as a constructor, a factory function, a field initializer, or a function used with `runInInjectionContext`.
Тому тут у нас лишаються два варіанти. Перший найпростіший і робочий тут — це просто вказати filteredCities як writable сигнал:
filteredCities = signal([]);
А потім змінити його в callback subscribe:
ngOnInit(): void { this.cityControl().valueChanges.pipe(map(value => this.filter(value))).subscribe(cities => this.filteredCities.set(cities)); }
У другому варіанті доведеться міняти вже два компоненти: поточний та батьківський. Перенесемо всю обробку введення даних у батьківський компонент, а в CitySelectionComponent перетворимо filteredCities у вхідну властивість:
export class CitySelectionComponent { cityControl = input.required<FormControl<string>>(); cityPrompt = input.required<string>(); filteredCities = input.required<string[]>(); }
В результаті тут не залишиться жодної логіки, лише стан. А ось у батьківському компоненті додамо таке ж поле filteredCities:
filteredCities: Record<string, Signal<string[]>>;
Яке буде типом Record та міститиме назви полів введення як ключі (у нас кілька полів для введення населених пунктів) та сигнали як властивості. Тоді в конструкторі ми можемо після створення форми та отримання населених пунктів ініціалізувати це поле:
this.form = fb.record({ start: fb.control(’’, [Validators.required]), destination: fb.control(’’, [Validators.required]) }); this.cities = toSignal(this._cityService.getCities()); this.filteredCities = { start: toSignal(this.getControl(’start’).valueChanges.pipe(map(value => this.filter(value)))), destination: toSignal(this.getControl(’destination’).valueChanges.pipe(map(value => this.filter(value)))) };
Або, якщо хочеться зробити рішення більш універсальним, можна оголосити filteredCities як Map:
filteredCities = new Map<string, Signal<string[]>>();
А ініціалізувати його так:
Object.keys(this.tripForm.controls) .forEach(key => this.filteredCities.set(key, toSignal(this.getControl(key).valueChanges.pipe(map(value => this.filter(value))))));
А в темплейті батьківського компонента ми передаватимемо поточне значення сигналу:
<app-city-selection [cityPrompt]="’trip.prompt.start’ | translate" [cityControl]="getControl(’start’)" [filteredCities]="filteredCities.get(’start’)()"></app-city-selection>
Висновки
Сигнали — це новий реактивний API в Angular, який прийшов на зміну RxJS і дозволяє обійтися без Zone.js і перехоплення подій для відстеження змін станів компонентів. Тепер Angular engine знає, який компонент та яка властивість було змінено. Завдяки функціям computed() і linkedSignal() можна зв’язувати сигнали між собою, а завдяки функції model() можна зв’язувати між собою і компоненти.
Але в той же час у нового API є свої обмеження, які не дозволяють використовувати (трансформувати) сигнали так, як це було можливим з RxJS.
5 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів