Pull to refresh

Подробно о внутренней кухне AngularJS

Reading time 9 min
Views 48K
Original author: Nicolas Bevacqua
У фреймворка AngularJS есть несколько интересных решений в коде. Сегодня мы рассмотрим два из них – как работают области видимости и директивы.

Первое, чему обучают всех в AngularJS – директивы должны взаимодействовать с DOM. А больше всего новичка запутывает процесс взаимодействия между областями видимости, директивами и контроллерами. В этой статье мы рассмотрим подробности работы областей видимости и жизненный цикл Angular-приложения.

Если в следующей картинке вам что-то непонятно – эта статья для вас.

image

(В статье рассматривается AngularJS 1.3.0)

AngularJS использует области видимости, чтобы абстрагировать общение директив и DOM. Области видимости есть и на уровне контроллеров. Области видимости – это простые объекты JavaScript (plain old JavaScript objects, POJO). Они добавляют кучку «внутренних» свойств, которые предваряются одним или двумя символами $. Те, у которых стоит префикс $$, не нужно использовать в коде слишком часто – обычно их использование говорит о непонимании работы приложения.

Так что это за области видимости такие?

На жаргоне AngularJS область видимости означает не то, что под этим подразумевается в коде JS. Обычно под областью видимости понимают блок кода, которая содержит контекст, разные переменные и т.п. К примеру:

function eat (thing) {
   console.log('Кушаю ' + thing);
}

function nuts (peanut) {
   var hazelnut = 'hazelnut'; // фундук

   function seeds () {
      var almond = 'almond'; // миндаль
      eat(hazelnut); // Отсюда я могу залезть в мешок!
   }

   // Миндаль здесь недоступен.
}


Однако это не те области видимости, про которые идёт речь в AngularJS.

Наследование областей видимости в AngularJS

Область видимости в AngularJS также является контекстом, но только в понимании AngularJS. В AngularJS область видимости связана с элементом и всеми его дочерними элементами, при этом элемент не обязательно прямо связан с областью видимости. Элементам назначаются области видимости одним из трёх способов.

Первый – если область видимости создаётся у элемента контроллером или директивой.

<nav ng-controller='menuCtrl'>


Второй – если у элемента нет области видимости, он наследует её от родителя

<nav ng-controller='menuCtrl'>
   <a ng-click='navigate()'>Жми меня!</a> <!-- also <nav>'s scope -->
</nav>


Третий – если элемент не является частью ng-app, то он не принадлежит ни к одной области видимости

<head>
   <h1>Приложение</h1>
</head>
<main ng-app='PonyDeli'>
   <nav ng-controller='menuCtrl'>
      <a ng-click='navigate()'>Жми меня!</a>
   </nav>
</main>


Чтобы понять, к какой области видимости принадлежит элемент, пройдитесь по дереву элементов изнутри наружу, используя три правила. Создаёт ли он новую область видимости? Тогда он связан именно с ней. Есть ли у него родитель? Проверяем родителя. Если он не входит в ng-app – тогда никакой области видимости.

Вызываем внутренние свойства областей видимости AngularJS

Пройдёмся по некоторым типичным свойствам. Для этого я открою Chrome и перейду к приложению, над которым работаю. Затем я открою Developer tools для просмотра свойств элемента. Знаете ли вы, что $0 даёт доступ к последнему выбранному элементу в панели «Elements»? $1 – предыдущий выбранный элемент, и т.д. $0 мы будем использовать чаще всего.

angular.element обёртывает каждый элемент DOM либо в jQuery, либо в jqLite. После этого у вас появляется доступ к функции scope(), возвращающей область видимости элемента. Скомбинируем это с $0 и получим часто используемую команду:

angular.element($0).scope()


Раз уж используется jQuery, то $($0).scope() тоже сработает. Теперь посмотрим, какие же свойства доступны в типичной области видимости – те, которые записываются начиная с $.

for(o in $($0).scope())o[0]=='$'&&console.log(o)

