Разбор 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
:
- Компоненты с флагом
OnPush
должны пропускаться, когда они неdirty
- Компоненты с флагом
OnPush
не должны проверяться, когда происходят события в родительском компоненте. - Компоненты с флагом
OnPush
должны проверяться при инициализации. - Метод
doCheck
должен вызываться даже в том случае, если компоненты с флагомOnPush
не являютсяdirty
. - Компоненты с флагом
OnPush
должны проверяться при изменении@Input
- Компоненты с флагом
OnPush
должны проверяться при возникновении событий в компоненте (mouse click и прочие) - Родительские компоненты с флагом
OnPush
должны проверяться при возникновении событий в дочерних компонентах. - Родительские компоненты с флагом
OnPush
должны проверяться, когда директива дочернего компонента в шаблоне генерирует событие.
Этот набор сценариев гарантирует, что процесс пометки компонента как dirty
происходит в следующих сценариях:
Прослушка @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); });
Это вольный перевод материала