Skip to main content

Сигнали в Angular

Сигнали Angular (Signals) - це реактивні контейнери стану, представлені в Angular 16. Вони автоматично відстежують залежності і оновлюють лише ті частини UI, які насправді читають змінене значення.

Теорія

Коротко

  • Сигнали схожі на клітинку в Excel: змінюєш A1 - перераховуються лише клітинки, що посилаються на A1, а не весь аркуш
  • Zone.js проходив по всьому дереву компонентів після кожної події; Signals надсилають оновлення лише по записаному графу залежностей
  • Три примітиви: signal() зберігає стан, computed() виводить значення, effect() запускає побічні ефекти
  • Новий застосунок на Angular 16+: Signals для стану, RxJS - для HTTP, WebSocket і таймерів
  • Міст між двома підходами: toSignal() обгортає Observable, toObservable() - навпаки

Швидкий приклад

typescript
import { Component, signal, computed } from '@angular/core'; @Component({ template: ` <p>Лічильник: {{ count() }}</p> <p>Подвоєне: {{ double() }}</p> <button (click)="increment()">+</button> ` }) export class CounterComponent { count = signal(0); // writable signal double = computed(() => this.count() * 2); // перераховується лише коли count змінюється increment() { this.count.update(v => v + 1); // атомарне оновлення } }

Натискаєш "плюс": count стає 1, double - 2. Перерендерюються лише ці два байндінги. Решта дерева компонентів не зачіпається.

Головна різниця від Zone.js

Zone.js патчить браузерні API і запускає повний обхід дерева компонентів після кожної події, таймауту або HTTP-відповіді. Signals будують граф залежностей під час читання: коли шаблон або computed() викликає count(), Angular фіксує цей зв'язок. На set() або update() планувальник проходить лише по зафіксованих ребрах і ставить мікрозадачу в чергу через queueMicrotask. Zone.js не потрібен. Бенчмарки Angular показують прискорення в 2-10 разів на великих деревах компонентів при роботі в Zone-less режимі.

Коли використовувати

  • Локальний стан компонента (поле форми, перемикач, лічильник) → signal(), мінімум коду
  • Спільний стан застосунку → Signal в @Injectable сервісі, ін'єктуй де потрібно
  • Значення, що залежать від іншого стану → computed(), кешується і обчислюється ліниво
  • HTTP-запити, WebSocket, таймери → RxJS, потім toSignal() для шаблонів
  • Існуюча кодова база на Zone.js → Signals працюють поруч, мігруй поступово

Як це працює всередині

signal(0) створює вузол у реактивному графі. Виклик count() всередині computed() або байндінгу шаблону записує ребро від читача до вузла сигналу. При count.set(1) планувальник позначає всі залежні вузли як "брудні" і ставить мікрозадачу в чергу. Під час виконання черги Angular переобчислює лише брудні вузли. Результати computed() кешуються: якщо нічого вище по графу не змінилось, повертається закешоване значення без повторного запуску функції.

effect() працює так само. Він повторно запускається, коли змінюється будь-який сигнал, прочитаний під час попереднього виконання. Перед повторним запуском Angular викликає onCleanup(), зареєстрований в попередньому циклі.

На практиці я бачив, як команди годинами дебажили проблеми з Zone.js, які повністю зникали після переносу стану в Signal.

Signals проти Observables

SignalsObservables (RxJS)
Завжди має значенняТакНі (потрібен BehaviorSubject)
Синхронне читанняТакМоже бути асинхронним
АвтоочищенняТакРучний unsubscribe()
Синтаксис у шаблоні{{ count() }}{{ count$ | async }}
ОператориВідсутніПовний набір RxJS
ПризначенняСинхронний станАсинхронні потоки подій

Типові помилки

1. Забув () при читанні

typescript
{{ count }} // виведе об'єкт Signal, без реактивності {{ count() }} // правильно: реєструє залежність

2. Читання Signal в конструкторі

typescript
constructor() { console.log(this.count()); // виконається один раз, більше не оновиться } // Виправлення: перенеси читання в effect() або lifecycle hook

3. set з ручним читанням замість update

typescript
count.set(count() + 1); // два читання, ризик застарілого значення count.update(c => c + 1); // правильно: атомарна трансформація

4. Пряма мутація масиву-сигналу

typescript
items.value.push('нове'); // сповіщення не надійде items.update(arr => [...arr, 'нове']); // правильно: нове посилання сповіщає залежних

5. Застарілі замикання в effects

typescript
externalValue = 10; // Неправильно: externalValue захоплюється один раз, не перевідстежується effect(() => console.log(this.count() * externalValue)); // Правильно: читай властивість класу всередині effect effect(() => console.log(this.count() * this.externalValue));

Де зустрічається в реальних проектах

  • NgRx 17+ перейшов на Signals для селекторів, замінивши RxJS map-ланцюги
  • Angular Material 16+ таблиці приймають Signal-масив як dataSource
  • Angular 17 представив input.required<string>() як Signal-заміну для @Input()
  • PrimeNG використовує toSignal() для переведення HTTP Observable в шаблони
  • Nx workspace застосовують Signal-сервіси для стану між мікрофронтендами

Питання на співбесіді

Q: Яка різниця між writable signal і computed signal?
A: Writable signal (signal(0)) надає методи set() і update() для зміни значення. computed() - лише для читання, він виводить значення з інших сигналів автоматично; виклик set() на ньому кидає помилку.

Q: Як Signals покращують продуктивність порівняно з Zone.js?
A: Zone.js запускає повний обхід дерева після кожної асинхронної події. Signals оновлюють лише брудні вузли графа залежностей. Бенчмарки Angular фіксують прискорення в 2-10 разів на великих застосунках у Zone-less режимі.

Q: Коли computed() перераховується?
A: Лише коли хоча б один upstream сигнал змінився І обчислене значення фактично прочитане. Якщо ніхто не читає - перерахунок відкладається. Результат кешується між читаннями.

Q: Що робить effect() між запусками?
A: Перед кожним повторним запуском Angular викликає onCleanup() з попереднього циклу. Використовуй його для скасування таймерів, обриву запитів або видалення слухачів подій.

Q: Як Signals запобігають UI tearing при пакетних оновленнях в Angular 18+ без Zone.js?
A: Планувальник об'єднує кілька set() в один flush мікрозадач. Всі читання computed() під час цього flush бачать узгоджений знімок усіх значень - часткові стани ніколи не потрапляють у шаблон.

Приклади

Базовий лічильник із похідним станом

typescript
import { Component, signal, computed } from '@angular/core'; @Component({ selector: 'app-counter', template: ` <p>Лічильник: {{ count() }}</p> <p>Статус: {{ status() }}</p> <button (click)="increment()">+</button> <button (click)="reset()">Скинути</button> ` }) export class CounterComponent { count = signal(0); status = computed(() => this.count() % 2 === 0 ? 'парний' : 'непарний'); increment() { this.count.update(c => c + 1); } reset() { this.count.set(0); } }

status перераховується лише при зміні count. Не при кожній події в застосунку, лише на точну залежність.

Спільний стан у сервісі

typescript
// todo.service.ts import { Injectable, signal, computed } from '@angular/core'; @Injectable({ providedIn: 'root' }) export class TodoService { private todos = signal<string[]>([]); count = computed(() => this.todos().length); completed = computed(() => this.todos().filter(t => t.startsWith('[x]')).length); addTodo(text: string) { this.todos.update(list => [...list, text]); } removeTodo(index: number) { this.todos.update(list => list.filter((_, i) => i !== index)); } } // В будь-якому компоненті: // service = inject(TodoService); // шаблон: {{ service.count() }} завдань, {{ service.completed() }} виконано

Будь-який компонент, що читає service.count(), оновиться автоматично. Жодного Subject, жодного async пайпу, жодного ручного unsubscribe.

Input signals із computed-валідацією (Angular 17+)

typescript
import { Component, input, computed } from '@angular/core'; @Component({ selector: 'app-user-card', template: ` <p>{{ name() }}</p> <span *ngIf="isAdult()">18+</span> <p>{{ roleLabel() }}</p> ` }) export class UserCardComponent { name = input.required<string>(); age = input(0); role = input<'admin' | 'user'>('user'); isAdult = computed(() => this.age() >= 18); roleLabel = computed(() => this.role() === 'admin' ? 'Адміністратор' : 'Учасник'); }

В батьківському шаблоні: <app-user-card [name]="'Alice'" [age]="25" [role]="'admin'" />. Всі три inputs - це Signals під капотом, тому computed() відстежує їх без зайвого коду і без ngOnChanges.

Коротка відповідь

Для співбесіди
Premium

Коротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.

Дочитали статтю?