Изучаем внутренности области видимости AngularJS

Перечислю свойства, которые вывела эта команда, сгруппировав их по функциональности. Начнём с простых.

    $id
    идентификатор области видимости
    $root
    корневая область видимости
    $parent
    родительская область видимости, или null, если scope == scope.$root
    $$childHead
    область видимости первого дочернего узла, или null
    $$childTail
    область видимости последнего дочернего узла, или null
    $$prevSibling
    область видимости предыдущего узла этого же уровня, или null
    $$nextSibling
    область видимости следующего узла этого же уровня, или null


Навигация при помощи этих свойств – неудобная. Может иногда пригодиться $parent, но всегда есть более удобные способы обращаться к родительским элементам. Например, использование событий, которые мы рассмотрим в следующей части списка.

Событийная модель в области видимости AngularJS

Следующие свойства помогают определять события и подписываться на них.

    $$listeners
    обработчики событий, зарегистрированные в области видимости
    $on(evt, fn)
    присоединяет обработчик fn на событие evt
    $emit(evt, args)
    запускает событие evt, проходящее вверх по цепочке областей видимости, начиная с
текущего и заканчивая всеми родительскими $parent, включая $rootScope
    $broadcast(evt, args)
    запускает событие evt, проходящее вниз по цепочке областей видимости, начиная с
текущего и заканчивая всеми дочерними


При запуске, обработчики событий получают объект события и любые аргументы, переданные в $emit или $broadcast. Как же можно использовать события?

Директива может использовать их для сообщения о важном событии. В примере событие запускается по нажатию кнопки.

angular.module('PonyDeli').directive('food', function () {
   return {
      scope: { // К области видимости директив я ещё вернусь
         type: '=type'
      },
      template: '<button ng-click="eat()">Хочу поесть немножко {{type}}!</button>',
      link: function (scope, element, attrs) {
         scope.eat = function () {
            letThemHaveIt();
            scope.$emit('food.order, scope.type, element);
         };

         function letThemHaveIt () {
            // Возня с UI 
         }
      }
   };
});


Я задаю событиям пространства имён. Это предотвращает пересечение имён, и помогает понять, откуда приходят события или на какое событие вы подписываетесь. Предположим, вас интересует аналитика и вы хотите отследить все нажатия food через Mixpanel. Вместо замусоривания контроллера или директивы, вы можете сделать отдельную директиву для отслеживания нажатий, которая будет отдельной вещью в себе.

angular.module('PonyDeli').directive('foodTracker', function (mixpanelService) {
   return {
      link: function (scope, element, attrs) {
         scope.$on('food.order, function (e, type) {
            mixpanelService.track('food-eater', type);
         });
      }
   };
});


Реализация сервиса здесь не важна – она просто служила бы обёрткой клиентского API от Mixpanel. HTML выглядел бы так, как указано ниже, и я бы добавил ещё контроллер, содержащий все нужные типы еды. Для завершения примера я добавлю ng-repeat, чтобы можно было выводить списки еды, не копируя код. Просто выведем их циклом по foodTypes, который доступен в области видимости foodCtrl.

<ul ng-app='PonyDeli' ng-controller='foodCtrl' food-tracker>
   <li food type='type' ng-repeat='type in foodTypes'></li>
</ul>

angular.module('PonyDeli').controller('foodCtrl', function ($scope) {
   $scope.foodTypes = ['лучок', 'огурчик', 'орешек'];
});


Работающий пример смотрите на CodePen.

Но нужно ли вам событие, к которому сможет подключиться что угодно? Не будет ли достаточно сервиса? В этом случае можно сделать и так. Можно возразить, что события нужны, поскольку вы не знаете заранее, кто ещё будет подписываться на food.order, а значит, использование событий – более дальновидно с точки зрения развития приложения. Также можно сказать, что директива отслеживания еды не нужна, поскольку она не взаимодействует с DOM, а только ждёт события, поэтому её можно заменить на сервис.

