DevGang
Авторизоваться

Глубокое погружение в стратегию обнаружения изменений OnPush в Angular

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

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

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

Стратегия по умолчанию, называемая внутри 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

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

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

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

Angular не навязывает разработчикам какие-либо рабочие процессы для обнаружения изменения состояния компонента, поэтому поведение по умолчанию — всегда проверять компоненты. Пример принудительного рабочего процесса — неизменяемость объекта, которая передается через привязки @Input. Это то, что используется для стратегии OnPush, и мы рассмотрим ее далее.

Здесь у нас есть простая иерархия из двух компонентов:

@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 и обновляется экран:

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

Стратегия OnPush или CheckOnce

Хотя Angular не навязывает нам неизменяемость объекта, он дает нам механизм объявления компонента как имеющего неизменяемые входные данные, чтобы уменьшить количество проверок компонента. Этот механизм представляет собой стратегию обнаружения изменений OnPush и является очень распространенным методом оптимизации. Внутри эта стратегия называется CheckOnce, так как она подразумевает, что обнаружение изменений для компонента пропускается до тех пор, пока он не будет помечен как грязный, затем проверяется один раз, а затем снова пропускается. Компонент может быть помечен как грязный автоматически или вручную с помощью метода 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. Но он не проверяется во время последующих запусков обнаружения изменений, поэтому вы не видите, что имя изменилось с A на B при нажатии на кнопку . Это происходит потому, что ссылка на user объект, переданная компоненту B через @Input, не изменилась.

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

should skip OnPush components in update mode when they are not dirty
should not check OnPush components in update mode when parent events occur

should check OnPush components on initialization
should call doCheck even when OnPush components are not dirty
should check OnPush components in update mode when inputs change
should check OnPush components in update mode when component events occur
should check parent OnPush components in update mode when child events occur
should check parent OnPush components when child directive on a template emits event

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

  • ссылка @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 выполняет неглубокое сравнение входных параметров, а ссылка на user объект не изменилась. Прямое изменение объекта не приводит к созданию новой ссылки и не будет автоматически помечать компонент как грязный.

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

@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);
}

Так что, когда кто-то попытается изменить объект, он выдаст ошибку:

Вероятно, лучший подход - использовать специализированную библиотеку, такую как immer:

import { produce } from 'immer';
 
@Component({...})
export class AOpComponent {
  user = { name: 'A' };
 
  changeName() {
    this.user = produce(this.user, (draft) => {
      draft.name = 'B';
    });
  }
}

Это тоже будет хорошо работать.

Привязанные события UI

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

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

AppComponent
    HeaderComponent
    ContentComponent
        TodoListComponent
            TodoComponent

Если мы прикрепим прослушиватель событий внутри шаблона TodoComponent:

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

Угловые метки загрязняют все компоненты-предки перед запуском обработчика событий:

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

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

Во время следующего цикла обнаружения изменений Angular проверит все дерево компонентов-предков TodoComponent:

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

Обратите внимание, что HeaderComponent не проверяется, поскольку он не является предком TodoComponent.

Ручная маркировка компонентов как загрязненных

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

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

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

Что мы можем использовать для метода someMethodWhichDetectsAndUpdate? Хук NgDoCheck — очень хороший кандидат. Он выполняется до того, как Angular запустит обнаружение изменений для компонента, но во время проверки родительского компонента. Здесь мы поместим логику для сравнения значений и вручную пометим компонент как грязный при обнаружении изменения.

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

Не используйте ngDoCheck для регистрации проверки компонента. Вместо этого используйте функцию доступа внутри шаблона, например {{ logCheck() }}.

Итак, давайте введем нашу пользовательскую логику сравнения внутри крючка 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;
    }
  }
}

Помните, что markForCheck не запускает и не гарантирует запуск обнаружения изменений.

Наблюдаемые как @Inputs

Теперь давайте немного усложним наш пример. Давайте предположим, что наш дочерний компонент B принимает observable на основе RxJS, который асинхронно отправляет обновления. Это похоже на то, что вы могли бы иметь в архитектуре, основанной на 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';
      })
    );
  }
}

Итак, мы получаем этот поток user объектов в дочернем компоненте B. Нам нужно подписаться на поток, проверить, обновилось ли значение, и при необходимости пометить компонент как грязный:

@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 почти такая же, как и в асинхронном канале:

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

Вот почему общепринятый подход заключается в делегировании логики подписки и сравнения асинхронному каналу. Единственное ограничение заключается в том, что объекты должны быть неизменяемыми.

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

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

Существует множество тестовых случаев, которые проверяют асинхронный канал и его взаимодействие с различными типами:

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);
});

Канал подписывается на наблюдаемое внутри transform, и когда наблюдаемое выдает новое message, оно помечает компонент как грязный.

#JavaScript #Angular
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу