Что такое $apply и $digest в AngularJS?

В 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() вручную.

Спасибо тебе за то, что так усердно прочёл эту статью до последнего абзаца. Хочется, чтоб она оказалась тебе полезной, и моё время было потрачено не даром. Не забывай делиться ссылкой на статью со всеми неравнодушными к данной теме, пусть они тоже прочтут и выскажут своё мнение. И, конечно же, оставляй отзывы, сообщай о неточностях, опечатках и задавай вопросы о том, что осталось непонятным, в комментариях ниже.

До новых статей!

  • Viacheslav Babai

    Премного благодарен! Легко читается и усваивается.

    • Вячеслав, благодарю за отзыв!

    • @viacheslavbabai:disqus благодарю за отзыв! :)

  • Статья класс! Последовательно. Примеры тоже норм.
    Запили уже wrapper для disqus-a какой. А то тянется на всю ширину страницы)
    Peace!

    • Спасибо за оценку моих трудов! :)
      Disqus подпилил, всё руки не доходили)

  • Игорь Лапицкий

    Спасибо за статью! Всё очень понятно написано.
    Только у меня остался один вопрос, стоит ли вообще использовать $scope.$digest() в реальном приложении? Если да, то в каких случаях?
    Или в реальном приложении лучше всегда использовать $scope.$apply? А $scope.$digest() например в юнит тестах сгодится?

    • Привет!
      Использование $scope.$digest() распространяет изменение значения только дочерним $scope’ам. Но минус такого способа, например, в том, что в случае ошибки в предшествующей вызову $scope.$digest() строке, $digest вызван так и не будет (если не обернуть в try/catch самостоятельно). А это значит, что дочерние $scope’ы о новых данных так и не узнают.

      В случае использования $scope.$apply() мы сообщаем ВСЕМ $scope’ам приложения об изменении, но проблема с возможным исключением перед вызовом $apply(), как и в $digest(), остаётся.

      Другое дело, $scope.$apply( ( ) => *someCode*) (именно с передачей функции в $apply). В этом варианте запустятся все вотчеры, начиная с $rootScope, а в случае ошибки в вызывающем изменения коде *someCode*, изменения в других $scope’ах все равно применятся, а ошибка прилетит и без нашего лишнего кода в единую точку обработки ошибок — $exceptionHandler. Такой способ уменьшает количество возможных проблем с неконсистентностью данных в разных $scope’ах.

      Конечно, вызов полного $digest-цикла в приложениях с очень большим количеством $watcher’ов — операция ресурсоёмкая и использовать её нужно только при необходимости. Поэтому однозначного ответа на такой вопрос дать, на мой взгляд, нельзя. Всё зависит от приложения.
      А в unit-ах обычно можно и $digest’ом обойтись.

      • Игорь Лапицкий

        понял, спасибо за ответ!

  • Артем Чунихин

    Спасибо большое за доходчивую статью и хорошие советы!

  • Vladimir Gachkovsky

    Благодарю за статью. Очень познавательно :)

    • Владимир, благодарю за положительный отзыв! Приятно осознавать полезность своих трудов :)

  • Игорь Перекрестов

    Что сказать, талант!
    Пиши больше )