И это верные замечания, в данном случае. Но когда и другим компонентам нужно будет общаться с food.order, станет ясна необходимость в событиях. В реальной жизни события наиболее полезны, когда вам надо соединить несколько областей видимости.

У элементов, находящихся на одном уровне, обычно общение друг с другом затруднено, и они обычно делают это через своего родителя. В результате это выливается в броадкастинг из $rootScope, который слушают все, кому это нужно:

<body ng-app='PonyDeli'>
   <div ng-controller='foodCtrl'>
      <ul food-tracker>
         <li food type='type' ng-repeat='type in foodTypes'></li>
      </ul>
      <button ng-click='deliver()'>Я хочу это съесть!</button>
   </div>
   <div ng-controller='deliveryCtrl'>
      <span ng-show='received'>
         Обезьяна уже выслана, скоро вы поедите.
      </span>
   </div>
</body>

angular.module('PonyDeli').controller('foodCtrl', function ($rootScope) {
   $scope.foodTypes = ['лучок', 'огурчик', 'орешек'];
   $scope.deliver = function (req) {
      $rootScope.$broadcast('delivery.request', req);
   };
});

angular.module('PonyDeli').controller('deliveryCtrl', function ($scope) {
   $scope.$on('delivery.request', function (e, req) {
      $scope.received = true; // обработка запроса
   });
});


Также можно посмотреть работу на CodePen.

Я бы сказал, что события нужно использовать, когда вы ожидаете изменения Вида в ответ на событие, а сервисы – когда Виды не меняются.

Если у вас две компоненты общаются через $rootScope, то лучше использовать $rootScope.$emit и $rootScope.$on вместо $broadcast. Тогда событие распространяется только среди $rootScope.$$listeners, и не будет терять время на проход всех дочерних узлов $rootScope, у которых нет обработчиков этого события. В примере сервис использует $rootScope для событий, не ограничиваясь определённой областью видимости. Он предоставляет метод subscribe для подписки на прослушивание событий.

angular.module('PonyDeli').factory("notificationService", function ($rootScope) {
   function notify (data) {
      $rootScope.$emit("notificationService.update", data);
   }

   function listen (fn) {
      $rootScope.$on("notificationService.update", function (e, data) {
         fn(data);
      });
   }

   // Всё, у чего есть смысл для создания событий в будущем
   function load () {
      setInterval(notify.bind(null, 'Что-то случилось!'), 1000);
   }

   return {
      subscribe: listen,
      load: load
   };
});


И это тоже есть на CodePen.

Digest

Привязка к данным у AngularJS работает посредством цикла, который отслеживает изменения и запускает события. В цикле $digest есть несколько методов. По-первых, это scope.$digest, рекурсивно переваривающий изменения в текущей области видимости и дочерних областях.

    $digest()
    исполняет цикл 
    $$phase
    текущая фаза цикла – один из вариантов [null, '$apply', '$digest']


Не стоит запускать digest, если вы уже находитесь в фазе digest – это приведёт к непредсказуемым последствиям. Что говорится по поводу digest в документации:

Запускает всех наблюдателей (watcher) в текущей области видимости и её дочерних областях. Поскольку слушатель (listener) наблюдателя может менять модель, $digest() вызывает наблюдателей до тех пор, пока их слушатели не перестанут выполняться. Это может привести к попаданию в бесконечный цикл. Поэтому функция выбросит ошибку 'Достигнуто максимальное количество итераций', если их количество превысит 10.


Обычно $digest() не вызывается напрямую из контроллеров или директив. Нужно вызывать $apply() (обычно это делают изнутри директив), который сам уже вызовет $digest().

Значит, $digest обрабатывает всех наблюдателей, и затем всех тех наблюдателей, которые вызываются предыдущими наблюдателями, до тех пор, пока они не перестанут выполняться. Остаётся два вопроса:

— кто такие наблюдатели?
— что вызывает $digest?

