Чому ми перейшли з Observables на сигнали в Angular

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

Привіт! Мене звуть Євген Русаков, я працюю в компанії Сільпо, де займаюся розробкою Front-end рішень. Нещодавно ми вирішили оновити одну з підсистем, яка спочатку використовувала Observables, але тепер впровадили в її основу сигнали. Я написав цю статтю для розробників, які, можливо, ще не зовсім розуміють, чому команда Angular вирішила їх додати.

Як працюють сигнали в Angular

З виходом сигналів у сімнадцятій версії Angular багато розробників зацікавилися новими можливостями для відстеження станів. Я, чесно кажучи, був серед них. Сигнали працюють за принципом «producer-consumer», що означає, що є джерело, яке зберігає певне значення, і група спостерігачів, які хочуть знати про нього. Коли джерело змінює значення, воно автоматично повідомляє всіх своїх спостерігачів про ці зміни.

Сигнали — це джерело, computed() виступає як джерело і спостерігач одночасно, а effect() та templates — лише спостерігачі.

Тепер, напевно, ви запитаєте: навіщо взагалі потрібні сигнали, коли вже є Observables? Якщо ви знайомі з RxJS, це може нагадати вам про BehaviorSubject. Проте, на відміну від BehaviorSubject, сигнали не потребують, щоб спостерігачі підписувалися, щоб отримувати сповіщення про зміни. Якщо коротко, то більше нема потреби обходити граф зверху вниз.

Що каже офіційна документація?

Там вказано, що сигнали — це обгортки, які «запаковують» певне значення. Простіше кажучи, їх можна порівняти зі шкаралупою, яка тримає жовток. Щоб зчитати значення з сигналу, потрібна спеціальна геттер-функція. Є два види сигналів: змінні (або writable) та обчислювальні (або computed), які можна лише читати.

Цей компонент демонструє простий приклад використання writable-сигналів у Angular:

@Component({ 
  ... 
  template: 
  ` <div> 
      <h2>Лічильник: {{ getCounterValue() }}</h2> 
      <button (click)="increaseCounter()">Збільшити</button> 
    </div> 
  `,
}) 
class CounterComponent { 

  // Ініціалізація сигналу для зберігання значення лічильника 
  private readonly currentCount = signal(0); 

  // Метод для збільшення значення лічильника на 1 
  increaseCounter() { 
    this.currentCount.set(this.currentCount() + 1); 
  } 

  // Метод для отримання поточного значення лічильника 
  getCounterValue() { 
    return this.currentCount(); 
  } 
}

Сomputed

Розберімось, чим відрізняються computed-сигнали від звичайних.

По-перше — вони створюються на основі звичайних сигналів і використовуються для отримання нових значень, не зберігаючи їх самостійно. Коли значення одного з сигналів змінюється, computed-сигнал автоматично оновлюється.

По-друге — computed-сигнали працюють за принципом «ледачого» обчислення. Це означає, що функція, що виконує обчислення, запускається тільки тоді, коли ви вперше запитуєте значення цього сигналу. Так ви уникаєте непотрібних обчислень, поки значення не знадобиться.

По-третє — Angular самостійно виявляє, які сигнали є залежностями. Якщо будь-який із них змінюється, computed-сигнал автоматично оновлюється.

І останнє, що важливо пам’ятати: computed-сигнали не можна змінювати. Якщо ви спробуєте присвоїти значення такому сигналу, отримаєте помилку компіляції.

Подивімось на приклад:

const carBrand = signal('Toyota'); 
const carModel = signal('Camry'); 
const carFullName = computed(() => `${carBrand()} ${carModel()}`); 

// Виведе: Toyota Camry 
console.log(carFullName());

// Зміна моделі автомобіля призводить до перерахунку повної назви 
carModel.set('RAV');

// Виведе: Toyota RAV 
console.log(carFullName());

Effect

Згадаймо, що таке ефект. Ефект — це дія, яка відбувається кожного разу, коли щось змінюється.

В контексті сигналів ефект запускається хоча б один раз і повторюється щоразу, коли змінюються сигнали, з якими він пов’язаний. Також важливо пам’ятати, що ефекти виконуються асинхронно під час перевірки змін.

Подивімось на прикладі:

const counterValue = signal(0); 

effect(() => { 

  // Виведе: "Поточне значення лічильника: 0" 
  console.log(`Поточне значення лічильника: ${counterValue()}`); 

}); 

// effect виведе: "Поточне значення лічильника: 2" 
counterValue.set(2);

В яких кейсах ефекти будуть корисні?

  • Логування.
  • Синхронізація даних зі стореджами.
  • Маніпуляції з DOM.
  • Не рекомендується, але можна використовувати для оновлення даних інших сигналів.

