Як працює Change Detection в Angular
Привіт усім! Мене звати Олег. В ІТ я вже понад 15 років, із них половину працював з Angular. Однак мій поточний проєкт не передбачає його використання, тож час від часу освіжаю знання, щоб залишатися у формі. Під час чергового перечитування матеріалів про Angular помітив, що маю прогалини в розумінні механізму виявлення змін (change detection). Почав досліджувати цю тему та записувати свої спостереження — з часом ці нотатки перетворилися на повноцінну статтю. Оскільки я не знайшов схожих матеріалів, вирішив поділитися нею.
Я планую охопити всі аспекти циклу виявлення змін. Водночас деякі речі доведеться спростити, щоб зробити матеріал зрозумілішим. Також я підготував демо-застосунок, якщо вам цікаво поекспериментувати з механізмом виявлення змін, або ви можете переглянути код у репозиторії на GitHub.
Допомогати нам буде просте дерево компонентів із різними конфігураціями. Натискання на компонент запускає setInterval()
, який оновлює й відображає змінни на екрані. Нижче наведено приклад коду компонента.
@Component({ ... }) class BlockDefaultComponent { count: null | number = null; triggerInterval() { // Initialize counter value this.count = 0; // Stop the previous interval if it exists clearInterval(this.intervalRef); // Start a new interval this.intervalRef = **setInterval**(() => { // Increment the counter by 1 this.count = (this.count ?? 0) + 1; }, INTERVAL_DELAY); } }
Стратегії виявлення змін
Почнімо зі стратегії виявлення змін Default, яка ілюструється на анімації нижче.
Тут видно, як Angular оновлює кожен компонент у застосунку під час циклу виявлення змін.
Коли я кажу «оновлює», маю на увазі процес виявлення змін в Angular. Це не просто «швидкий погляд» — Angular порівнює всі змінні та виконує всі функції в шаблоні для кожного компонента.
Так, це не ідеально, але принаймні Angular виявляє та відображає зміни. А що буде, якщо setInterval()
не змінює жодного значення?
triggerInterval() { // Stop the previous interval if it exists clearInterval(this.intervalRef); // Start a new interval this.intervalRef = setInterval(() => { // Do nothing }, INTERVAL_DELAY); }
Анімація нижче ілюструє таку поведінку.
Навіть якщо setInterval()
не змінює нічого, Angular все одно виконує повне оновлення дерева компонентів. Запуск виявлення змін по всьому застосунку може серйозно впливати на продуктивність, особливо в застосунках з великою кількістю асинхронних операцій або з великим деревом компонентів.
Angular не запускає процес виявлення змін одразу в момент їх виникнення під час виконання операції. Натомість завдяки
zone.js
він відкладається до завершення всіх асинхронних операцій. При цьому Angular насправді навіть не знає, де саме відбулася зміна — лише те, що вона могла статися.
Команда Angular це знала й запропонувала рішення. Стратегія OnPush дозволяє розробникам «відрізати» або виключити деякі гілки дерева з процесу виявлення змін і вручну позначати, в якому компоненті сталася зміна. Подивімося, як це працює.
Щось пішло не так — я не позначив компонент як «брудний» (dirty).
Я використовую фразу «позначити як брудний» (mark as dirty), бо вона краще відображає суть процесу. Хоча розробник викликає метод
ChangeDetectorRef.markForCheck()
, внутрішньо Angular викликає функціюmarkViewDirty()
, яка позначає поточний компонент і всіх його батьків до самого кореня як брудні. Після цього Angular оновить ці компоненти під час наступного циклу виявлення змін.
Нижче показано приклад базового компонента зі стратегією OnPush.
@Component({ ... changeDetection: ChangeDetectionStrategy.OnPush }) export class BlockOnPushComponent { count: null | number = null; private changeDetectorRef = inject(ChangeDetectorRef) triggerInterval() { // Initialize counter value this.count = 0; // Stop the previous interval if it exists clearInterval(this.intervalRef); // Start a new interval this.intervalRef = setInterval(() => { // Increment the counter by 1 this.count = (this.count ?? 0) + 1; // Mark the current component and all its ancestors as “dirty” this.changeDetectorRef.markForCheck(); }, INTERVAL_DELAY); } }
Подивімося, що відбувається під час виконання.
У цьому випадку компонент і його батьківський компонент позначені як «брудні». Angular оновлює лише ці компоненти. Також можна побачити, що натискання спричинило оновлення — у деяких випадках Angular самостійно позначає компоненти замість нас.
Стратегія OnPush вимагає від розробника вручну вказувати, які компоненти потрібно оновити. Ви повинні явно викликати
ChangeDetectorRef.markForCheck()
, щоб позначити компонент, який потребує оновлення. Водночас Angular автоматично позначає компоненти в таких випадках:
- змінилось вхідне значення (
@Input
);- відбулась подія, зв’язана з шаблоном (включаючи
@Output
або обробник події);async
пайп отримав нове значення;- зміна стану блоку
@defer
.Це працює також і для стратегії Default.
Змішані стратегії виявлення змін
Давайте розглянемо, як змішання стратегій виявлення змін поводяться в різних сценаріях. Почнемо з поширеного випадку — коли частина застосунку переходить на стратегію OnPush.
Я запускаю дві асинхронні функції в різних компонентах. Як видно, їхні гілки оновлюються окремо, на відміну від компонентів зі стратегією Default, які оновлюються щоразу.
Тепер проаналізуймо, що відбувається, коли застосунок в основному використовує OnPush, але має деякі компоненти зі стратегією Default.
Як і очікувалось, оновлюються лише компоненти, позначені як «брудні» (dirty). А як щодо компонентів, які використовують стратегію Default?
Погляньмо з висоти пташиного польоту, як Angular виконує виявлення змін для кожного компонента.
На відміну від процесу «позначення», виявлення змін починається з кореневого компонента і відвідує всіх нащадків у глибину (depth-first), дотримуючись наступних умов відвідування:
- використовує стратегію
Default
(позначений як «перевіряти завжди»);- позначений як «брудний» (dirty);
- позначений як «має нащадка для оновлення» (has child to refresh) або «оновити представлення (refresh view) — це я поясню трохи згодом.
Цей процес триває доти, доки Angular не відвідає всі компоненти, що відповідають цим критеріям.
Мій перший клік по компоненту [e] викликав подію, прив’язану до шаблону, яка запустила setInterval()
і змусила Angular позначити цей компонент і його батьків як «брудні». Після завершення події Angular почав оновлювати всі позначені компоненти. Потім, коли спрацював setInterval()
, процес виявлення змін почався, але одразу зупинився, оскільки кореневий компонент не відповідав умовам для відвідування.
Нічого не відбулося до наступного кліку на компонент [c], який знову викликав подію з шаблону, повторивши попередній процес. Однак цього разу Angular не зупинився на компоненті, який ініціював подію — його дочірній компонент також оновився, оскільки відповідав умовам відвідування.
Наступна анімація краще показує цей процес.
Angular оновив компоненти [a], [b], [d] і [f]. Після кліку на компоненті [b], цей компонент і його батьківський [a] були позначені як «брудні». Процес виявлення змін почався з оновлення кореневого компонента [a], який був позначений як «брудний», далі оновився дочірній компонент [b]. Але на цьому процес не зупинився, оскільки компонент [d] використовує стратегію Default, яка відповідає умовам відвідування, і в результаті оновлюється також останній компонент [f], який також має стратегію Default.
Ручне виявлення змін
Тепер настав час поговорити про zone.js і сервіс ChangeDetectorRef
.
Zone
У Angular виявлення змін зазвичай тригериться автоматично через асинхронні операції. Це реалізовано за допомогою zone.js — бібліотеки, яка дозволяє перехоплювати та керувати виконанням асинхронних операцій. Зони дають можливість виконувати додаткову логіку до або після асинхронної операції, інформуючи про це зацікавлені частини програми.
Створимо нову зону та виконаємо setTimeout()
у її межах:
// Create a new zone based on the current one, // adding behavior when invoking operations (e.g., setTimeout). const zone = Zone.current.fork({ onInvokeTask: (delegate, current, ...params) => { console.log('Before setTimeout'); // Execute the actual operation (e.g., setTimeout) delegate.invokeTask(...params); console.log('After setTimeout'); } }); // Run a function within the created zone. zone.run(() => { setTimeout(() => { console.log('Async operation'); }, 1000); });
Після виконання коду ми побачимо такий результат:
Before setTimeout Async operation After setTimeout
Angular завантажує zone.js у кожен застосунок і надає сервіс NgZone
, який дозволяє керувати поточною зоною. Існує Observable об’єкт onMicrotaskEmpty
, який надсилає повідомлення, коли черга мікрозавдань порожня. Angular використовує цей механізм, щоб визначити, коли всі асинхронні задачі завершено, і тоді безпечно запускати зміну стану (change detection).
Оригінальний код можна переглянути тут, а ось спрощена версія:
private zone = inject(**NgZone**); private applicationRef = inject(ApplicationRef); this.zone.**onMicrotaskEmpty**.subscribe(() => { this.zone.**run**(() => this.applicationRef.tick()); });
Angular надає два методи для ручного запуску виявлення змін:
ApplicationRef::tick()
ChangeDetectorRef.detectChanges()
У наведеному вище коді демонструється використання ApplicationRef::tick()
, який, як ви могли здогадатись, запускає цикл виявлення змін по всьому застосунку. Angular виконує його без додаткової інформації — лише знаючи, що завершилась асинхронна операція.
Другий метод, ChangeDetectorRef.detectChanges()
, синхронно запускає виявлення змін для конкретної гілки компонента (оновлюється сам компонент та компоненти під ним). До речі, цей метод також використовується всередині ApplicationRef::tick()
тільки для кореневого компонента.
ChangeDetectorRef
Ми вже знаємо кілька методів, які надає ChangeDetectorRef
, але є й інші корисні методи:
markForCheck()
— позначає компонент як такий, що змінився, тож він буде перевірений під час наступного циклу.detectChanges()
— запускає процес виявлення змін для цього компонента та його дочірніх.detach()
— від’єднує компонент від дерева виявлення змін. Від’єднані компоненти не перевіряються, навіть якщо вони позначені як «брудні».reattach()
— повторно приєднує компонент до дерева виявлення змін.
@Component({ ... }) export class BlockDefaultDetectChangesComponent { count: null | number = null; private changeDetectorRef = inject(ChangeDetectorRef); private zoneRef = inject(NgZone); triggerInterval() { // Initialize counter value this.count = 0; // Detaches this component from the change-detection tree. this.changeDetectorRef.detach(); this.changeDetectorRef.detectChanges(); // Stop the previous interval if it exists clearInterval(this.intervalRef); // Run the interval logic outside Angular's zone this.zoneRef.runOutsideAngular(() => { // Start a new interval this.intervalRef = setInterval(() => { // Increment the counter by 1 this.count = (this.count ?? 0) + 1; // Manually trigger change detection since it's detached this.changeDetectorRef.detectChanges(); }, INTERVAL_DELAY); }); }
У коді вище компонент від’єднується від дерева виявлення змін при першому кліку. Саме тому ми повинні викликати detectChanges()
, щоби відобразити зміни — адже detach()
виключив цей компонент із глобального процесу.
Потім ми запускаємо setInterval()
поза зоною Angular. Це необхідно, оскільки зона не знає, коли завершено асинхронну операцію, й не запустить зміну стану по всьому застосунку.
І насамкінець, виявлення змін для цього компонента вручну виконується під час кожного інтервалу, щоби відобразити нові значення.
Анімація нижче показує, як поводиться від’єднаний компонент.
Мій перший клік видалив компонент [e] із дерева виявлення змін і запустив локальне виявлення змін. Angular також тригернув глобальне виявлення змін після кліку.
Другий клік на компоненті [g] оновив лише конкретні компоненти [a], [b], [c] і [d]. Компонент [e] — від’єднаний, тож Angular його і його дочірні пропускає — ми не бачимо змін у компоненті [g].
Оновлення в [g] відбулося пізніше, коли setInterval()
з [e] виконав ChangeDetectorRef.detectChanges()
, тригернувши зміну стану для компонента [e] та його дочірніх — за тими самими умовами, що й у глобальному циклі.
Натомість setInterval()
у [g] запустив глобальний процес, бо працював у межах Angular-зони. Але оскільки [e] був від’єднаний, Angular пропустив його та дочірні компоненти.
Запуск від’єднаного компонента з усіма його дочірніми поза Angular-зоною — єдиний спосіб отримати кращий контроль над процесом виявлення змін.
Сигнали та виявлення змін
Тепер розгляньмо цикл виявлення змін у поєднанні з сигналами.
@Component({ ... // changeDetection: ChangeDetectionStrategy.OnPush }) export class BlockOnPushSignalComponent { count = signal<number | null>(null); triggerInterval() { // Initialize counter value this.count.set(0); // Stop the previous interval if it exists clearInterval(this.intervalRef); // Start a new interval this.intervalRef = **setInterval**(() => { // Increment the counter by 1 this.count.update(i => (i ?? 0) + 1); }, INTERVAL_DELAY); } }
Анімація нижче демонструє, як процес виявлення змін реагує на зміну сигналу в компонентах зі стратегією Default.
Цікавіша ситуація виникає, коли компонент із сигналом має стратегію OnPush, а кореневий компонент — Default.
Усе виглядає як очікувалося, за винятком одного моменту, коли компонент [e] був відвіданий лише один раз.
Тепер заглибимось глибше. Щоб продемонструвати, як це працює, подивімося наступну анімацію. Вона показує ситуацію, коли кореневий компонент використовує стратегію OnPush, і ми взаємодіємо з компонентами зі стратегією Default, клікаючи на них.
Тут видно, що кореневий компонент [a] відвідується двічі — по одному разу на кожен клік. Наступний клік запускає виявлення змін для всіх компонентів, вкладених у компонент [b].
Як згадувалося раніше, компоненти можуть бути позначені як «has child to refresh» (має нащадка, який треба оновити) або «refresh view» (оновити представлення) у певних сценаріях. Як можна здогадатися, ця функція викликається під час оновлення сигналу. Це схоже на
markViewDirty()
, але з однією ключовою відмінністю: вона позначає сам компонент як «refresh view», а всіх його предків — як «has child to refresh».Сигнали також приносять новий режим виявлення змін. Angular тепер має два режими: старий «Global» (глобальний) і новий «Targeted» (цільовий). У «Targeted» режимі Angular проходить тільки ті компоненти, які позначені як «has child to refresh», та оновлює ті, що мають мітку «refresh view». Компоненти з міткою «dirty» або ті, що використовують стратегію Default, ігноруються у цьому режимі.
«Targeted» режим вмикається, коли Angular не знаходить компонентів, які можна оновити, і вимикається, коли оновлено компонент із міткою «refresh view».
Код, який реалізує цю логіку, можна переглянути тут.
У чому різниця між «refresh view» і «dirty»? Angular завжди оновлює компоненти з міткою «refresh view», незалежно від активного режиму. У той час як «dirty» компоненти оновлюються лише в «Global» режимі.
Початковий клік по компоненту [d] викликав подію, пов’язану з шаблоном. Angular автоматично позначив компонент [d] та його предків [b] і [a] як «dirty». Цикл виявлення змін оновив усі ці компоненти.
Потім setInterval()
змінив значення сигналу, і Angular позначив лише компонент [d] як «refresh view», а його батьків [b] і [a] — як «has child to refresh». Оскільки кореневий компонент має стратегію OnPush і не був «dirty», Angular перейшов у «Targeted» режим. Цей режим пропускає пряме оновлення компонентів «has child to refresh», натомість перевіряє їхніх нащадків на наявність мітки «refresh view». Зрештою, Angular оновив компонент [d].
Другий клік на компоненті [b] викликав подію шаблону і позначив усіх предків як «dirty», тому Angular оновив їх усіх.
Після того як setInterval()
з компонента [b] змінив сигнал, Angular позначив компонент [b] як «refresh view», а його предка [a] як «has child to refresh». Цикл перейшов у «Targeted» режим, пройшовся по дітях компонента [a], виявив компонент [b] з міткою «refresh view», оновив його і повернувся до «Global» режиму. Потім Angular оновив компоненти [c] і [d], оскільки вони використовують Default і відповідають критеріям відвідування.
Наступна анімація показує, як зміни працюють краще зі стратегією OnPush.
Zoneless
Коли Angular проєктувався вперше, команда вибрала zone.js як основу механізму виявлення змін, оскільки це було найкраще рішення на той час. Це спрощувало розробку і забезпечувало передбачуване виявлення змін. Це також дозволило команді сфокусуватися на інших аспектах Angular. Тепер Angular розвивається далі — відходить від zone.js і переходить до сигналів зі стратегією OnPush.
Переваги видалення zone.js як залежності:
- Покращена продуктивність: як ми вже знаємо, zone.js виявляє, коли Angular може знадобитися запустити цикл виявлення змін. Проте zone.js не може визначити, чи справді змінився стан, тому оновлення відбуваються частіше, ніж потрібно.
- Покращені Core Web Vitals: zone.js додає певний обсяг до розміру файлу та збільшує час старту застосунку.
- Полегшене дебагування: zone.js значно ускладнює процес відлагодження через глибоко вкладені stack traces.
У зонлес-режимі Angular надає внутрішній сервіс ChangeDetectionScheduler
як альтернативу zone.js для планування циклів виявлення змін. Метод notify
цього сервісу викликається функціями markViewDirty
і markAncestorsForTraversal
, які в свою чергу викликаються в різних ситуаціях.
Варто зазначити, що видалення зони не гарантує автоматичне покращення продуктивності в усіх випадках. Анімація нижче демонструє, як працює виявлення змін із сигналами, коли батьківські компоненти використовують Default.
Стратегія Default нівелює переваги zoneless. Варто розуміти, що zoneless — це лише про те, як буде запущено цикл виявлення змін. Уся інша логіка циклу залишається незмінною. Тому стратегія OnPush у цьому випадку працюватиме краще. Це демонструє наступна анімація.
Виявлення змін і ngOnCheck()
Наостанок хочу поділитися кількома думками щодо життєвого циклу ngOnCheck()
. На анімації нижче жовтий фон означає, що компонент було оновлено, а червоний текст показує, що в компоненті був викликаний ngOnCheck()
.
Як бачите, в деяких компонентах ngOnCheck()
було викликано двічі.
Angular викликає ngOnCheck()
:
- у самому компоненті — перед його оновленням;
- у дочірніх компонентах — після оновлення батьківського компонента;
Розгляньмо, як можна використовувати ngOnCheck()
. У деяких випадках вбудований механізм виявлення змін Angular не може зафіксувати зміни — наприклад, якщо оновлення ініціюються поза межами Angular (через сторонні бібліотеки, слухачі подій або маніпуляції з даними).
@Component({ ... changeDetection: ChangeDetectionStrategy.OnPush }) export class BlockOnPushComponent implements DoCheck { // Assume some untracked changes in this object data: any = {}; private previousData?: string; private changeDetectorRef = inject(ChangeDetectorRef) ngDoCheck(): void { // Check if the data has changed if (JSON.stringify(this.data) !== JSON.stringify(this.previousData)) { // Update snapshot this.previousData = structuredClone(this.data); // Marks the component for change detection this.changeDetectorRef.markForCheck(); // or // this.changeDetectorRef.detectChanges(); } } }
Це один із можливих варіантів використання. Переважно ми застосовуємо цей підхід, коли потрібно реалізувати власну логіку виявлення змін.
Тепер спростимо приклад і додамо логіку, яка примусово оновлює компоненти [b] і [d]. Подивимось на код нижче:
ngDoCheck(): void { // Check if the component is 'b' or 'd' if (['b', 'd'].includes(this.id)) { // Marks the component for change detection this.changeDetectorRef.markForCheck(); } }
Результат показано на анімації нижче.
Як бачите, Angular перевіряє компоненти [b] і [d] під час основного циклу виявлення змін.
Висновок
Підсумовуючи, можна сказати, що механізм виявлення змін в Angular суттєво еволюціонував — від Zone.js і стратегії Default до більш гнучкої й ефективної системи, що базується на сигналах і стратегії OnPush.
Ми побачили, як стратегія Default може призводити до втрати продуктивності. Перехід до OnPush відкрив можливість ручного керування, дозволяючи розробникам покращити продуктивність, але й вимагаючи глибшого розуміння тригерів оновлення. Сигнали — це наступний крок: вони забезпечують високоточну реактивність і мінімізують зайві оновлення, особливо в поєднанні з OnPush.
Для детального занурення в механізми виявлення змін рекомендую звернути увагу на наступні статті:
— A change detection, zone.js, zoneless, local change detection, and signals story 📚
— The Latest in Angular Change Detection — All You Need to Know
— Change Detection Big Picture — Overview
— Change detection and component trees in Angular applications
Ця стаття надихнула мене на створення анімацій:
8 коментарів
Додати коментар Підписатись на коментаріВідписатись від коментарів