Сигнали в Angular
Сигнали Angular (Signals) - це реактивні контейнери стану, представлені в Angular 16. Вони автоматично відстежують залежності і оновлюють лише ті частини UI, які насправді читають змінене значення.
Теорія
Коротко
- Сигнали схожі на клітинку в Excel: змінюєш A1 - перераховуються лише клітинки, що посилаються на A1, а не весь аркуш
- Zone.js проходив по всьому дереву компонентів після кожної події; Signals надсилають оновлення лише по записаному графу залежностей
- Три примітиви:
signal()зберігає стан,computed()виводить значення,effect()запускає побічні ефекти - Новий застосунок на Angular 16+: Signals для стану, RxJS - для HTTP, WebSocket і таймерів
- Міст між двома підходами:
toSignal()обгортає Observable,toObservable()- навпаки
Швидкий приклад
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
| Signals | Observables (RxJS) | |
|---|---|---|
| Завжди має значення | Так | Ні (потрібен BehaviorSubject) |
| Синхронне читання | Так | Може бути асинхронним |
| Автоочищення | Так | Ручний unsubscribe() |
| Синтаксис у шаблоні | {{ count() }} | {{ count$ | async }} |
| Оператори | Відсутні | Повний набір RxJS |
| Призначення | Синхронний стан | Асинхронні потоки подій |
Типові помилки
1. Забув () при читанні
{{ count }} // виведе об'єкт Signal, без реактивності
{{ count() }} // правильно: реєструє залежність2. Читання Signal в конструкторі
constructor() {
console.log(this.count()); // виконається один раз, більше не оновиться
}
// Виправлення: перенеси читання в effect() або lifecycle hook3. set з ручним читанням замість update
count.set(count() + 1); // два читання, ризик застарілого значення
count.update(c => c + 1); // правильно: атомарна трансформація4. Пряма мутація масиву-сигналу
items.value.push('нове'); // сповіщення не надійде
items.update(arr => [...arr, 'нове']); // правильно: нове посилання сповіщає залежних5. Застарілі замикання в effects
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 бачать узгоджений знімок усіх значень - часткові стани ніколи не потрапляють у шаблон.
Приклади
Базовий лічильник із похідним станом
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. Не при кожній події в застосунку, лише на точну залежність.
Спільний стан у сервісі
// 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+)
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.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.