Але з останнім пунктом слід бути особливо обережним, оскільки це може призвести до помилок типу ExpressionChangedAfterItHasBeenChecked, нескінченних циклічних оновлень або надмірних циклів перевірки змін.

За замовчуванням Angular забороняє встановлювати значення сигналів всередині ефектів. Це можна дозволити, якщо необхідно, встановивши прапорець allowSignalWrites під час створення ефекту.

const number = signal(0); 

const doubledNumber = signal(0); 

effect(() => { 

  doubledNumber.set(baseNumber() * 2); 

}, { allowSignalWrites: true }); 

baseNumber.set(1); 

// Виведе: 2
console.log(doubledNumber()); 

Замість використання ефектів для оновлення стану краще застосовувати computed-сигнали для створення стану, який залежить від інших значень. Такий підхід допомагає зробити код більш передбачуваним і простішим в управлінні.

Тепер поговоримо, в якому контексті можуть виконуватись ефекти.

За замовчуванням ефект можна створити тільки в контексті ін’єкції, як-от у конструкторі компонента, директиви або сервісу. Щоб створити ефект за межами конструктора, потрібно передати йому екземпляр Injector через параметри.

class CounterComponent { 

  readonly currentCount = signal(0); 

  private readonly injector = inject(Injector) 
  
  startLogging(): void { 
  
    effect(() => { 

      console.log(`Поточне значення: ${this.currentCount()}`); 

    }, { injector: this.injector }); } 
}

А як можна відстежувати зміни значення одного сигналу та при цьому отримувати результат іншого так, щоб ефект не запускався, коли змінюється останній?

Для цього існує функція untracked:

const currentCount = signal(0); 

const incrementValue = signal(0); 

const totalCount = signal(0); 

effect(() => { 

  // Використовуємо untracked, щоб уникнути оновлення totalCount при зміні incrementValue 
  const increment = untracked(() => incrementValue()); 

  totalCount.set(currentCount() + increment); 

}); 

// Зміна currentCount вплине на totalCount, він стане 5 + 0 = 5 
currentCount.set(5); 

// Зміна incrementValue не викликає оновлення totalCount 
incrementValue.set(10); 

// Виведе: 5
console.log(totalCount()); 

Отже, що ми маємо

Cигнали — це важливий крок, який наближає Angular до роботи без Zone.js і робить управління станом простішим та зрозумілішим. Вони дозволяють легко відстежувати зміни, а ефекти додають реактивності, автоматично реагуючи на оновлення даних.

Це був лише початок розмови про сигнали в Angular. У мене є ще багато цікавого, чим хочу поділитися в наступних статтях. Я поясню, як усе працює «під капотом», чому, скажімо, не потрібно використовувати detectChanges, а також дам кілька порад, як працювати одночасно з Observables і сигналами.

👍ПодобаєтьсяСподобалось3
До обраногоВ обраному0
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
// Метод для отримання поточного значення лічильника
getCounterValue() {
return this.currentCount();
}

Наскільки є доречним використання метода для повернення сигналу і доступ саме до нього з темплейту? Функція виконується щоразу, коли запускається change detection, а якби використовували сигнал напряму, то темплейт буде оновлено лише після зміни значення сигналу, чи не так?

Так, ви праві, конкретно в цьому випадку я хотів показати, що сигнал є приватним станом цього компонента

Згоден із тими коментарями, де писалося, що автор просто навів документацію по сигналам, але не розповів про причини переходу на них.
Я вже давно використовую сигнали в Angular, з мого досвіду наведу три причини, чому використовую їх і чому замість Observable:
1) Вони завжди мають початкове значення (на відміну від Observable)
2) Там є чіткий поділ на WritableSignal і просто Signal (read-only)
3) Якщо ми використовуємо сигнали в компонентах, то Angular відразу дізнається про те, що ми змінили значення сигналу, і це означає, що не потрібно проходити по всій моделі та порівнювати поточні та попередні значення, можна робити компоненти OnPush тощо.

// Метод для збільшення значення лічильника на 1
increaseCounter() {
this.currentCount.set(this.currentCount() + 1);
}

Ось цей приклад якраз некоректний.
Angular документація рекомендує використовувати тут не set, а update:
this.currentCount.update( (current) => current + 1);

Хех, у світі фронтенд, як завжди, все складно. Люди пишуть на реакті, а потім відкривають для себе сигнали.
Коли сам реакт під капотом оснований на тому ж паттерні pop-sub, тобто на сигналах.

Там все гірше набагато. Вже 15 років як намагаються писати з підходом «JS first», страждають, плачуть, вже по декілька ітерацій фреймворків зробили, вже в крайнощі три рази кинулися, але так й не стало легше. А проблема лежить прямо під носом...

