angular
March 30

Разбор OnPush стратегии в Angular

Angular реализует две стратегии, которые контролируют поведение обнаружения изменений на уровне отдельных компонентов. Эти стратегии определены как Default (По умолчанию) и OnPush (По запросу):

export enum ChangeDetectionStrategy {
  OnPush = 0,
  Default = 1
}

Angular использует эти стратегии, чтобы определить, следует ли проверять дочерний компонент при выполнении changeDetection родительского компонента. Стратегия, определенная для родителя, влияет на все дочерние компоненты, так как они проверяются вместе с проверкой хост-компонента. Определенную стратегию нельзя изменить во время выполнения.

Стратегия по умолчанию, внутренне называемая CheckAlways, подразумевает регулярное автоматическое обнаружение изменений компонента. Стратегия OnPush, внутренне называемая CheckOnce, подразумевает, что обнаружение изменений пропускается, если компонент помечен как "грязный". Angular реализует механизмы для автоматической пометки компонента как "грязного". При необходимости компонент можно пометить как "грязный" вручную с использованием метода markForCheck, доступного в ChangeDetectorRef.

Когда мы определяем стратегию с помощью декоратора @Component(), компилятор Angular записывает ее в определение компонента через функцию defineComponent. Например, для компонента, подобного следующему:

