Міграція на сигнали в Angular-проєктах

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

Всім привіт. Я Сергій Моренець, розробник, викладач, спікер та технічний письменник, хочу поділитися з вами досвідом використання сигналів у Angular-проєктах. Це все ще досить нова фіча, яка з’явилася в Angular 17, і досі постійно розвивається та просувається розробниками цього фреймворку. Тому я хочу розповісти про неї більш детально і описати особливості та проблеми при міграції проєктів на Signal API. Сподіваюся, що ця стаття буде корисна для всіх, хто хоче дізнатися більше про нові фічі в Angular і особливості їх використання.

Переваги сигналів

Про сигнали я вже писав тут, тому в цьому розділі лише наведу переваги сигналів, виходячи з досвіду їх використання. Чим сигнали кращі за звичайне оголошення властивостей в компонентах?

export class AppComponent {

    counter = 0;

По-перше, Angular engine ніяк не може дізнатися, що ви змінили це поле. Він може лише відстежити, що викликалася деяка подія для вашого компонента, яка може змінити його стан. По-друге, не всі поля відображаються в темплейтах, багато які є внутрішніми і відстежувати їх зміну немає потреби. Ситуація ускладнюється тим, що в темплейтах можуть бути використані функції і це ще більше заплутує логіку оновлення:

{{getCounter()}}

Здавалося б, вихід є — RxJS та Observable:

counter$: Observable<number>;

Тим більше, що є зручна pipe async:

{{counter$ | async}}

Але у Observable є свої обмеження та недоліки:

  1. Observable використовується для генерації та обробки подій, а не для прив’язки до стану. Тут не можна вказати початковий стан або отримати поточний стан, а тільки отримати повідомлення про зміну стану.
  2. Фактично вони read-only, як тільки ви отримали Observable, ви можете тільки читати з нього дані у вашому компоненті.
  3. RxJS пропонує більше 100 найрізноманітніших команд для управління, конвертації та генерації подій, які не так просто вивчити і запам’ятати. Хотілося б мати більш простий API для читання/запису даних.
  4. Для того, щоб читати дані з 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();

І ви будете праві, але у сигналів є дві переваги:

  1. Простіший та інтуїтивніший API для роботи.
  2. Перетворення BehaviorSubject повертає вам вже Observable, тоді як при конвертації сигналів ви знову отримуєте на виходи сигнали.

Більш того, Angular rendering engine відстежує зміни тільки в тих сигналах, які використовуються в темплейтах. Для зручності використання нової фітчі ви також можете застосувати:

  1. Обчислювані (computed) read-only сигнали.
  2. Ефекти (effects), що дозволяють підписуватися на зміну стану сигналу і виконувати потрібні вам дії.

У наступних версіях Angular 17.1 і 17.2 була додана підтримка сигналів для оголошення:

  1. Вхідні параметри елементів (input signals).
  2. Моделі, яка дозволяє організувати двостороннє зв’язування дочірнього та батьківського компонентів (model inputs).

А в Angular 19 розробники пішли ще далі та додали:

  1. Зв’язані сигнали (linked signals).
  2. Асинхронний 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 можуть генерувати три типи повідомлень:

  1. Дані.
  2. Помилки.
  3. Завершення роботи.

Як це все працюватиме із сигналом? Події з даними оновлять його поточний стан, повідомлення про завершення роботи ніяк не позначиться на сигналі, а помилка буде повертатися при виклику сигналу-функції (при чому при кожному виклику).

Спробуємо ініціалізувати 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
До обраногоВ обраному3
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

Дякую за цікаву статтю, але зовсім не зрозуміло навіщо ті сигнали треба, якщо таку купу кода треба використовувати задля ініціалізації пари полів.

Если вы разберетесь, что есть сигналы и как они работают под капотом и в чем их принципиальное отличие от механизма CD, то Вам сразу станет понятно зачем их использовать

Да я не про те що вони нафіг не треба.
Я про те що якось воно незграбно виглядає.

І це називається простіше і зрозуміліше ніж Subject?) Питання як мінімум спірне і виглядає складніше і заплутаніше.

Отримав задоволення від стилю «рефакторимо рядок — тепер у нас помилка — виправляємо в іншому рядку — ой, у нас знову помилка». Бо насправді воно в робочих проектах так і є, значно корисніше для читача, ніж передрук з офіційних документацій, де імпортував нові функції з модулів, замінив, і все (нібито) працює.

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