Skip to main content

Як працює виявлення змін в Angular?

Виявлення змін (change detection) в Angular - це механізм, який обходить дерево компонентів і синхронізує DOM-прив'язки при зміні стану застосунку.

Теорія

TL;DR

  • Zone.js патчить setTimeout, fetch і addEventListener, щоб перехоплювати асинхронну активність і автоматично запускати обхід дерева
  • Default стратегія перевіряє кожен компонент при кожній події. OnPush пропускає піддерево, якщо посилання @Input() не змінилось або Observable не видав нове значення
  • OnPush + async pipe скорочує кількість перевірок приблизно на 80% в застосунках з 50+ компонентами
  • Angular порівнює по посиланню, не по значенню. Мутація об'єкта не запустить OnPush

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

typescript
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 + async pipe скрізь
  • Часті оновлення через WebSocket або таймери: ChangeDetectorRef.markForCheck() всередині підписки

Як Angular порівнює значення

Angular використовує порівняння за посиланням (===), а не глибоке порівняння. Це важливо для OnPush.

typescript
// OnPush бачить те саме посилання - оновлення не відбудеться this.user.name = 'Bob'; // Нове посилання - OnPush запустить перевірку this.user = { ...this.user, name: 'Bob' };

Саме тому незмінні оновлення (immutable updates) є стандартним підходом в OnPush-компонентах. Той самий масив - перевірки не буде. Новий масив через spread або .concat() - перевірка запуститься.

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

Мутація @Input об'єктів в дочірньому компоненті

typescript
// Батьківський компонент передає user, дочірній мутує його напряму this.user.name = 'Bob'; // OnPush-компонент не перерендериться

OnPush порівнює посилання на об'єкти. Мутація не видима для нього. Завжди повертайте новий об'єкт або масив з батьківського компонента.

setTimeout для оновлення стану

typescript
setTimeout(() => this.data = newData, 0); // працює, але запускає обхід всього дерева

При масштабуванні це вдарить по продуктивності. Краще використовувати markForCheck() в OnPush-компоненті з Observable-станом.

Скрізь відключати перевірки через detach

typescript
constructor(private cdr: ChangeDetectorRef) { this.cdr.detach(); // цей компонент більше не перевіряється автоматично }

Відключені компоненти потребують ручних викликів detectChanges(), інакше UI замерзне. Detach варто застосовувати лише в листових OnPush-компонентах без взаємодії з користувачем, і detectChanges() викликати обережно.

Виклики функцій в шаблонах

html
<div *ngFor="let item of getFilteredItems()">{{ item }}</div>

getFilteredItems() виконується при кожному циклі виявлення змін. При великих списках на 60fps це вбиває продуктивність. Обчислюйте результат в ngOnInit або використовуйте pure: true pipe.

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

  • Angular Material таблиця: OnPush + trackBy для virtual scroll з 10k рядками
  • NgRx: async pipe в 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 стратегією

typescript
@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

typescript
@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 з незмінними оновленнями

typescript
@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-дашбордах, де редагування імені начебто не спрацьовувало.

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

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

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

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