Возможно, вы уже знаете, что такое «наблюдатель» и использовали scope.$watch, а может даже и scope.$watchCollection. Свойство $$watchers содержит всех наблюдателей из области видимости.

    $watch(watchExp, listener, objectEquality)
    добавляет слушателя в область видимости
    $watchCollection
    наблюдает за элементами массива или свойствами объекта
    $$watchers
    содержит всех наблюдателей из области видимости


Наблюдатели – самый важный аспект AngularJS, но их вызов нужно инициировать, чтобы привязка данных отработала правильно. Пример:

<body ng-app='PonyDeli'>
   <ul ng-controller='foodCtrl'>
      <li ng-bind='prop'></li>
      <li ng-bind='dependency'></li>
   </ul>
</body>

angular.module('PonyDeli').controller('foodCtrl', function ($scope) {
   $scope.prop = 'начальное значение';
   $scope.dependency = 'пока ничего!';

   $scope.$watch('prop', function (value) {
      $scope.dependency = 'prop содержит "' + value + '"! ну ничего ж себе';
   });

   setTimeout(function () {
      $scope.prop = 'другое значение';
   }, 1000);
});


Значит, у нас есть 'начальное значение', и мы ожидаем, что вторая строка HTML поменяется на 'prop содержит «другое значение»! ну ничего ж себе', не так ли? И можно было бы ожидать, что первая строка поменяется на 'другое значение'. Почему она не меняется?

Многое из того, что вы создаёте в HTML, в результате создаёт наблюдателя. В нашем случае, каждая директива ng-bind создаёт наблюдателя для свойства. Она обновляет в HTML , когда prop и dependency меняются. Поэтому, в нашем коде есть три наблюдателя – по одному на каждый ng-bind, и один для контроллера. Откуда AngularJS узнает, что свойство обновилось после таймаута? Можно напомнить ему об этом, добавив вызов digest на обратный вызов таймера:

setTimeout(function () {
   $scope.prop = 'другое значение';
   $scope.$digest();
}, 1000);


Я сохранил два примера на CodePen – один без $digest, а второй – с ним. Но более правильный способ – использовать сервис $timeout вместо setTimeout. Он даёт возможность обработки ошибок и выполняет $apply().

$timeout(function () {
   $scope.prop = 'другое значение';
}, 1000);


    $apply(expr)
    разбирает и вычисляет выражение, и выполняет цикл $digest по $rootScope


Теперь по поводу того, кто вызывает $digest. Эти функции вызываются самим AngularJS в стратегических местах кода. Их можно вызвать напрямую, или через вызов $apply(). Большинство директив фреймворка вызывают эти функции. Они вызывают наблюдателей, а наблюдатели обновляют интефейс.

Посмотрим на список свойств, связанных с циклом $digest, которые можно обнаружить в области видимости.

    $eval(expression, locals)
    разбор и немедленное выполнение выражения
    $evalAsync(expression)
    разбор и отложенное выполнение выражения
    $$asyncQueue
    асинхронная очередь задач, обрабатывается на каждом цикле digest
    $$postDigest(fn)
    выполняет fn после следующего цикла digest 
    $$postDigestQueue
    зарегистрированные при помощи $$postDigest(fn) методы


Вот ещё несколько свойств области видимости, имеющие дело с её жизненным циклом и обычно используемые для внутренних целей. Но в некоторых случаях может потребоваться создание новых областей видимости через $new.

    $$isolateBindings
    изоляция привязок области видимости (к примеру, { options: '@megaOptions' } 
    $new(isolate)
    создаёт дочернюю область видимости или изолированную область, которая не будет наследником родителя
    $destroy
    удаляет область видимости из цепочки областей. её дочерние области не будут получать информацию о событиях и их наблюдатели не будут выполняться
    $$destroyed
    была ли область видимости удалена


Во второй части статьи мы рассмотрим директивы, изолированные области видимости, трансклюзии, привязанные функции, компиляторы, контроллеры директив и другое.
Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
+28
Comments 4
Comments Comments 4

Articles