У фреймворка AngularJS есть несколько интересных решений в коде. Сегодня мы рассмотрим два из них – как работают области видимости и директивы.
Первое, чему обучают всех в AngularJS – директивы должны взаимодействовать с DOM. А больше всего новичка запутывает процесс взаимодействия между областями видимости, директивами и контроллерами. В этой статье мы рассмотрим подробности работы областей видимости и жизненный цикл Angular-приложения.
Если в следующей картинке вам что-то непонятно – эта статья для вас.
(В статье рассматривается 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
была ли область видимости удалена
Во второй части статьи мы рассмотрим директивы, изолированные области видимости, трансклюзии, привязанные функции, компиляторы, контроллеры директив и другое.
Автор: SLY_G