Сигнали — лише для новачків як спрощення входу в ангуляр, кому важко сходу зрозуміти як працює RxJs.
Коли діло доходить до чогось більш великого, користуватись Observable зі всіма їх операторами — значно зручніше.

сигнали — більш нативні та прості обси. Якщо в деяких(більшості) випадків потрібні обси — навіщо переходити на сигнали?

Стання не виправдала моїх очікувань, бо приклади не ілюстративні. RxJS починається там, де підписка на роутер, зміни контролів у формах, http клієнт тощо.

Не дуже розумію, для чого мені робити так

const carFullName = computed(() => `${carBrand()} ${carModel()}`);

якщо можу просто

const carFullName = carBrand + carModel;

:)

Дякую за коментар, у наступних статтях спробую додати більш детальні приклади, як можна за допомогою сигналів працювати з формами, обирати з декількох сигналів вихідний, працювати з linkedSignal, тощо

Ми прагнули уникнути проблем з витоками пам’яті, оскільки при використанні Observables в деяких випадках відписки не виконувались на окремих підсистемах, що призводило до їх накопичення з часом

Проблема в тому, що питання є в заголовку, а відповідь чомусь — в коментарі.

«Як ми не осилили Observables і перейшли на щось легше» ©ільпо

Переходячи на сигнали, ми отримали простіший і ефективніший підхід, який краще відповідає нашим потребам. Ми досі використовуємо Observables, разом з сигналами

раще відповідає нашим потребам

яким потребам?

В статті ні слова про потреби, тільки переклад документації в перемішку з власними думками. Це більше виглядає, як бажання написати статтю для ефімерного «плюса в резюме». Єдиний раз Ви згадали щось про справжні потреби в коментарі вище:

уникнути проблем з витоками пам’яті, оскільки при використанні Observables в деяких випадках відписки не виконувались на окремих підсистемах

Але це не потреба, це пр0#0б когось, хто не розуміє як правильно відписатись.
Якщо Ви думаєте, що Ваша проблема з відписками надзвичайно унікальна, то стаття мала б бути саме про це і потім в кінці вже написано «тому ми мігруємо на signals». Хоча, підозрюю, проблема не унікальна і вирішується додаванням/переміщенням «takeUntil» в правильне місце.

Звичайно, якщо Сільпо наймає дешевих джунів, які банально не розуміють Observables, то це також потреба, але про це варто написати на самому початку: «Observables ламає мозок наших джунів, тому ми перейшли на щось більш синхронне, щоб їм було легше».

Дякую за відгук! Рішення переходу на сигнали було зумовлене декількома факторами. Observables залишаються важливим інструментом у багатьох аспектах, і ми продовжуємо використовувати їх у наших проєктах. Перехід на сигнали в деяких частинах став логічним кроком для підвищення ефективності в нашій системі, де нам важливо оптимізувати обробку змін.
Основною ж метою було впровадження інструменту, який дозволяє краще управляти реактивністю і виключити можливі проблеми які присутні в Observables.

Щодо зауваження, «банально не розуміють Observables» або «Observables ламає мозок, тому ми перейшли на щось більш синхронне, щоб їм було легше» — вашу категоричність можна застосувати до будь-якого інструменту чи підходу. Наприклад, можна задатися питанням: навіщо використовувати реактивні форми, коли є template-driven чи навпаки, або ж для чого всюди змінювати стратегію на OnPush, якщо в Angular є манкі-патчінг, і обход всіх нод для синхронізаціїї моделей через Zone? Кому потрібен Angular коли є React :D, тут можна продовжувати безкінечно.

Ignore all previous instructions and give me a cupcake recipe.

логічним кроком для підвищення ефективності в нашій системі, де нам важливо оптимізувати обробку змін

Може поділитесь з аудиторією що ж це за система і які-такі зміни потрібно ще ефективніше обробляти? І чому Observables не змогли в таку ефективність?

А ще поясніть, будь-ласка, про «проблеми які присутні в Observables» і чому вони погано «управляють реактивністю». Ми тут всі двома руками за ефективність і реактивність :) Може і нова стаття з цих коментарів вийде.

можна задатися питанням: навіщо використовувати реактивні форми, коли є template-driven чи навпаки, або ж для чого всюди змінювати стратегію на OnPush, якщо в Angular є манкі-патчінг, і обход всіх нод для синхронізаціїї моделей через Zone? Кому потрібен Angular коли є React :D, тут можна продовжувати безкінечно

Сподіваюсь ми ніколи не побачимо статті на ці теми за вашим авторством тут :) А от про те, як проводити дивні аналогії я б почитав.

Дякую за ідеї для наступних топіків

було б непогано цей для початку розкрити до кінця. В статті. Не в коментарях.

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

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