Различия подходов zone.js и zoneless
Обнаружение изменений (рендеринг) в Angular обычно запускается полностью автоматически в результате асинхронных событий в браузере. Это становится возможным благодаря библиотеке zone.js. В общем, зоны обеспечивают механизм перехвата и вызова асинхронных операций. Логика перехватчика может выполнить дополнительный код до или после задачи и уведомить интересующие стороны о событии. Эти правила определяются индивидуально для каждой зоны при её создании.
Зоны составляют иерархию из родителей и потомков. В начале браузер работает в специальной корневой зоне, что позволяет любому существующему коду, который не осведомлен о зонах, работать ожидаемым образом. Только одна зона может быть активной в любой момент времени, и эту зону можно получить через свойство Zone.current
:
Хороший материал о работе zone.js доступен по ссылке.
Вопреки распространенному мнению, зоны не являются частью механизма обнаружения изменений в Angular. Фактически, Angular может работать без зон, используя changeDetection. Для включения автоматического обнаружения изменений Angular использует службу NgZone,
которая создает дочернюю зону и подписывается на её уведомления.
Эта зона называется зоной Angular, и ожидается, что выполнение приложения будет только внутри неё. Это необходимо, поскольку NgZone
получает уведомления только о событиях, происходящих внутри этой зоны Angular, и не получает уведомлений о событиях в других зонах:
Если вы изучите NgZone
, то увидите, что ссылка на созданную дочернюю зону Angular хранится в свойстве _inner
:
export class NgZone { constructor(...) { forkInnerZoneWithAngularBehavior(self); } } function forkInnerZoneWithAngularBehavior(zone: NgZonePrivate) { zone._inner = zone._inner.fork({ ... });
Это зона, используется при выполнении NgZone.run()
:
export class NgZone { run(fn, applyThis, applyArgs) { return this._inner.run(fn, applyThis, applyArgs); } }
Текущая зона в момент создания дочерней зоны Angular сохраняется в свойстве _outer
и используется для вызова при выполнении NgZone.runOutsideAngular()
:
export class NgZone { runOutsideAngular(fn) { return (this as any as NgZonePrivate)._outer.run(fn); } }
Чаще всего эта внешняя зона является верхней "корневой" (root) зоной.
Когда Angular завершает инициализацию, иерархия зон будет такая:
"root" "angular"
Что вы легко можете увидеть сами, просто зарегистрировав соответствующие свойства:
export class AppComponent { constructor(zone: NgZone) { console.log((zone as any)._inner.name); // angular console.log((zone as any)._outer.name); // root } }
Однако в режиме разработки также существует AsyncStackTaggingZone
, которая находится между root
и angular
зонами:
"root" "AsyncStackTaggingZone" "angular"
В этом случае экземпляр NgZone
хранит ссылку на AsyncStackTaggingZone
в своем свойстве _inner
. AsyncStackTaggingZone
обеспечивает связанные стековые трассировки, чтобы показать, где запланирована асинхронная операция. Больше информации об отладке changeDetection в Angular по ссылке.
Последнее, что нам нужно знать, это то, что Angular
создает экземпляр NgZone
во время фазы инициализации (bootstrapping):
export class PlatformRef { bootstrapModuleFactory<M>(moduleFactory, options?) { const ngZone = getNgZone(options?.ngZone, getNgZoneOptions(options)); const providers: StaticProvider[] = [{provide: NgZone, useValue: ngZone}]; // all initialization logic runs inside Angular zone return ngZone.run(() => {...}); } }
Angular использует событие onMicrotaskEmpty
внутри ApplicationRef
для автоматического запуска обнаружения изменений для всего приложения:
@Injectable({providedIn: 'root'}) export class ApplicationRef { constructor( private _zone: NgZone, private _injector: EnvironmentInjector, private _exceptionHandler: ErrorHandler, ) { this._onMicrotaskEmptySubscription = this._zone.onMicrotaskEmpty.subscribe({ next: () => { this._zone.run(() => { this.tick(); }); } }); } }
Теперь рассмотрим как приложение может работать без Zone.js
Zoneless подход
Чтобы запустить приложение Angular без zone.js
, мы должны передать noop
в функцию bootstrapModule
для параметра ngZone:
platformBrowserDynamic() .bootstrapModule(AppModule, { ngZone: 'noop' });
Если мы запустим это простое приложение:
@Component({ selector: 'app-root', template: `{{time}}` }) export class AppComponent { time = Date.now(); }
changeDetection полностью продолжит функционирование и обновление изменений в DOM.
Однако, если мы обновим свойство name
внутри setTimeout
:
@Component({ selector: 'app-root', template: `{{time}}` }) export class AppComponent { time = Date.now(); constructor() { setTimeout(() => { this.time = Date.now() }, 1000); } }
Страница не будет обновлена. Это ожидаемое поведение, поскольку нет зоны Angular, чтобы уведомить Angular о наступлении события таймаута. Интересно, что мы всё ещё можем внедрить NgZone
в конструктор:
import { ɵNoopNgZone } from '@angular/core'; export class AppComponent { constructor(zone: NgZone) { console.log(zone instanceof ɵNoopNgZone); // true } }
Но это пустая реализация NgZone
, которая ничего не делает.
Можно использовать changeDetectorRef
, чтобы запускать обнаружение изменений вручную:
@Component({ selector: 'app-root', template: `{{time}}` }) export class AppComponent { time = Date.now(); constructor(cdRef: ChangeDetectorRef) { setTimeout(() => { this.time = Date.now() }, 1000); cdRef.detectChanges(); } }
Запуск кода в зоне Angular
Может возникнуть ситуация, когда есть функция, которая работает вне зоны Angular, и вы не получаете преимущества автоматического обнаружения изменений. Это часто происходит с библиотеками сторонних разработчиков, которые выполняют свои задачи, не имея представления о контексте Angular.
Вот пример такого вопроса, касающегося библиотеки Google API Client Library (gapi)
. Одной из причин может быть использование техник, таких как JSONP
, которые не используют стандартные API AJAX
, такие как XMLHttpRequest
или Fetch API
, которые патчатся и отслеживаются Zones
. Вместо этого создается тег скрипта с URL-адресом и определяется глобальный обратный вызов для выполнения кода после получения данных от сервера. Этот процесс не может быть отслежен Zones, и поэтому фреймворк Angular остается неведомым к запросам, выполненным с использованием этой техники.
Решение таких проблем заключается в простом запуске обратного вызова внутри зоны Angular, как показано ниже. Например, для gapi мы должны сделать это так:
// Load the JavaScript client library. gapi.load('client', ()=> { // Run initialization code INSIDE Angular zone NgZone.run(()=>{ // Initialize the JavaScript client library gapi.client.init({...}).then(function() { ... }); }); });
Другой пример - это компонент, который генерирует уведомления функции-коллбека, запущенной вне зоны Angular:
@Component({ selector: 'n-cmp', template: '{{title}} <div><n1-cmp></n1-cmp></div>' }) export class N { title = 'N component'; emitter = new Subject(); constructor(zone: NgZone) { zone.runOutsideAngular(() => { setTimeout(() => { this.emitter.next(3); }, 1000); }); } }
Если мы просто подписываемся на изменения в дочернем компоненте N1
следующим образом:
@Component({ selector: 'n1-cmp', template: '{{title}}, emitted value: {{value}}' }) export class N1 { title = 'Child of N'; value = 'nothing yet'; constructor(parent: N, zone: NgZone) { parent.emitter.subscribe((v: any) => { this.value = v; }); } }
Мы не увидим обновлений на экране, даже если this.value
обновлено до 3 после того, как его передаст родитель. Чтобы исправить это, как и в примере с gapi, мы можем выполнить обратный вызов в зоне Angular:
@Component({...}) export class N1 { title = 'Child of N'; value = 'nothing yet'; constructor(parent: N, zone: NgZone) { parent.emitter.subscribe((v: any) => { zone.run(() => { this.value = v; }); }); } }
Это вольный перевод материала