В AngularJS есть два основополагающих понятия, которые многие недопонимают и путают, — $apply
и $digest
. Чтобы прояснить, как фрэймворк работает, каждый должен понимать, что из себя представляют $apply
и $digest
, и как они могут помочь AngularJS-разработчику в ежедневной разработке пользовательских интерфейсов.
Исследование $apply и $digest
Одной из самых полезных возможностей AngularJS из коробки является двустороннее связывание данных (two-way data binding), так сильно упрощающее жизнь front-end разработчиков. Двустороннее связывание данных подразумевает, что при изменении чего-либо в представлении (view), scope-модель автоматически изменится. Обратное также верно: когда меняется scope-модель, отображение показывает новое значение без нашего вмешательства. Возникает вопрос: Как же AngularJS это делает? Когда мы в представлении пишем выражение {{myModel}}
, под капотом у Angular устанавливается наблюдатель (watcher) за scope-моделью, который в свою очередь при изменении модели отражает новые данные в представлении. Этот watcher ничем не отличается от тех, которые создаются в контроллерах и директивах, когда развешиваем наблюдателей.
$scope.$watch('myModel', function(newValue, oldValue) {
// изменение DOM-дерева с новым значением myModel
});
$watch()
вторым аргументом ожидает функцию-слушателя (listener function) и вызывается при каждом изменении myModel
. Для нашего понимания всё просто: когда myModel
меняется, вызывается эта функция-слушатель, меняющая значение выражения в HTML. Но если смотреть глубже, то можно задуматься, в какой момент AngularJS вычисляет, поменяло myModel
свое значение или нет, и нужно ли вызывать соответствующий listener
? Может быть, он вызывает с определённым интервалом проверяющую функцию? И вот на этом вопросе давайте узнаем, что из себя представляет $digest
-цикл.
Итак, $digest
-цикл — это место, где вызываются watcher’ы. В момент вызова наблюдателя фрэймворк вычисляет scope-модель и, если она поменялась, вызывает своих слушателей. Теперь рассмотрим, когда и как $digest
-цикл запускается.
$digest
-цикл стартует при вызове $scope.$digest()
. Представим, что вы поменяли scope-модель в функции-обработчике клика по элементу в директиве ng-click
. В таком случае, умный фрэймворк автоматически запускает $digest
-цикл через вызов $digest()
. После старта $digest
-цикл стартует всех наблюдателей. Эти наблюдатели проверяют, изменилось ли текущее значение scope
-модели, за которыми они следят, по сравнению с последним вычисленным значением. Если да, то будет вызваны соответствующие слушатели. Результат следующий — если у вас выводятся выражения в вашей view’хе, они будут обновлены. В дополнение к ng-click
есть ещё несколько встроенных директив и сервисов (например, ng-model
, $timeout
и т. д.), позволяющих менять модели и запускающие автоматически $digest
-цикл.
Итак, кое в чём мы уже разобрались! Но дальше будет ещё интереснее! Есть небольшой нюанс — в случаях, описанных выше, нами любимый фрэймворк не вызывает $digest()
напрямую. Вместо этого, он вызывает $scope.$apply()
, вызывающий $rootScope.$digest()
. Как результат этого, digest-цикл запускается на $rootScope
, и затем обходит все дочерние scope
, вызывая по пути все их наблюдатели.
Теперь давайте вообразим, что у кнопки имеется директива ng-click
с переданным ей обработчиком клика. При возникновении события AngularJS обернёт вызов переданной функции в $scope.$apply()
. Поэтому после вполне обычного выполнения вашей функции-обработчика клика и изменения модели (если оно имело место быть в обработчике), вызовется $scope.$apply()
, и в $rootScope запустится $digest
-цикл, чтобы убедиться, что новые изменения отобразились в пользовательском интерфейсе.
На заметку: $scope.$apply()
автоматически вызывает $rootScope.$digest()
. Функцию $apply()
можно использовать в двух вариациях. В первой мы передаём функцию-аргумент, после выполнения которой запустится $digest
-цикл. Во второй вариации аргументы при вызове не передаются вообще, сразу запускается $digest
-цикл. Скоро мы узнаем, почему первый вариант предпочтительнее.
В каких случаях вызывать $apply() вручную?
Это вполне логичный вопрос после того, как мы узнали, что AngularJS во многих случаях оборачивает наш код в $apply()
своими силами. На самом деле, фрэймворк самостоятельно видит только изменения модели, совершённые внутри контекста AngularJS (т.е. код, меняющий модель, обёрнутый в $apply()
). Встроенные в фрэймворк директивы уже оснащены такой обёрткой, так что любое изменение модели через них сразу же отразится в UI. Но, если вы меняете какую-нибудь модель извне Angular-контекста, то вам нужно сообщить фрэймворку о своих изменениях путём вызова $apply()
вручную. Тем самым вы просите Angular отработать всех наблюдателей, что гарантирует правильное распространение измененной модели по системе.
К примеру, если вы вдруг для изменения scope используете всем известную функцию setTimeout()
, фрэймворк об этом ничего не узнает. В таком случае ручной вызов $apply()
, запускающий цикл digest, на вашей совести. Аналогично, если вы создали директиву, вешающую обработчик события на DOM-элемент и меняющую scope из своего обработчика, вам необходимо вызвать $apply(), информирующую систему о новых изменениях и применяющую их.
Давайте потренируемся. Предположим, вы создали страницу, которая должна показывать сообщение пользователю в GUI через заданный таймаут после загрузки. Ваша реализация может выглядеть примерно так:
Запустив пример, в консоли видно, как отложенная функция стартует через 2 секунды и меняет scope.message
. Но в пользовательском интерфейсе никаких изменений не отобразилось. Причина, как вы уже могли понять, в том, что мы забыли вызвать $apply()
. Поэтому нам нужно немного отредактировать функцию getMessage()
:
Запустив изменённый пример, можно наблюдать результат работы функции не только в консоли, но и в интерфейсе пользователя.
На заметку: Несмотря на использование в примере setTimeout()
, для этих целей в реальных задачах вам следует использовать встроенный в фрэймворк сервис $timeout
с автоматическим вызовом $apply()
(если это не отключено соответствующим параметром при вызове сервиса). С ним вам не придётся вручную вызывать метод $apply()
, как в примере выше.
И ещё, код выше можно было написать немного иначе, просто добавив в отложенной функции последней строкой вызов функции $apply()
без аргументов. Получилось бы вот так:
$scope.getMessage = function() {
setTimeout(function() {
$scope.message = 'Получено через 2 секунды';
console.log('Сообщение: ' + $scope.message);
$scope.$apply(); // вызываем $digest вручную
}, 2000);
};
Этот код использует версию $apply()
без аргументов и работает с первого взгляда абсолютно одинаково. Но глаза вас обманывают! Помните, что вам следует всегда использовать функцию $apply()
, принимающую первым аргументом функцию, т.к. в таком случае исполняемый код будет обёрнут в try...catch
-блок. Выброшенные в ходе выполнения исключения попадут в сервис $exceptionHandler
и $apply()
отработает даже в случае исключения, а, значит, модель и представление останутся консистентными. Если же использовать необёрнутый вариант, то рискуем после изменения модели где-нибудь получить исключение, которое не позволит запустить digest-цикл, что приведёт к неконсистентному состоянию.
Для большей ясности функцию $apply()
с аргументом можно показать следующим псевдокодом, взятым из документации:
function $apply(expr) {
try {
return $eval(expr);
} catch (e) {
$exceptionHandler(e);
} finally {
$root.$digest();
}
}
Здесь всё, о чём мы с вами говорили выше, я думаю, видно. Комментарии излишни.
Сколько раз запускается $digest()?
При старте $digest
-цикла выполняются все наблюдатели и вычисляется, изменились ли модели? Если да — запускается соответствующий listener
. Но может получиться и так, что функция-слушатель изменяет scope. Как AngularJS отнесётся к такому?
Очень просто! Дело в том, что $digest
-цикл вызывается несколько раз. После каждого цикла, он запускает ту же самую проверку ещё раз, чтобы проверить, были ли изменения в ходе выполнения listener
‘ов. Этот подход называется «dirty checking», определяющий возможные изменения в ходе выполнения функций-слушателей. Таким образом, цикл будет работать до тех пор, пока не закончатся изменения в scope или пока количество запусков цикла не достигнет 10. Поэтому хорошей практикой является сведение к минимуму изменений модели в listener
‘ах и стремление к идемпотентности (сколько бы раз listener
с текущим состоянием не запустился, конечное состояние будет одним и тем же).
На заметку: $digest
-цикл всегда запускается не менее двух раз, даже если ваши функции-слушатели ничего не поменяли в модели. Как говорилось выше, цикл запускается второй раз, чтобы убедиться, что модели в консистентном состоянии и изменения отсутствуют.
$scope.$digest() vs $scope.$apply(). Демонстрация.
В конце я решил сделать для вас небольшую демонстрацию, пример, явно демонстрирующий, что делает каждая из изучаемых нами функций. В $rootScope
я разместил объект со счётчиком, сделал две ветки контроллеров — ветка чёрного и ветка красного. У красного внутри есть ещё два вложенных друг в друга контроллера, код в них практически одинаковый. Все они знают про объект-счётчик за счёт прототипного наследования scope.
Я специально не использовал ng-click
, а сделал директиву testClick
, чтобы запустить код вне контекста AngularJS и не вызывать $apply()
автоматически после обработчика клика. Из примера видно, что $scope.$digest()
запускает наблюдатели в своём и всех дочерних scope, тогда как $scope.$apply()
запускает абсолютно все наблюдатели, начиная с $rootScope
. Обязательно понажимайте по кнопкам! Запомните разницу между двумя понятиями ещё и визуально.
Подытожим
Вот и всё! Ты дочитал эту статью, разобрал примеры, и, я искренне надеюсь, у тебя в голове прояснилось, что из себя представляют $apply
и $digest
. Всегда думайте о том, сможет ли AngularJS в данном месте кода обнаружить ваши изменения в $scope. Если нет, то вызывайте $apply()
вручную.
Спасибо тебе за то, что так усердно прочёл эту статью до последнего абзаца. Хочется, чтоб она оказалась тебе полезной, и моё время было потрачено не даром. Не забывай делиться ссылкой на статью со всеми неравнодушными к данной теме, пусть они тоже прочтут и выскажут своё мнение. И, конечно же, оставляй отзывы, сообщай о неточностях, опечатках и задавай вопросы о том, что осталось непонятным, в комментариях ниже.
До новых статей!