@Component({
  selector: 'a-op',
  template: `I am OnPush component`,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class AOpComponent {}

после сборки будет выглядеть так:

Когда Angular создает экземпляр компонента, он использует это определение для установки соответствующего флага на экземпляре LView, который представляет представление компонента:

Это означает, что все экземпляры LView, созданные для этого компонента, будут иметь установленный флаг либо CheckAlways, либо Dirty. Для стратегии OnPush флаг Dirty будет автоматически сброшен после первого прохода обнаружения изменений.

Флаги, установленные на LView, проверяются внутри функции refreshView, когда Angular определяет, должен ли компонент быть проверен:

function refreshComponent(hostLView, componentHostIdx) {
  // Only attached components that are CheckAlways or OnPush and dirty 
  // should be refreshed
  if (viewAttachedToChangeDetector(componentView)) {
    const tView = componentView[TVIEW];
    if (componentView[FLAGS] & (LViewFlags.CheckAlways | LViewFlags.Dirty)) {
      refreshView(tView, componentView, tView.template, componentView[CONTEXT]);
    } else if (componentView[TRANSPLANTED_VIEWS_TO_REFRESH] > 0) {
      // Only attached components that are CheckAlways 
      // or OnPush and dirty should be refreshed
      refreshContainsDirtyView(componentView);
    }
  }
}

Подробнее рассмотрим эти стратегии.

Default

Эта стратегия означает, что дочерний компонент всегда будет проверяться, проверяется родительский. Единственным исключением из этого правила является отключение проверки изменений у дочернего компонента, как в этом случае:

@Component({
  selector: 'a-op',
  template: `I am OnPush component`
})
export class AOpComponent {
  constructor(private cdRef: ChangeDetectorRef) {
    cdRef.detach();
    

Важно помнить, что если родительский компонент не проверяется, Angular не запустит changeDetection для дочернего компонента, даже если он использует Default. Angular запускает проверку для дочернего компонента в рамках проверки его родителя. Angular не делает "глубокую" проверку значений, проверяя все поступающие значения только по ссылке. Создадим простой пример из двух компонентов:

@Component({
  selector: 'a-op',
  template: `
    <button (click)="changeName()">Change name</button>
    <b-op [user]="user"></b-op>
  `,
})
export class AOpComponent {
  user = { name: 'A' };
 
  changeName() {
    this.user.name = 'B';
  }
}
 
@Component({
  selector: 'b-op',
  template: `<span>User name: {{user.name}}</span>`,
})
export class BOpComponent {
  @Input() user;
}

Angular запустит обработчик события при нажатии на кнопку. Клик меняет значение user.name. Вместе с этим будет перепроверен дочерний компонент <b-op>.

Cсылка на объект пользователя не изменилась, но он был изменен внутри. Angular это не поймёт. Значение в браузере не изменилось.

OnPush (CheckOnce)

Хотя Angular не заставляет нас использовать неизменяемые объекты, он даёт механизм объявления компонента с неизменяемыми входными данными, чтобы уменьшить количество проверок. Это стратегия OnPush и её часто используют для оптимизации. Под капотом она называется CheckOnce, поскольку предполагает, что проверка изменений пропускается для компонента, пока он не будет помечен как dirty. Компонент может быть помечен как dirtyлибо автоматически, либо вручную с использованием метода markForCheck.

Возьмем пример показанный выше и объявим стратегию OnPush для компонента B:

@Component({...})
export class AOpComponent {
  user = { name: 'A' };
 
  changeName() {
    this.user.name = 'B';
  }
}
 
@Component({
  selector: 'b-op',
  template: `
    <span>User name: {{user.name}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
  @Input() user;
}

При запуске приложения Angular больше не распознает изменения в user.name

Вы можете заметить, что компонент B всё ещё проверяется один раз во время первой загрузки - он рендерит начальное имя A. Однако при последующих запусках changeDetection он не проверяется, поэтому при нажатии на кнопку вы не видите, что имя меняется с A на B. Это происходит потому, что ссылка на объект пользователя, передаваемая в компонент B через @Input, не изменилась.

Прежде чем мы рассмотрим различные способы пометить компонент как dirty, ознакомимся со списком сценариев, которые Angular использует для тестирования поведения OnPush:

  1. Компоненты с флагом OnPush должны пропускаться, когда они не dirty
  2. Компоненты с флагом OnPush не должны проверяться, когда происходят события в родительском компоненте.
  3. Компоненты с флагом OnPush должны проверяться при инициализации.
  4. Метод doCheck должен вызываться даже в том случае, если компоненты с флагом OnPush не являютсяdirty.
  5. Компоненты с флагом OnPush должны проверяться при изменении @Input
  6. Компоненты с флагом OnPush должны проверяться при возникновении событий в компоненте (mouse click и прочие)
  7. Родительские компоненты с флагом OnPush должны проверяться при возникновении событий в дочерних компонентах.
  8. Родительские компоненты с флагом OnPush должны проверяться, когда директива дочернего компонента в шаблоне генерирует событие.


Этот набор сценариев гарантирует, что процесс пометки компонента как dirty происходит в следующих сценариях:

  • Изменение ссылки @Input
  • Получение события, вызванного самим компонентом

Рассмотрим их подробнее

Прослушка @Input

В большинстве случаев потребуется будет проверять дочерний компонент только при изменении входных данных. Это особенно верно для чисто представительных компонентов, данные для которых поступают исключительно через них.

Рассмотрим предыдущий пример:

@Component({...})
export class AOpComponent {
  user = { name: 'A' };
 
  changeName() {
    this.user.name = 'B';
  }
}
 
@Component({
  selector: 'b-op',
  template: `
    <span>User name: {{user.name}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
  @Input() user;
}

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

Чтобы Angular обнаружил разницу в привязках @Input, мы должны изменить ссылку на объект пользователя. Если мы создадим новый экземпляр взамен изменения существующего, все заработает:

@Component({...})
export class AOpComponent {
  user = { name: 'A' };
 
  changeName() {
    this.user = {
      ...this.user,
      name: 'B',
    }
  }
}

При помощи Object.freeze можно обеспечить неизменяемость объектов. Поскольку по умолчанию функция "морозит" только первый уровень вложенности, можно дополнительно обработать все остальные:

export function deepFreeze(object) {
  const propNames = Object.getOwnPropertyNames(object);
 
  for (const name of propNames) {
    const value = object[name];
    if (value && typeof value === 'object') {
      deepFreeze(value);
    }
  }
 
  return Object.freeze(object);
}

Теперь при попытке изменить свойство у объекта будет получена ошибка:

События пользовательского интерфейса

Все события браузера, когда они возникают в компоненте, помечают как dirty все родительские компоненты вплоть до корневого. Поскольку Angular не знает, был ли изменен родительский компонент или нет - происходит такая проверка.

Предположим, что у нас есть иерархия компонентов OnPush:

AppComponent
    HeaderComponent
    ContentComponent
        TodoListComponent
            TodoComponent

Если мы добавим eventListener в шаблон компонента TodoComponent:

@Component({
  selector: 'todo',
  template: `
    <button (click)="edit()">Edit todo</button>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class TodoComponent {
  edit() {}
}

, то перед выполнением обработчика события Angular пометит все родительские компоненты как dirty

Поэтому иерархия компонентов помеченных для проверки будет выглядеть следующим образом:

Root Component -> LViewFlags.Dirty
     |
    ...
     |
 ContentComponent -> LViewFlags.Dirty
     |
     |
 TodoListComponent  -> LViewFlags.Dirty
     |
     |
 TodoComponent (event triggered here) -> markViewDirty() -> LViewFlags.Dirty

Во время следующего цикла changeDetection Angular будет проверять всё дерево родительских компонентов TodoComponent. HeaderComponent будет пропущен, потому что он не является предком ContentComponent

AppComponent (checked)
    HeaderComponent
    ContentComponent  (checked)
        TodosComponent  (checked)
            TodoComponent (checked)

Программный перевод компонента в состояние dirty

Вернемся к примеру, где мы изменили ссылку на объект user при обновлении поля name. Это позволило Angular обнаружить изменение и автоматически пометить компонент как dirty. Предположим, что мы хотим обновить имя, но не хотим изменять ссылку. В этом случае мы можем пометить компонент как dirty вручную.

Для этого мы можем внедрить changeDetectorRef и использовать его метод markForCheck, чтобы указать Angular, что этот компонент нужно проверить:

@Component({...})
export class BOpComponent {
  @Input() user;
 
  constructor(private cd: ChangeDetectorRef) {}
 
  someMethodWhichDetectsAndUpdate() {
    this.cd.markForCheck();
  }
}

Для метода someMethodWhichDetectsAndUpdate мы можем использовать хук NgDoCheck. Решение о запуске хука ngDoCheck если компонент находится в режиме OnPush часто вызывает путаницу. Однако это сделано намеренно, и нет противоречия, если вы знаете, что он выполняется в рамках проверки родительского компонента. ngDoCheck вызывается только для самого верхнего дочернего компонента. Если у компонента есть дочерние элементы, и Angular не проверяет этот компонент, ngDoCheck не будет вызван для них.

Внедрим свою логику сравнения в хук NgDoCheck и пометим компонент как "грязный", когда мы обнаружим изменение:

@Component({...})
export class AOpComponent {...}
 
@Component({
  selector: 'b-op',
  template: `
    <span>User name: {{user.name}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
  @Input() user;
  previousUserName = '';
 
  constructor(private cd: ChangeDetectorRef) {}
 
  ngDoCheck() {
    if (this.user.name !== this.previousUserName) {
      this.cd.markForCheck();
      this.previousUserName = this.user.name;
    }
  }
}

Observables как @Input

Теперь немного усложним пример. Допустим, дочерний компонент B использует Observable, который асинхронно передает обновления. Это аналогично тому, что может быть в архитектуре на основе NgRx:

@Component({
  selector: 'a-op',
  template: `
    <button (click)="changeName()">Change name</button>
    <b-op [user$]="user$.asObservable()"></b-op>
  `,
})
export class AOpComponent {
  user$ = new BehaviorSubject({ name: 'A' });
 
  changeName() {
    const user = this.user$.getValue();
    this.user$.next(
      produce(user, (draft) => {
        draft.name = 'B';
      })
    );
  }
}

Родительский компонент передаёт дочернему Observable со значениями user в дочернем компоненте B. Предстоит подписаться на поток, проверить, обновлено ли значение, и, при необходимости, пометить компонент как dirty:

@Component({
  selector: 'b-op',
  template: `
    <span>User name: {{user.name}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
  @Input() user$;
  user = null;
 
  constructor(private cd: ChangeDetectorRef) {}
 
  ngOnChanges() {
    this.user$.subscribe((user) => {
      if (user !== this.user) {
        this.cd.markForCheck();
        this.user = user;
      }
    });
  }
}

Логика внутри ngOnChanges получилась практически идентичной тому, что делает async pipe

export class AsyncPipe {
  transform() {
    if (obj) {
      this._subscribe(obj);
    }
  }
  
  private _updateLatestValue(async, value) {
    if (async === this._obj) {
      this._latestValue = value;
      this._ref!.markForCheck();
    }
  }
}

Поэтому обычным подходом является делегирование подписки и логики сравнения async pipe. Единственным ограничением является то, что объекты должны быть неизменяемыми.

Вот реализация дочернего компонента B, который использует async pipe:

@Component({
  selector: 'b-op',
  template: `
    <span>User name: {{(user$ | async).name}}</span>
  `,
  changeDetection: ChangeDetectionStrategy.OnPush
})
export class BOpComponent {
  @Input() user$;
}

В Angular написано несколько тест-кейсов, которые проверяют async pipe и его взаимодействие с различными типами данных:

describe('Observable', () => {
	describe('transform', () => {
	  it('should return null when subscribing to an observable');
	  it('should return the latest available value');
	  it('should return same value when nothing has changed since the last call');
	  it('should dispose of the existing subscription when subscribing to a new observable');
	  it('should request a change detection check upon receiving a new value');
	  it('should return value for unchanged NaN');
	});
});
describe('Promise', () => {...});
describe('null', () => {...});
describe('undefined', () => {...});
describe('other types', () => {...});

Для случая описанного выше применим этот тест:

it('should request a change detection check upon receiving a new value', done => {
    pipe.transform(subscribable);
    emitter.emit(message);
 
    setTimeout(() => {
      expect(ref.markForCheck).toHaveBeenCalled();
      done();
    }, 10);
});

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