angular
March 3

Немного про changeDetection в Angular

Пошагово, начиная с очень простых и базовых вещей и переходя к более сложным, я объясню, как работает обнаружение изменений в Angular, почему была создана ZoneJS и как будут улучшены эти технологии

В любом приложении присутствует дерево компонентов, и каждый из них имеет свой шаблон. Шаблоны описывают, какие DOM-элементы должен создавать Angular, и какие свойства должны иметь узлы DOM.

Шаблон — это всего лишь чертеж, набор инструкций. Процесс преобразования этих инструкций в реальные узлы DOM называется "рендерингом", и Angular делает это под капотом. Пока что все просто и понятно, но в конечном итоге нам приходится изменять DOM. Разберем, как Angular влияет на изменения.

<div>
  <button (click)="areaDisabled = !areaDisabled">Editable</button>
  <textarea [attr.readOnly]="areaDisabled ? 'true': null"></textarea>
  
  <button (click)="counter++">Increment</button>
  <div>{{counter}}</div>
  
</div>


В этом примере у нас есть выражения, которые определяют, как должны быть отображены узлы DOM [attr.readOnly]=”areaDisabled ? ‘true’: null” и {{counter}}.


Angular не будет перерисовывать каждый шаблон — это было бы слишком медленно. Вместо этого Angular сравнивает выражения в шаблоне, и если какие-то из них изменились в каком-то шаблоне, то этот шаблон будет перерисован. Это называется обнаружением изменений (Change Detection, CD).

Кроме того, Angular не будет выполнять этот процесс в бесконечном цикле (как мы делаем при создании игр). Чтобы узнать, изменилось ли какое-то выражение, Angular должен сравнить новое значение с предыдущим. Хотя это и не такая "дорогая" операция, выполнение ее в бесконечном цикле сделало бы наше приложение непригодным для использования (и даже быстро замораживало бы вкладку браузера).

Когда нужно запустить цикл обнаружения изменений?

На данный момент Angular использует ZoneJS для ответа на этот вопрос. ZoneJS подменяет (monkey-patches) все асинхронные API, так что функции типа setTimeout() или Promises встанут в очередь выполнения. ZoneJS также подменяет (monkey-patches) большинство DOM-событий, и они попадут в очередь на обнаружение изменений. Пока нет DOM-событий и нет выполнения асинхронного кода — циклы обнаружения изменений не будут помещены в очередь.


Как определить, какие изменения стоит проверять - а какие нет?

При внесении изменений в компонент можно пометить его как как «грязный» (dirty). Вместе со стратегией ChangeDetection OnPush, Angular во время выполнения цикла поиска изменений, пропустит все компоненты, которые не помечены как грязные. Это сокращает количество перерисовок.

Все это звучит замечательно, но кто именно должен помечать шаблон как dirty? Программист? Вручную? После каждого изменения переменных? На практике мы используем комбинацию Observable и async pipe — все привязки и выражения предоставляются Observable, и async pipe отвечает за подписку, прослушивание новых значений и пометку представления как dirty. Использование Observable обусловлено тем, что в случае обычных переменных они не сообщают движку Angular об изменениях. Observable это умеют за счет async pipe. В исходном коде async pipe явно вызывается ChangeDetectorRef, который перерисовывает представление.

 transform<T>(obj: Observable<T> | Subscribable<T> | Promise<T> | null | undefined): T | null {
    if (!this._obj) {
      if (obj) {
        try {
          // Only call `markForCheck` if the value is updated asynchronously.
          // Synchronous updates _during_ subscription should not wastefully mark for check -
          // this value is already going to be returned from the transform function.
          this.markForCheckOnValueUpdate = false;
          this._subscribe(obj);
        } finally {
          this.markForCheckOnValueUpdate = true;
        }
      }
      return this._latestValue;
    }

    if (obj !== this._obj) {
      this._dispose();
      return this.transform(obj);
    }

    return this._latestValue;
  }

Но есть и другой способ, начиная с Angular 16 была добавлена экспериментальная поддержка Angular Signals. Angular Signals - это новый способ добавления реактивности в наши шаблоны — они также уведомляют фреймворк об изменениях. Это их основная цель. Сигналы могут существовать наряду с Observable прямо сейчас, но в будущем они станут основным способом выражения реактивности в Angular-шаблонах.


Как найти измененные компоненты?

В настоящее время есть одно небольшое ограничение в этом механизме. Каждый раз, когда Angular запускает цикл Change Detection, он проходит от корневого компонента к его дочерним и так далее. Любое приложение состоит из дерева компонентов, и цикл CD будет рекурсивно проходить через это дерево.

Angular пропустит компоненты, у которых установлена стратегия OnPush и которые не помечены как грязные. Но как быть, если потомки этого компонента помечены как dirty? Как Angular их найдет?

