angular
March 31

Различия подходов 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;
      });
    });
  }
}

Это вольный перевод материала