Мягкое введение в обнаружение изменений в Angular
В этой статье описывается вариант использования, который приводит к общему ExpressionChangedAfterItHasBeenCheckedError
и использует его для подробного изучения механизма обнаружения изменений и связанных с ним внутренних деталей реализации.
Современные веб-приложения являются интерактивными. Состояние приложения может измениться в любое время в результате нажатия кнопки или запроса, возвращаемого с сервера. И по мере изменения состояния код должен обнаруживать это и отражать изменения в пользовательском интерфейсе. Это основная задача механизма обнаружения изменений.
За последний год я написал много подробных статей о механике обнаружения изменений в Angular. Они предоставляют сложные объяснения и охватывают много внутренних деталей. Но они также требуют много времени, чтобы прочитать внимательно. Для тех из вас, у кого нет времени, но, тем не менее, любопытно: эта статья предоставляет «более легкое» объяснение механизма обнаружения изменений. Он предоставит вам общий обзор его составных частей и механизмов: внутренние структуры данных, используемые для представления компонента, роль привязок и операций, выполняемых как часть процесса. Я также коснусь зон и покажу, как именно эта функция позволяет автоматически обнаруживать изменения в Angular.
Когда дела идут плохо, знание внутренних функций обнаружения изменений поможет вам отладить ошибки, например ExpressionChangedAfterItHasBeenCheckedError
, более эффективно и избежать некоторых распространенных недоразумений. В этой статье я продемонстрирую несколько установок, которые вызывают ошибку, и использую их для объяснения внутренних возможностей обнаружения изменений.
Первая встреча
Давайте начнем с этого простого компонента. Он отображает время, когда в приложении происходит обнаружение изменений. Метка времени имеет точность в миллисекундах. Нажатие на кнопку запускает обнаружение изменений:
Вот реализация:
@Component({
selector: 'my-app',
template: `
<h3>
Change detection is triggered at:
<span [textContent]="time | date:'hh:mm:ss:SSS'"></span>
</h3>
<button (click)="0">Trigger Change Detection</button>
`
})
export class AppComponent {
get time() {
return Date.now();
}
}
Как видите, это довольно просто. Есть геттер с именем time
, который возвращает текущую метку времени. И я связываю его с элементом span
в HTML.
Angular не допускает пустых выражений, поэтому я назвал функцию обратного вызова click 0.
Вы можете поиграть с этим здесь. Когда Angular запускает обнаружение изменений, он принимает значение свойства time
, передает его через date pipe
и использует результат для обновления DOM. Все работает как положено. Однако, когда я проверяю консоль, я вижу ошибку ExpressionChangedAfterItHasBeenCheckedError
:
Это на самом деле довольно удивительно. Обычно эта ошибка встречается в гораздо более сложных реализациях. Так как же возможно, что мы получим это с такой простой функциональностью? Не волнуйтесь, мы собираемся исследовать это сейчас.
Начнем с сообщения об ошибке:
Выражение изменилось после того, как оно было проверено. Предыдущее значение: «textContent: 1542375826274». Текущее значение: «textContent: 1542375826275».
Это говорит нам о том, что значения, создаваемые выражениями для привязок textContent
, различны. Да, миллисекунды действительно разные. Таким образом, Angular оценил выражение time | date:’hh:mm:ss:SSS
дважды и сравнил результаты. Он обнаружил разницу, и именно это вызвало ошибку.
Но почему Angular выполняет это сравнение?
Или когда именно это делает?
Это были вопросы, которые пробудили во мне любопытство и в конечном итоге привели меня к внутренностям обнаружения изменений. Потому что, чтобы узнать ответы на эти вопросы, мне пришлось начать отладку. И я занимался отладкой и отладкой, и, думаю, это продолжалось около… нескольких месяцев 😅. Давайте начнем со второго вопроса о том, когда выдается ошибка. Но сначала я должен поделиться с вами некоторыми своими выводами, которые помогут нам понять поведение, которое мы только что наблюдали выше.
Представления компонентов и привязки
В Angular есть два основных строительных блока обнаружения изменений:
- компонентный вид
- связанные привязки
Каждый компонент в Angular имеет шаблон с элементами HTML. Когда Angular создает узлы DOM для визуализации содержимого шаблона на экране, ему необходимо место для хранения ссылок на эти узлы DOM. Для этой цели внутренне существует структура данных, известная как View. Он также используется для хранения ссылки на экземпляр компонента и предыдущих значений выражений привязки. Между компонентом и представлением существует отношение один к одному. Вот схема, которая демонстрирует это:
Поскольку компилятор анализирует шаблон, он определяет свойства элементов DOM, которые, возможно, потребуется обновить во время обнаружения изменений. Для каждого такого свойства компилятор создает привязку . Привязка определяет имя свойства для обновления и выражение, которое Angular использует для получения нового значения.
В нашем случае свойство time
используется в выражении для свойств textContent
. Итак, Angular создает привязку и связывает ее с элементом span
:
В реальной реализации привязка не является единым объектом со всей необходимой информацией. AviewDefinition
определяет фактические привязки для элементов шаблона и свойства для обновления. Выражение, используемое для привязки, помещается в функциюupdateRenderer
.
Проверка компонентов view
Как известно, в Angular обнаружение изменений выполняется для каждого компонента. Теперь, когда мы знаем, что компоненты внутренне представлены как представления, мы можем сказать, что обнаружение изменений выполняется для каждого представления.
Когда Angular проверяет представление, он просто перебирает все привязки, сгенерированные для представления компилятором. Он оценивает выражения и сравнивает их результат со значениями, хранящимися в массиве oldValues
в представлении. Вот откуда взялась грязная проверка названия. Если он обнаруживает разницу, он обновляет свойство DOM, относящееся к привязке. И также необходимо поместить новое значение в массив oldValues
в представлении. И это все. Теперь у вас есть обновленный интерфейс. Как только Angular завершит проверку текущего компонента, он повторяет те же самые шаги для дочерних компонентов.
В нашем приложении есть только одна привязка к свойству textContent этого span
элемента в компоненте App
. Таким образом, во время обнаружения изменений Angular считывает значение свойства компонента time
, применяет date pipe
и сравнивает его с предыдущим значением, сохраненным в представлении. Если он обнаруживает разницу, Angular обновляет свойство textContent
у span и массива oldValues
.
Но откуда берется ошибка?
После каждого цикла обнаружения изменений в режиме разработки Angular синхронно выполняет еще одну проверку, чтобы убедиться, что выражения выдают те же значения, что и во время предыдущего запуска обнаружения изменений. Эта проверка не является частью исходного цикла обнаружения изменений. Он запускается после завершения проверки для всего дерева компонентов и выполняет точно такие же шаги. Однако на этот раз, когда Angular обнаруживает разницу, он не обновляет DOM. Вместо этого он выбрасывает ExpressionChangedAfterItHasBeenCheckedError
.
Почему
Итак, теперь мы знаем, когда выдается ошибка. Но зачем Angular такая проверка? Хорошо, представьте, что некоторые свойства компонентов были обновлены во время прогона обнаружения изменений. В результате выражения создают новые значения, которые несовместимы с тем, что отображается в пользовательском интерфейсе. Итак, что делает Angular? Конечно, он может запустить еще один цикл обнаружения изменений для синхронизации состояния приложения с пользовательским интерфейсом. Но что, если во время этого процесса некоторые свойства будут обновлены снова? Видите образец? На самом деле Angular может оказаться в бесконечном цикле прогонов обнаружения изменений. И на самом деле, это часто случалось в AngularJS .
Чтобы избежать такой ситуации, Angular ввел так называемый однонаправленный поток данных. И эта проверка, которая выполняется после обнаружения изменений и возникающей ошибки ExpressionChangedAfterItHasBeenCheckedError
, является механизмом принудительного применения. После того как Angular обработает привязки для текущего компонента, вы больше не сможете обновлять свойства компонента, которые используются в выражениях для привязок.
Исправление ошибки
Чтобы предотвратить ошибку, мы должны убедиться, что значения, возвращаемые выражениями во время выполнения обнаружения изменений и следующей проверки, совпадают. В нашем случае мы можем сделать это, переместив часть оценки из геттера time
следующим образом:
export class AppComponent {
_time;
get time() { return this._time; }
constructor() {
this._time = Date.now();
}
}
Однако в этой реализации значение time
для получателя всегда будет одинаковым. Нам все еще нужно обновить значение. Ранее мы узнали, что проверка, которая выдает ошибку, выполняется синхронно сразу после цикла обнаружения изменений. Так что, если мы обновим ее асинхронно , мы избежим ошибки. Поэтому, чтобы обновлять значение каждую миллисекунду, мы можем использовать функцию setInterval
с задержкой в 1 миллисекунду следующим образом:
export class AppComponent {
_time;
get time() { return this._time; }
constructor() {
this._time = Date.now();
setInterval(() => {
this._time = Date.now();
}, 1);
}
}
Эта реализация решает нашу первоначальную проблему. Но, к сожалению, он вводит новый. Все временные события, например setInterval
, запускают обнаружение изменений триггера в Angular. Это означает, что с этой реализацией мы окажемся в бесконечном цикле циклов обнаружения изменений. Чтобы избежать этого, нам нужен способ запуска, setInterval
не триггеря обнаружение изменений. К счастью для нас, есть способ сделать это. Чтобы узнать, как это сделать, нам нужно понять, почему setInterval
триггеры изменяют обнаружение в Angular.
Автоматическое определение изменения с зонами
В отличие от React, обнаружение изменений в Angular может запускаться полностью автоматически в результате любого асинхронного события в браузере. Это стало возможным благодаря использованию библиотеки под названием zone.js
, которая вводит понятие зон. Вопреки распространенному мнению, зоны не являются частью механизма обнаружения изменений в Angular. На самом деле Angular может работать без них. Библиотека просто предоставляет способ перехватывать асинхронные события, например setInterval
, и уведомлять Angular о них. На основании этого уведомления Angular запускает обнаружение изменений.
Что интересно, вы можете иметь много разных зон на веб-странице. Один из них будет NgZone
. Это зона, в которой работает приложение Angular. Angular получает только уведомления о событиях, происходящих внутри этой зоны.
Но zone.js
также предоставляет API для запуска некоторого кода в зоне, отличной от Angular. Он не уведомляется об асинхронных событиях, происходящих в других зонах. А отсутствие уведомления означает отсутствие обнаружения изменений. Для этого вызывается метод runOutsideAngular
из сервиса NgZone
.
Вот реализация, которая внедряет NgZone
и запускает setInterval
за пределами зоны Angular:
export class AppComponent {
_time;
get time() {
return this._time;
}
constructor(zone: NgZone) {
this._time = Date.now();
zone.runOutsideAngular(() => {
setInterval(() => {
this._time = Date.now()
}, 1);
});
}
}
Сейчас мы постоянно обновляем время, но делаем это асинхронно и за пределами зоны Angular. Это гарантирует, что во время обнаружения изменений и последующей проверки time
возвращает одно и то же значение. И когда Angular считывает значение time
во время следующего цикла обнаружения изменений, значение будет обновлено, и изменения будут отображены на экране.
Использование NgZone для запуска некоторого кода за пределами Angular во избежание обнаружения изменений является распространенным методом оптимизации.
Отладка
Вы можете быть удивлены, есть ли способ увидеть этот view и привязки внутри Angular. На самом деле, есть. Внутри модуля @angular/core
есть функция с именем checkAndUpdateView
. Он работает над каждым представлением (компонентом) в дереве компонентов и выполняет проверку для каждого представления. Это функция, которую я всегда начинаю отлаживать, когда возникают проблемы с обнаружением изменений.
Попробуйте отладить это для себя. Перейдите к демонстрационному приложению stackblitz и откройте консоль. Найдите функцию и установите точку останова. Нажмите на кнопку, чтобы активировать обнаружение изменений. Проверьте переменную view
. Вот запись того, как я это делаю:
Первым view
будет представление хоста. Это своего рода корневой компонент, созданный Angular для размещения нашего компонента приложения. Нам нужно возобновить выполнение, чтобы перейти к его дочернему представлению, которое будет представлением, созданным для нашего AppComponent
. Изучите это. Свойство component
содержит ссылку на экземпляр App
компонента. Свойство nodes
содержит ссылку на DOM - узлы, созданные для элементов внутри шаблона App
компонента. В массиве oldValues
хранятся результаты выражений привязки.
Порядок операций
Мы только что узнали, что из-за однонаправленного ограничения потока данных нельзя изменить некоторые свойства компонента во время обнаружения изменений после того, как этот компонент был проверен. Чаще всего это обновление происходит через общую службу или синхронную трансляцию событий, когда Angular запускает обнаружение изменений для дочерних компонентов. Но также возможно напрямую внедрить родительский компонент в дочерний компонент и обновить родительское состояние в хуке жизненного цикла. Вот некоторый код, который демонстрирует это:
@Component({
selector: 'my-app',
template: `
<div [textContent]="text"></div>
<child-comp></child-comp>
`
})
export class AppComponent {
text = 'Original text in parent component';
}
@Component({
selector: 'child-comp',
template: `<span>I am child component</span>`
})
export class ChildComponent {
constructor(private parent: AppComponent) {}
ngAfterViewChecked() {
this.parent.text = 'Updated text in parent component';
}
}
Вы можете поиграть с этим здесь. По сути, мы определяем простую иерархию двух компонентов. Родительский компонент объявляет свойство text
, которое используется в привязке. Дочерний компонент внедряет родительский компонент в конструктор и обновляет его свойство в хуке жизненного цикла ngAfterViewChecked
. Можете ли вы угадать, что мы увидим в консоли? 😃
Правильно, знакомая ошибка ExpressionChangedAfterItWasChecked
. И все потому, что когда Angular вызывает хук жизненного цикла ngAfterViewChecked
для дочернего компонента, он уже проверял привязку для родительского компонента App
. Но мы обновляем свойство родителя text
, используемое в привязке, после проверки.
Но вот интересная часть. Что если я сейчас поменяю хук на ngOnInit
? Как вы думаете, мы все еще увидим ошибку?
export class ChildComponent {
constructor(private parent: AppComponent) {}
ngOnInit() {
this.parent.text = 'Updated text in parent component';
}
}
Ну, на этот раз его там нет. Проверьте демо. Фактически, мы можем поместить код в любой другой хук (кроме AfterViewInit
и AfterViewChecked
) и не увидим ошибку в консоли. Так что здесь происходит? Почему хук ngAfterViewChecked
особенный?
Чтобы понять это поведение, нам нужно знать, какие операции Angular выполняет при обнаружении изменений, и их порядок. И мы уже знаем, где мы можем их найти: функция checkAndUpdateView
, которую я показал вам ранее. Вот часть кода, которую вы можете найти в теле функции:
function checkAndUpdateView(view, ...) {
...
// update input bindings on child views (components) & directives,
// call NgOnInit, NgDoCheck and ngOnChanges hooks if needed
Services.updateDirectives(view, CheckType.CheckAndUpdate);
// DOM updates, perform rendering for the current view (component)
Services.updateRenderer(view, CheckType.CheckAndUpdate);
// run change detection on child views (components)
execComponentViewsAction(view, ViewAction.CheckAndUpdate);
// call AfterViewChecked and AfterViewInit hooks
callLifecycleHooksChildrenFirst(…, NodeFlags.AfterViewChecked…);
...
}
Как видите, Angular также запускает ловушки жизненного цикла как часть обнаружения изменений. Интересно то, что некоторые обработчики вызываются перед рендерингом, когда Angular обрабатывает привязки, а некоторые - после. Вот диаграмма, которая демонстрирует, что происходит, когда Angular запускает обнаружение изменений для родительского компонента:
Давайте пройдем это шаг за шагом. Во-первых, он обновляет привязки ввода для дочернего компонента. Затем он вызывает OnInit
, DoCheck
и OnChanges
хуки, опять же, на потомка компонента. Это имеет смысл, потому что он только что обновил привязки ввода и Angular должен уведомить дочерние компоненты, что привязки ввода были инициализированы. Затем Angular выполняет рендеринг для текущего компонента. И после этого он запускает обнаружение изменений для дочернего компонента. Это означает, что он будет в основном повторять эти операции в дочернем представлении. И наконец, он вызывает AfterViewChecked
и AfterViewInit
перехватывает дочерний компонент, чтобы сообщить ему, что он проверен.
Здесь мы можем заметить, что Angular вызывает хук жизненного цикла AfterViewChecked
для дочернего компонента после того, как он обработал привязки родительского компонента. С другой стороны, ловушка OnInit
вызывается перед обработкой привязок. Таким образом, даже если в значении есть изменение text
, оно будет таким же во время следующей проверки. И это объясняет, на первый взгляд, странное поведение отсутствия ошибки с хуком ngOnInit
. Тайна разгадана?