Когда компонент помечается как dirty, Angular помечает всех его предков как грязные. Рекурсивно, пока не будет достигнут корень дерева. Это необходимый компромисс, чтобы позволить Angular найти все представления, помеченные как грязные. Это добавляет несколько проверок, но влияет на производительность не так сильно как со "стандартной" стратегией.

Кроме асинхронного кода, так же есть DOM-события. Если в шаблоне прослушиваются DOM-события, они также будут помечать представление (и всех его предков) как dirty. В нашем примере мы прослушиваем событие click:

<button (click)=”counter++”>Increment</button>

Из-за этого каждое событие click будет помечать наш компонент и всех его предков как грязные.

Лучшие практики

Очевидно, мы хотели бы иметь идеальный механизм change detection который бы «перерисовывал» только те представления, которые помечены как dirty, без лишних компонентов. Кроме того, мы бы хотели планировать циклы обнаружения изменений только тогда, когда они нам нужны, а не просто потому, что было сгенерировано какое-то DOM-событие — мы обычно не изменяем наши представления так часто, как генерируется событие mousemove.

Для достижения этих результатов команда Angular создала Angular Signals и собирается улучшить механизм планирования обнаружения изменений, чтобы планировать CD без использования ZoneJS.

На данный момент (в версии 17), если ваш компонент использует стратегию OnPush и единственным источником реактивности в шаблоне являются сигналы, то сигналы будут помечать этот компонент как dirty, но не будут помечать его предков как dirty— предки будут помечены только «для обхода», и обнаружение изменений все равно будет проходить через предков, но не будет их «выполнять» (проверять, дали ли их выражения новые результаты).

Это называется «Локальное обнаружение изменений». Звучит довольно круто, но это работает только в случае, если изменение вызвано асинхронным кодом, а не сразу после события DOM — потому что, как я писал выше, DOM-события будут помечать всех предков как dirty. Это все равно замечательный шаг вперед, и я довольно рад, что у нас есть это.

Компоненты, совместимые со стратегией OnPush, получат еще более впечатляющее обновление в будущем: они смогут работать без ZoneJS (циклы CD будут планироваться без ZoneJS). Компоненты являются «совместимыми с OnPush», когда все значения в шаблоне являются реактивными, что означает, что шаблон читает их из сигналов или Observable.

И, наконец, компоненты на основе сигналов (новый вид компонентов, еще не выпущенный) получат совершенно новое обнаружение изменений: обнаруживать будут только измененные представления, для планирования цикла CD не потребуется ZoneJS.

Обновление

Вы уже можете попробовать работу без zone.js с помощью функции ɵprovideZonelessChangeDetection(). Для этого приложение должно быть совместимо с OnPush.

Если приложение или сторонние библиотеки используют Zone.onStable() или Zone.onMicrotaskEmpty(), вам может потребоваться следующий код:

import { EventEmitter, NgZone } from "@angular/core";
import { ReplaySubject } from "rxjs";

class ReplayEmitter<T> extends ReplaySubject<T> {
  emit(v: T) {
    this.next(v);
  }

  constructor(_isAsync?: boolean) {
    super(1);
  }
}

export class NoopNgZone implements NgZone {
  readonly hasPendingMicrotasks = false;
  readonly hasPendingMacrotasks = false;
  readonly isStable = true;
  readonly onUnstable = new EventEmitter<any>();

  get onMicrotaskEmpty() {
    const e = new ReplayEmitter<any>();
    setTimeout(() => e.next(null));
    return e as unknown as EventEmitter<any>;
  }

  get onStable() {
    const e = new ReplayEmitter<any>();
    setTimeout(() => e.next(null));
    return e as unknown as EventEmitter<any>;
  }

  readonly onError = new EventEmitter<any>();

  run<T>(fn: (...args: any[]) => T, applyThis?: any, applyArgs?: any): T {
    return fn.apply(applyThis, applyArgs);
  }

  runGuarded<T>(
    fn: (...args: any[]) => any,
    applyThis?: any,
    applyArgs?: any
  ): T {
    return fn.apply(applyThis, applyArgs);
  }

  runOutsideAngular<T>(fn: (...args: any[]) => T): T {
    return fn();
  }

  runTask<T>(
    fn: (...args: any[]) => T,
    applyThis?: any,
    applyArgs?: any,
    _name?: string
  ): T {
    return fn.apply(applyThis, applyArgs);
  }
}

export function provideNoopNgZone() {
  return { provide: NgZone, useClass: NoopNgZone };
}
  
  
/**
 * in your code:
 * 
 * bootstrapApplication(AppComponent, {
 *   providers: [
 *     // ...
 *     ɵprovideZonelessChangeDetection(),
 *     provideNoopNgZone(),
 *   ]
 * });
 */

Слайды, демо

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