Наткнулся на хорошую статью об устройстве Angular'овского механизма обнаружения изменений (change detection). Т.к. тема достаточна важна, но при этом недостаточно глубоко раскрыта даже на англоязычных ресурсах, а найти русскоязычные материалы на эту тему, вообще, не представляется возможным, решил перевести данную статью.
Исследование реализаций, лежащих в основе и примеров использования
Если, подобно мне, Вы желаете исчерпывающе понимать механизм обнаружения изменений в Angular, Вам необходимо исследовать исходный код, поскольку в интернете недостаточно информации об этом. Большинство статей упоминают о том, что каждый компонент имеет свой собственный детектор изменений, который отвечает за проверку компонентов, но, фактически этим и ограничиваются, сконцентрировавшись на иммутабельности и стратегии обнаружения изменений. Данная статья предоставляет Вам информацию, которой достаточно для понимания почему работают примеры применения с иммутабельностью и как стратегия обнаружения изменений влияет на проверки. Также, информация, полученная из данной статьи, позволит Вам самостоятельно использовать различные подходы при оптимизации производительности.
Статья разбита на две части. Первая часть в достаточной мере техническая и содержит много ссылок на исходный код. Она детально объясняет как устроен механизм обнаружения изменений «под капотом». Её содержимое основано на новейшей версии Angular — 4.0.1. Реализация механизма обнаружения изменений в этой версии отличается от предыдущей 2.4.1. Если интересно, Вы можете немного почитать как работает предыдущая реализация в этом ответе на stackoverflow.
Вторая часть показывает как использовать обнаружение изменений в приложениях и её содержимое применимо и для предыдущей 2.4.1 и для новейшей 4.0.1 версий Angular, т.к. публичный API не менялся.
В руководстве к Angular постоянно упоминается, что приложение — это, по сути, дерево компонентов. Однако, «под капотом» Angular использует низкоуровневую абстракцию, называемую view (здесь и далее view будет использоваться без перевода). Есть прямая взаимосвязь 1:1 между компонентом и view — один view связан с одним компонентом, и наоборот. View содержат ссылку на связанный компонент в свойстве component. Все операции, такие как проверка свойств и обновление DOM осуществляют view, таким образом технически более верно рассматривать Angular как дерево view, тогда как компонент можно описать как высокоуровневое представление view. Вот какое описание view можно найти в исходном коде:
View — это основной строительный блок для UI приложения. Это минимальная группа элементов, которые создаются и удаляются совместно.
Свойства элементов View могут изменяться, но структура (количество и порядок) этих элементов — нет. Изменение структуры элементов может осуществляться только посредством вставки, перемещения либо удаления вложенныхх View через ViewContainerRef. Любой View может содержать много view-контейнеров.
В данной статье я буду использовать понятия компонента и его view взаимозаменяемо.
Есть две важные вещи обо view в контексте обнаружения изменений. Первая — каждый view содержит ссылки на дочерние view в свойстве nodes и, таким образом, может производить действия на дочерних view. Вторая — каждый view имеет состояние (свойство state), которое играет большую роль, потому как основываясь именно на его значении Angular принимает решение запускать ли обнаружение изменений для этого view и всех его дочерних, либо пропустить. Есть четыре возможных состояния:
- FisrtCheck
- ChecksEnabled
- Errored
- Destroyed
Обнаружение изменений не осуществляется для view и его потомков если аттрибут состояния ChecksEnabled установлен в false либо если view находится в состоянии Errored или Destroyed. По умолчанию все view инициализируются с ChecksEnabled, если не применяется стратегия onPush ChangeDetectionStrategy.OnPush. К этому моменту мы вернемся несколько позже. Состояния могут комбинироваться, например, view может иметь установленные флаги FisrtCheck и ChecksEnabled одновременно.
Angular имеет ряд высокоуровневых концепций для манипуляций view. Я описывал некоторые из них в этой статье. Одна из таких абстрактных сущностей — ViewRef. Она изолирует view компонента и имеет очень удачно названный метод detectChanges. Когда возникает асинхронное событие, Angular запускает обнаружение изменений на ViewRef верхнего уровня, которые после запуска обнаружения изменений внутри себя, запускают обнаружение изменений у своих потомков.
Основная логика, которая отвечает за запуск обнаружения изменений для view, располагается в функции checkAndUpdateView. В основном её функционал производит действия над дочерними view компонента. Когда она вызвана для определенного view, она выполняет следующие операции в указанном порядке:
- обновляет входящие свойства у экземпляров дочерних компонентов
- обновляет состояние обнаружения изменений у дочерних view (как часть реализации стратегии обнаружения изменений)
- вызывает на дочерних компонентах хук OnChanges, в случае если связь изменилась
- вызывает хуки OnInit и ngDoCheck на дочерних компонентах
- вызывает хуки AfterContentInit, AfterContentChecked, AfterViewInit и AfterViewChecked на экземплярах дочерних компонентов
- вызывает хук OnDestroy, если дочерний/родительский компонент удален
- обновляет DOM для текущего view, если свойства экземпляра текущего view были изменены
- запускает обнаружение изменений для дочерних view
- отменяет проверки изменений для текущего view (если предустмотрено используемой стратегией)
- устанавливает значение false для флага состояния FirstCheck
Основываясь на вышеприведенном списке операций, хотелось бы обратить внимание на некоторые моменты.
Первое — это то, что хук onChanges вызывается у дочернего компонента перед тем как дочерний view проверен, он будет вызван даже в том случае, если обнаружение изменений на этом дочернем view будет пропущено. Это очень важно, и далее мы увидим каким образом мы сможем использовать это знание во второй части статьи.
Второе момент — обновление DOM для view — это часть механизма обнаружения изменений и происходит во время проверки. Это означает, что если компонент не проверен, его DOM не будет обновлен даже в случае, если свойства этого компонента используются в шаблоне отображения.
Еще одно интересное наблюдение — состояние view дочернего компонента может быть изменено во время обнаружения изменений. Ранее я упоминал о том, что все view компонентов инициализируются с установленным флагом ChecksEnabled по умолчанию, но для всех компонентов, которые используют стратегию OnPush, обнаружение изменений будет отключено после первой проверки (9-я операция в списке):
if (view.def.flags & ViewFlags.OnPush) {
view.state &= ~ViewState.<ChecksEnabled;
}
Это означает, что во время следующего запуска обнаружения изменений, проверка будет пропущена для view данного компонента и его потомков. Документация говорит нам о том, что компонент будет проверен только в случае изменений его связей (input-параметров) при использовании стратегии OnPush. Таким образом, для осуществления проверки следует установить флаг ChecksEnabled, что мы и видим в следующем коде (пункт 2):
if (compView.def.flags & ViewFlags.OnPush) {
compView.state |= ViewState.ChecksEnabled;
}
Состояние обновляется только в случае, если связи (входные параметры) родительского view были изменены и дочерний компонент был инициализирован со стратегией ChangeDetectionStrategy.OnPush.
Ну и в конечном счете, обнаружение изменений для текущего view отвечает за запуск обнаружения изменений у дочерних view (пункт 8). Здесь состояние view компонента проверяется и, если оно в статусе ChecksEnabled, то выполняется обнаружение изменений для этого view. Это отображает нижеприведенный код:
viewState = view.state;
...
case ViewAction.CheckAndUpdate:
if ((viewState & ViewState.ChecksEnabled) &&
(viewState & (ViewState.Errored | ViewState.Destroyed)) === 0) {
checkAndUpdateView(view);
}
}
Теперь мы знаем, что именно состояние view определяет будет ли выполняться обнаружение изменений на этом view и его потомках, или нет. Напрашивается вопрос — можем ли мы контролировать это состояние? Оказывается, да — мы можем, и именно об этом и будет вторая часть данной статьи.
Предположим, у нас есть следующее дерево компонетов:
Как мы выяснили ранее, каждый компонент связан с view. Каждый view инициализируется с установленным флагом ViewState.ChecksEnabled, чем определяется то, что при запуске Angular'ом обнаружения изменений, каждый компонент будет проверен.
Допустим, мы хотим отменить обнаружение изменений для компонента AComponent и его потомков. Это легко сделать — нам всего лишь следует установить значение false в аттрибут состояния ViewState.ChecksEnabled. Изменение состояния — это низкоуровневое действие, поэтому Angular предоставляет ряд публичных методов, который нам доступны у view. Каждый компонент может получить доступ к связанному с ним view посредством ChangeDetectorRef. Для Angular предоставляет следующий публичный интерфейс этого класса:
class ChangeDetectorRef {
markForCheck() : void
detach() : void
reattach() : void
detectChanges() : void
checkNoChanges() : void
}
Давайте посмотрим, какую же пользу мы можем извлечь из него.
detach
Первый метод, который позволяет нам манипулировать состоянием — это detach, который попросту отменяет проверки на текущем view:
detach(): void { this._view.state &= ~ViewState.ChecksEnabled; }
detach может быть применен в коде следующим образом:
export class AComponent {
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
Этим мы обеспечим пропуск левой ветки во главе с AComponent во время последующего обнаружения изменений (компоненты, обозначенные оранжевыми цветом не будут проверяться):
Тут следует обратить внимание на пару моментов — во-первых, даже если мы изменим состояние только лишь AComponent, все его потомки также не будут подвержены проверкам. Во-вторых, пока обнаружение изменений не будет выполнено для левой ветки, DOM также не будет обновляться. Небольшой пример для демонстрации:
@Component({
selector: 'a-comp',
template: `<span>See if I change: {{changed}}</span>`
})
export class AComponent {
constructor(public cd: ChangeDetectorRef) {
this.changed = 'false';
setTimeout(() => {
this.cd.detach();
this.changed = 'true';
}, 2000);
}
Во время первой проверки компонента содержимое span'а будет отображено как See if I change: false. По истечении двух секунд, когда свойство changed будет установлено в true, текст в span'е не изменится. Однако, если мы удалим строку this.cd.detach(), все начнет работать, как и ожидалось.
reattach
Как было сказано в первой части статьи, хук OnChanges все равно вызывается для AComponent, если входящее свойство aProp будет изменено в компоненте AppComponent. Это означает, что когда входящий параметр был изменен, мы можем активировать детектор текущего компонента для запуска обнаружения изменений и отключить его для последующего прохода. Именно это отображено в следующем коде:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.reattach();
setTimeout(() => {
this.cd.detach();
})
}
Так как, reattach просто устанавливает флаг ViewState.ChecksEnabled:
reattach(): void { this._view.state |= ViewState.ChecksEnabled; }
это полностью идентично тому, что происходит, когда мы устанавливаем значение OnPush для ChangeDetectionStrategy: отменяем проверки после первого запуска обнаружения изменений, включаем когда связанное свойство родительского компонента было изменено, и заново отключаем после запуска
Важно взять на заметку, что хук OnChanges срабатывает только на компоненте верхнего уровня отключенной ветки, а не на всех компонентах этой ветки.
markForCheck
Метод reattach позволяет проверить только текущий компонент, но если обнаружение изменений отключено у родительского компонента, он не произведет никакого эффекта. Это означает, что метод reattach полезен только для компонентов верхнего уровня отключенной ветки.
Нам необходим способ включения обнаружения для всех родительских компонентов, вплоть до корневого компонента. Метод, позволяющий сделать это называется markForCheck.
let currView: ViewData|null = view;
while (currView) {
if (currView.def.flags & ViewFlags.OnPush) {
currView.state |= ViewState.ChecksEnabled;
}
currView = currView.viewContainerParent || currView.parent;
}
Глядя на реализацию, мы видим, что он просто итеративно идет вверх по дереву и включает проверки обнаружения изменений для каждого родительского компонента, вплоть до корневого.
detectChanges
А можем ли мы одноразово запустить обнаружение изменений для текущего компонента и его потомков? Да, для этого нам достаточно воспользоваться методом detectChanges. Этот метод запускает обнаружение изменений для view текущего компонента, не взирая на его состояние, т.е. обнаружение может оставаться отключенным для текущего view и компонент не будет проверяться во время последующих регулярных запусков обнаружения. Вот пример кода:
export class AComponent {
@Input() inputAProp;
constructor(public cd: ChangeDetectorRef) {
this.cd.detach();
}
ngOnChanges(values) {
this.cd.detectChanges();
}
При изменении входящих свойств, DOM обновляется, даже если детектор изменений остается отключенным
checkNoChanges
Это последний, из представленных, метод, который гарантирует отсутствие изменений на текущем запуске детектора. По сути он выполняет 1, 7 и 8 пункты списка из первой части данной статьи и выбрасывает исключение, если связи (входящие свойства) были изменены, либо есть необходимость обновить DOM.
Автор: Bogdan1975