Skip to main content

Що таке 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 як альтернативний підхід.

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

typescript
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 всередині зони

typescript
// Неправильно: шаблон ніколи не оновиться 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

typescript
// Неправильно: лічильник замирає в шаблоні this.ngZone.runOutsideAngular(() => { setInterval(() => this.counter++, 1000); }); // Правильно: повертайся в зону при кожному оновленні this.ngZone.runOutsideAngular(() => { setInterval(() => { this.counter++; this.ngZone.run(() => {}); // Примусовий tick }, 1000); });

Помилка 3: Не виносити інтервали поза зону від самого початку

typescript
// Неправильно: зона відстежує задачу весь час 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(). Спочатку перевіряй:

typescript
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

typescript
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-дашбордах.

Нескінченний скрол на великих списках

typescript
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 із важкими обчисленнями

typescript
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 фінальний рендер

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

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

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

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