Як працює виявлення змін в Angular?
Виявлення змін (change detection) в Angular - це механізм, який обходить дерево компонентів і синхронізує DOM-прив'язки при зміні стану застосунку.
Теорія
TL;DR
- Zone.js патчить
setTimeout,fetchіaddEventListener, щоб перехоплювати асинхронну активність і автоматично запускати обхід дерева - Default стратегія перевіряє кожен компонент при кожній події. OnPush пропускає піддерево, якщо посилання
@Input()не змінилось або Observable не видав нове значення - OnPush +
asyncpipe скорочує кількість перевірок приблизно на 80% в застосунках з 50+ компонентами - Angular порівнює по посиланню, не по значенню. Мутація об'єкта не запустить OnPush
Швидкий приклад
import { Component } from '@angular/core';
@Component({
selector: 'app-counter',
template: `<button (click)="count++">{{ count }}</button>`
})
export class CounterComponent {
count = 0;
}Клік генерує DOM-подію. Zone.js перехоплює її і планує виклик ApplicationRef.tick(), який обходить дерево від кореня. Angular знаходить {{ count }}, бачить зміну з 0 на 1 і оновлює DOM. Ось і весь цикл.
Як Zone.js запускає цикл
Angular не стежить за даними напряму. Zone.js патчить браузерні API: addEventListener, setTimeout, setInterval, XMLHttpRequest, fetch і Promise. Коли будь-який з них спрацьовує всередині Angular zone, планується мікрозадача. Коли вона виконується, стріляє NgZone.onMicrotaskEmpty і викликається ApplicationRef.tick().
tick() стартує з кореневого ViewRef (в Ivy це LView) і йде вниз по всьому дереву. Для кожного view Angular запускає lifecycle хуки, порівнює кожну прив'язку з останнім збереженим значенням і оновлює DOM при змінах. Ivy оптимізує це через 8-байтові прапорці на кожному LView, щоб пропускати "чисті" view без зайвих перевірок.
Default проти OnPush
Default перевіряє кожен компонент при кожному тіку без винятків. Клік будь-де в застосунку запускає перевірку всіх 200 компонентів, навіть якщо 195 з них не мають жодного стосунку до цього кліку.
OnPush змінює правила. Angular пропускає компонент і його піддерево, якщо не виконується одна з умов: змінилось посилання @Input(), подія відбулась всередині самого компонента, Observable відправив значення через async pipe, або було викликано markForCheck() вручну.
Різниця в цифрах: 100 компонентів з Default дають 100 перевірок на кожен клік. З OnPush і лише 5 змінених inputs - 5 перевірок.
Коли яку стратегію використовувати
- Малий застосунок, менше 10 компонентів: Default підійде, без жодних налаштувань
- Список або таблиця зі 100+ рядками: OnPush +
trackByв*ngFor - NgRx або Observable-насичений код: OnPush +
asyncpipe скрізь - Часті оновлення через WebSocket або таймери:
ChangeDetectorRef.markForCheck()всередині підписки
Як Angular порівнює значення
Angular використовує порівняння за посиланням (===), а не глибоке порівняння. Це важливо для OnPush.
// OnPush бачить те саме посилання - оновлення не відбудеться
this.user.name = 'Bob';
// Нове посилання - OnPush запустить перевірку
this.user = { ...this.user, name: 'Bob' };Саме тому незмінні оновлення (immutable updates) є стандартним підходом в OnPush-компонентах. Той самий масив - перевірки не буде. Новий масив через spread або .concat() - перевірка запуститься.
Типові помилки
Мутація @Input об'єктів в дочірньому компоненті
// Батьківський компонент передає user, дочірній мутує його напряму
this.user.name = 'Bob'; // OnPush-компонент не перерендеритьсяOnPush порівнює посилання на об'єкти. Мутація не видима для нього. Завжди повертайте новий об'єкт або масив з батьківського компонента.
setTimeout для оновлення стану
setTimeout(() => this.data = newData, 0); // працює, але запускає обхід всього дереваПри масштабуванні це вдарить по продуктивності. Краще використовувати markForCheck() в OnPush-компоненті з Observable-станом.
Скрізь відключати перевірки через detach
constructor(private cdr: ChangeDetectorRef) {
this.cdr.detach(); // цей компонент більше не перевіряється автоматично
}Відключені компоненти потребують ручних викликів detectChanges(), інакше UI замерзне. Detach варто застосовувати лише в листових OnPush-компонентах без взаємодії з користувачем, і detectChanges() викликати обережно.
Виклики функцій в шаблонах
<div *ngFor="let item of getFilteredItems()">{{ item }}</div>getFilteredItems() виконується при кожному циклі виявлення змін. При великих списках на 60fps це вбиває продуктивність. Обчислюйте результат в ngOnInit або використовуйте pure: true pipe.
Де зустрічається в реальних проектах
- Angular Material таблиця: OnPush +
trackByдля virtual scroll з 10k рядками - NgRx:
asyncpipe в OnPush-компонентах як рекомендований підхід в офіційній документації - AngularFire: Zone.js автоматично перехоплює Firebase realtime listeners, ручних тригерів не потрібно
- Nx монорепозиторії: Default для lazy-loaded feature модулів, OnPush для компонентів в shared lib
Питання від інтерв'юерів
Q: Що конкретно патчить Zone.js і навіщо це Angular?
A: Zone.js патчить addEventListener, setTimeout, setInterval, XMLHttpRequest, fetch і Promise. Angular не відстежує зміни всередині класів компонентів напряму. Zone.js дає йому сигнал, що щось асинхронне сталося, а далі Angular сам вирішує, що перевіряти.
Q: Що відбувається в застосунках без Zone.js?
A: Виявлення змін запускається вручну через ChangeDetectorRef.detectChanges() або markForCheck(). Angular Signals (Angular 16+) - це чистіший шлях: вони точково сповіщають Angular про конкретну зміну без обходу всього дерева.
Q: Default проти OnPush в цифрах?
A: При 100 компонентах і Default кожен клік запускає 100 перевірок. З OnPush і 5 змінених inputs - 5 перевірок. Бенчмарки команди Angular показують близько 80% скорочення циклів у складних дашбордах.
Q: Чим Ivy відрізняється від ViewEngine в плані виявлення змін?
A: Ivy зберігає LView прапорці як 8-байтові вказівники, що дозволяє пропускати чисті view за 2 інструкції замість обходу всієї структури. ViewEngine позначав цілі view як змінені. Signals в Angular 17+ ідуть ще далі: детальна реактивність без жодного обходу дерева.
Q: Навіщо async pipe замість ручного subscribe()?
A: async pipe автоматично відписується при знищенні компонента і сам викликає markForCheck(). При ручному subscribe() в OnPush-компоненті потрібно викликати markForCheck() самостійно, інакше view не оновиться.
Приклади
Лічильник з Default стратегією
@Component({
selector: 'app-counter',
template: `
<p>Рахунок: {{ count }}</p>
<button (click)="increment()">+</button>
`
})
export class CounterComponent {
count = 0;
increment() {
this.count++; // Zone.js перехоплює клік, запускається обхід дерева
}
}Zone.js перехоплює клік, виконується tick(), Angular знаходить змінений {{ count }} і оновлює DOM. З Default стратегією кожен компонент в застосунку перевіряється при цьому кліку.
Список задач з OnPush і async pipe
@Component({
selector: 'app-todos',
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ul>
<li *ngFor="let todo of todos$ | async; trackBy: trackById">
{{ todo.title }}
</li>
</ul>
`
})
export class TodosComponent {
todos$ = this.todoService.todos$; // Observable з HttpClient
trackById(index: number, todo: Todo) {
return todo.id; // стабільний ідентифікатор для повторного використання DOM-вузлів
}
constructor(private todoService: TodoService) {}
}async pipe підписується на Observable і викликає markForCheck() при новому значенні. З trackBy Angular повторно використовує існуючі DOM-вузли для незмінених елементів. 100 задач завантажуються один раз, повторно рендеряться лише змінені.
OnPush з незмінними оновленнями
@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<p>{{ user.name }}</p>`
})
export class UserComponent {
@Input() user: User = { name: 'Alice' };
}
// В батьківському компоненті - неправильно: мутуємо існуючий об'єкт
this.currentUser.name = 'Bob'; // UserComponent залишиться з "Alice"
// В батьківському компоненті - правильно: нове посилання
this.currentUser = { ...this.currentUser, name: 'Bob' }; // UserComponent оновиться до "Bob"OnPush порівнює @Input() за посиланням (===). Мутація об'єкта зберігає те саме посилання, тому Angular пропускає піддерево. Новий об'єкт змушує Angular перевірити компонент. Я бачив цей баг в продакшн OnPush-дашбордах, де редагування імені начебто не спрацьовувало.
Коротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.