Що таке ngzone в Angular?
NgZone — це обгортка Angular над Zone.js, яка патчить браузерні async API і автоматично запускає виявлення змін (change detection), коли задачі завершуються всередині Angular-зони.
Теорія
TL;DR
- NgZone схожий на датчик руху: будь-яка async-активність всередині нього (setTimeout, fetch) сигналізує Angular перевірити UI.
- Всередині зони Angular запускає change detection автоматично. Зовні він ігнорує результати async-операцій.
runOutsideAngular()для важких циклів, які не чіпають UI.run()щоб повернути колбеки сторонніх бібліотек назад у зону.- Zone.js патчить 40+ браузерних API при завантаженні, підміняючи нативні виклики.
- Angular 17+ підтримує zoneless-режим через signals як альтернативний підхід.
Швидкий приклад
import { Component, NgZone, OnInit } from '@angular/core';
@Component({
selector: 'app-demo',
template: `<p>{{count}}</p>`
})
export class DemoComponent implements OnInit {
count = 0;
constructor(private ngZone: NgZone) {}
ngOnInit() {
// Всередині зони: Angular бачить зміну і оновлює шаблон
setTimeout(() => this.count++, 1000);
// Поза зоною: count зростає в пам'яті, шаблон показує 0
this.ngZone.runOutsideAngular(() => {
setTimeout(() => this.count++, 1000);
});
}
}Перший setTimeout перехоплений Zone.js, тому Angular знає що треба запустити change detection після його завершення. Другий виконується у форкнутій зоні, за якою Angular не стежить.
Як це працює всередині
Zone.js підміняє нативні браузерні API при завантаженні. Коли код викликає window.setTimeout, насправді виконується версія Zone.js: вона форкує дочірню зону, запускає колбек і сповіщає NgZoneImpl про завершення. Angular ставить у чергу ApplicationRef.tick() через queueMicrotask, який проходить деревом компонентів і перевіряє зміни.
В dev-режимі Angular перевіряє все дерево. З компонентами OnPush перевіряються тільки позначені піддерева. Тригер однаковий в обох випадках: задача завершилась всередині зони.
Колбеки WebSocket, нативний requestAnimationFrame і багато подій сторонніх бібліотек за замовчуванням виконуються поза Angular-зоною. Zone.js не патчить всі async-шляхи. Звідси і виникає більшість NgZone-багів.
Коли використовувати
- Важкі анімаційні цикли і
setInterval-лічильники, які оновлюють UI тільки в кінці:runOutsideAngular()зупиняє Angular від запуску change detection 60 разів на секунду. - Колбеки WebSocket або сторонніх бібліотек, які мають оновлювати шаблон: загортай в
ngZone.run(), щоб повернути їх у зону. - Server-side rendering з Angular Universal: перевіряй
NgZone.isInAngularZone()перед DOM-мутаціями, щоб уникнути проблем із гідратацією. - Будь-який async, що оновлює
@Inputабо@Outputпривʼязки: залишай всередині зони (поведінка за замовчуванням).
Типові помилки
Помилка 1: Припущення, що колбеки WebSocket всередині зони
// Неправильно: шаблон ніколи не оновиться
const socket = new WebSocket('ws://api.example.com/data');
socket.onmessage = (event) => {
this.data = event.data; // Zone.js не повністю патчить WebSocket
};
// Правильно
socket.onmessage = (event) => {
this.ngZone.run(() => {
this.data = event.data;
});
};Перший раз це зʼявилось у реал-тайм дашборді: дані WebSocket приходили, масив ріс, але графік не оновлювався. Zone.js просто не стежив за цим колбеком.
Помилка 2: runOutsideAngular() для коду, що оновлює UI
// Неправильно: лічильник замирає в шаблоні
this.ngZone.runOutsideAngular(() => {
setInterval(() => this.counter++, 1000);
});
// Правильно: повертайся в зону при кожному оновленні
this.ngZone.runOutsideAngular(() => {
setInterval(() => {
this.counter++;
this.ngZone.run(() => {}); // Примусовий tick
}, 1000);
});Помилка 3: Не виносити інтервали поза зону від самого початку
// Неправильно: зона відстежує задачу весь час
ngOnInit() {
this.timer = setInterval(() => this.frameCount++, 16); // Всередині зони
}
ngOnDestroy() {
clearInterval(this.timer); // Зона стежила весь час
}
// Правильно: зона ніколи не дізнається про цей інтервал
ngOnInit() {
this.ngZone.runOutsideAngular(() => {
this.timer = setInterval(() => this.frameCount++, 16);
});
}
ngOnDestroy() {
clearInterval(this.timer);
}Помилка 4: Вкладені виклики run() в Angular 16+
Виклик ngZone.run() всередині вже активної зони створює вкладені зони і дублює виклики tick(). Спочатку перевіряй:
const update = () => { this.value = newValue; };
if (this.ngZone.isInAngularZone()) {
update();
} else {
this.ngZone.run(update);
}Де зустрічається в реальних проектах
- NG Bootstrap:
NgbModalвикористовуєrunOutsideAngular()для анімаційних фреймів, щоб досягти 60fps. - PrimeNG DataTable: обробники віртуального скролу виконуються поза зоною, щоб не запускати CD на кожен піксель прокрутки.
- Angular Material CDK: події оверлеїв проходять через
NgZone.run()при відкритті і закритті. - RxJS у сервісах:
tap(() => this.ngZone.run(() => this.update()))коли observable починається поза зоною. - Angular Universal SSR:
isBrowser ? ngZone.run(...) : directDom(...)для запобігання невідповідностей гідратації.
ChangeDetectorRef.detectChanges() — компонентна альтернатива: швидша, бо не обходить все дерево. Використовуй NgZone коли тригер приходить із сервісу або cross-component async-джерела.
Follow-up питання
Q: Як Zone.js патчить setTimeout і addEventListener?
A: window.setTimeout підміняється враппером, який викликає currentZone.runGuarded(fn). addEventListener загортає обробник у проксі, що форкує дочірню зону при виклику. Обидва сповіщають NgZoneImpl після завершення задачі.
Q: Яка вартість перебування всередині Angular-зони?
A: Кожен виклик ApplicationRef.tick() коштує 10-50мс на великих застосунках через обхід дерева компонентів. Важкі цикли поза зоною уникають цих перевірок, але кожне оновлення UI потребує ручного run().
Q: В Angular 17+ із signals NgZone ще актуальний?
A: Signals використовують планувальник замість zone-патчингу, тому zoneless-застосунки можуть відмовитись від Zone.js. Якщо є legacy-код або сторонні бібліотеки, NgZone залишається потрібним як міст між двома підходами.
Q: В компоненті OnPush з async pipe NgZone впливає на виявлення змін?
A: async pipe позначає компонент dirty через ChangeDetectorRef.markForCheck(), а не через зону. Якщо observable приходить поза зоною, async pipe сам по собі недостатній: потрібен ngZone.run(), щоб запланувати tick, який прочитає цей dirty-прапор.
Q: (Senior) Як відлагодити заморожений UI після оновлення сторонньої бібліотеки?
A: В консолі браузера виконай ng.probe($0).injector.get(NgZone).isInAngularZone() поки застосунок обробляє дані. Якщо повертає false, колбек бібліотеки виконується поза зоною. Загорни в ngZone.run() або перейди на версію з нативною інтеграцією Angular.
Приклади
WebSocket dashboard
import { Component, NgZone, OnDestroy } from '@angular/core';
@Component({
selector: 'app-dashboard',
template: `<ul><li *ngFor="let d of data">{{d}}</li></ul>`
})
export class DashboardComponent implements OnDestroy {
data: number[] = [];
private socket: WebSocket;
constructor(private ngZone: NgZone) {
this.socket = new WebSocket('ws://api.example.com/metrics');
// onmessage за замовчуванням виконується поза Angular-зоною
this.socket.onmessage = (event) => {
this.ngZone.run(() => {
// Всередині зони: Angular бачить зміну і оновлює список
this.data.push(+event.data);
});
};
}
ngOnDestroy() {
this.socket.close();
}
}
// Результат: нові значення одразу зʼявляються в шаблоніБез ngZone.run() масив зростає в памʼяті, але шаблон не перерендерюється. Це найпоширеніший NgZone-баг у production-дашбордах.
Нескінченний скрол на великих списках
import { Component, NgZone } from '@angular/core';
@Component({
selector: 'app-list',
template: `
<div *ngFor="let item of items">{{item}}</div>
<p *ngIf="loading">Завантаження...</p>
`
})
export class InfiniteListComponent {
items: string[] = [];
loading = false;
constructor(private ngZone: NgZone) {
window.addEventListener('scroll', () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight) {
this.loadMore();
}
});
}
private async loadMore() {
this.loading = true;
const newItems = await fetchMore(); // Promise поза зоною
// Повертаємось в зону тільки коли дані готові
this.ngZone.run(() => {
this.items.push(...newItems);
this.loading = false;
});
}
}
// Результат: плавний скрол на 10k+ елементах, CD лише при отриманні данихChange detection спрацьовує один раз на пакет завантаження. Не на кожен піксель прокрутки.
Progress bar із важкими обчисленнями
import { Component, NgZone } from '@angular/core';
@Component({
selector: 'app-progress',
template: `<div [style.width.%]="progress"></div>`
})
export class ProgressComponent {
progress = 0;
constructor(private ngZone: NgZone) {}
startHeavyTask() {
// 1000 анімаційних фреймів поза зоною, один фінальний рендер
this.ngZone.runOutsideAngular(() => {
let i = 0;
const tick = () => {
i++;
this.progress = i / 10; // Оновлює тільки внутрішнє значення
if (i < 1000) {
requestAnimationFrame(tick);
} else {
// Повертаємось в зону для фінального рендеру
this.ngZone.run(() => {
this.progress = 100;
});
}
};
requestAnimationFrame(tick);
});
}
}
// Результат: 0 виявлень змін під час 1000 фреймів, 1 фінальний рендерКоротка відповідь
Для співбесідиКоротка відповідь допоможе вам впевнено відповідати на цю тему під час співбесіди.