Запропонувати правкуПокращити цю статтюДопрацюйте відповідь до «Як працює виявлення змін в Angular?». Ваші зміни проходять модерацію перед публікацією.Потрібне підтвердженняКонтентЩо ви змінюєте🇺🇸EN🇺🇦UAПереглядЗаголовок (UA)Коротка відповідь (UA)**Виявлення змін (change detection)** в Angular - це процес, який обходить дерево компонентів після того, як Zone.js перехопила асинхронну подію, і порівнює кожну DOM-прив'язку з останнім збереженим значенням. ```typescript @Component({ changeDetection: ChangeDetectionStrategy.OnPush, template: `<p>{{ user.name }}</p>` }) export class UserComponent { @Input() user!: User; // перевіряється лише при зміні посилання } ``` **Ключове:** Default перевіряє кожен компонент при кожному тіку. OnPush пропускає піддерева, якщо посилання `@Input()` не змінилось або Observable не видав нове значення, скорочуючи кількість перевірок на ~80% у великих застосунках.Показується над повною відповіддю для швидкого нагадування.Відповідь (UA)Зображення**Виявлення змін (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-дашбордах, де редагування імені начебто не спрацьовувало.Для рев’юераПримітка для модератора (необов’язково)Бачить лише модератор. Прискорює рев’ю.