Эта статья позволит быстро освоить основы JavaScript-фреймворка AngularJS.

Другие статьи по AngularJS:

С чего начать

    1. Подключаем скрипты фреймворка на страничку (взять можно здесь), как минимум, Вам потребуются: angular.js, angular-route.js, angular-locale_ru-ru.js. В рассматриваемом здесь приложении мы используем анимацию, поэтому добавим еще и angular-animate.js.
    2. Создаем app.js — это будет «точка входа» в приложение angular. Внутри объявляем корневой модуль приложения и его зависимости, конфигурируем роуты; если нужно, определяем функцию, которая выполнится при старте приложения, и оборачиваем все это в вызов анонимной функции, «чтобы наружу не торчало ничего»:
      (function () {
      
          var configuration = function ($routeProvider) {
              $routeProvider
                  .when('/bar', {
                      templateUrl: 'dummy/bar/bar.html'
                  })
                  .when('/foo', {
                      templateUrl: 'dummy/foo/foo.html'
                  })
                  .when('/', {
                      templateUrl: 'dummy/app.html'
                  });
          };
      
          var run = function () {
          };
      
          angular.module('dummy', ['dummy.bar', 'dummy.foo', 'dummy.config',
              'dummy.message', 'dummy.draggable', 'dummy.messagePoster',
              'dummy.collapsable', 'dummy.textEditor', 'ngAnimate', 'ngRoute'])
              .config(['$routeProvider', configuration])
              .run([run]);
      
      })();
      

      Рассмотрим код подробнее.
      Конструктор модуля принимает два аргумента — имя модуля и массив имен модулей, от которых он зависит.
      Функция config выполняется в момент старта приложения, здесь, во-первых, задается роутинг, а во-вторых, конфигурируются сервисы, предоставляемые провайдерами… ок, мы еще вернемся к этому.
      При конфигурации маршрутов мы задаем url шаблона, который нужно использовать для рендеринга (также можно прямо на месте строкой передать inline-шаблон, только не в templateUrl, а template, но лучше так не делать вообще никогда). Часто здесь же параметром controller обычно задается  контроллер (сюрприз!), который будет использоваться для взаимодействия с шаблоном, но мы этого делать не будем, позже объясню почему.
      Функция run выполняется после загрузки всех модулей, нам нечего делать на этом этапе, поэтому мы ничего не делаем в ней (то есть ее можно было вообще не передавать в .run()). Но, к примеру, здесь можно получить текущего пользователя из сессии и сохранить его $rootScope.
      А что за $rootScope? Для начала стоить сказать, что такое $scope. Скоуп — это модель, «клей» между логикой и шаблонами. Скоупы компонуются и наследуются, образуя древовидную структуру, в контексте скоупов выполняются выражения в шаблонах, скоуп может следить ($watch) за выражениями (а также функциями, коллекциями, переменными) и бросать события. Все, что есть в скоупе — доступно в связанном с ним шаблоне и в его дочерних скоупах. На самом деле, я сейчас просто пересказал вступление к статье по скоупам из официальной документации, поэтому советую все же заглянуть в первоисточник.
      Теперь, когда мы разобрались (разобрались же?), что такое скоуп, можно пояснить, что такое $rootScope — это, как не трудно догадаться, родительский скоуп всех скоупов в приложении. Ключевые моменты:

      • Одна штука на приложение
      • Доступен из любого места приложения, достаточно внедрить $rootScope
      • Ловит все события
      • Соответственно, события, брошенные им, дойдут до любого дочернего скоупа
      • Превращается в помойку при злоупотреблении
    3. На страничке приложения в тэг html или body добавляем директиву ng-app="dummy" тем самым заявляя, что внутри этого тэга будет работать angular-приложение dummy, точка входа в которое описана в одноименном модуле.
    4. Где-то внутри body добавляем блок с директивой ng-view — здесь будут рендериться наши шаблоны в соотстветствии с текущим роутом.

Это основные приготовления для angular-приложения. Теперь, прежде чем начать штамповать контроллеры и сервисы, замолвим слово про структуру приложения.

Структура приложения

Следуя рекомендациям разработчиков Angular, структура должна выглядеть примерно так:

angular-app-structure
Корневая директория называется по имени самого приложения, в ней лежат: контроллер верхнего уровня (app-controller.js), базовые стили приложения (app.css), дефолтный шаблон (app.html) и главный модуль приложения (app.js).

Следующий уровень:

  • разделы приложения (foo и bar), каждый со своими контроллером, шаблоном и стилями;
  • компоненты: здесь храним директивы, сервисы и т.д., которые разделяются по функционалу/сущностям, к которым они относятся (например, все, что имеет дело с message, лежит в одной директории), каждая директива лежит отдельно от другой;
  • конфигурация — здесь, соответственно все constant и value сервисы (url-config.js) и модуль (config.js), в котором все это содержится.

Предлагаемая структура — иерархическая, например, если раздел foo у нас сильно усложнится и мы решим разбить его на части, то файлы дочерних разделов уже будут храниться в поддиректориях foofoo/customer«, «foo/supplier«).

Структура модулей соответствует файловой, то есть сервисы MessageManagerFactory и MessageService содержатся в модуле dummy.message, директива MessageList — в модуле dummy.message.messageList, директива MessagePoster — в модуле dummy.message.messagePoster (да, каждая директива хранится в собственном модуле).

Контроллер

Контроллер — это функция-конструктор, которая используется для «оживления» шаблона. Типичный сценарий использования: шаблон привязывают к контроллеру на этапе конфигурации, в контроллер передают $scope, набивают его под завязку всеми данными и логикой, которые используются на странице, а потом пытаются совладать с 600-строчным чудовищем. Так делать не надо.
Во-первых, $scope нужно использовать только тогда, когда без него не обойтись вообще никак, то есть вызов $watch, $emit, $broadcast и т.д. Все функции, объекты и поля лучше держать в экземпляре контроллера:

(function () {

    var AppController = function ($scope, messageManagerFactory) {
        var me = this;
        me.saidHi = false;
        $scope.$on('message.new.local', function (event, args) {
            me.messages.push(args);
        });

        me.messageManager = messageManagerFactory.make(this);
        me.messages = [];
    };

    AppController.prototype.getMessages = function () {
        return this.messages;
    };

    AppController.prototype.setMessages = function (messages) {
        this.messages = messages;
    };

    AppController.prototype.sayHi = function () {
        this.greeting = this.saidHi ? 'Watcha doin'?' : 'Sup, bro?';
        this.saidHi = true;
    };


    angular.module('dummy')
        .controller('AppController', ['$scope', 'MessageManagerFactory', AppController]);
})();

В данном случае, в конструкторе мы инициализируем поле saidHi, создаем экземпляр MessageManager, в котором инкапсулируем работу с сообщениями, а также создаем пустой массив для хранения сообщений. Функции, которые будем использовать в шаблоне, выносим в прототип.

Убирая код, работающий с сообщениями в MessageManager, мы не только уменьшаем количество кода в контроллере (что само по себе хорошо), но и избавляемся от зависимостей, которые этот код может за собой тащить. (Да, конкретно для данного случая — это overkill, MessageManager всего-то умеет дергать сервис для отправки/получения/удаления сообщений и выполняет простые callback’и, но идея, думаю, ясна).

$scope же здесь используется только для задания обработчика события message.new.local (мы ждем, что один из дочерних контроллеров или директив может создать новое сообщение).

В шаблоне все это используется так:

<!-- в index.html -->
<body ng-app="dummy" ng-controller="AppController as appCtrl">
<div ng-view></div>
</body>
<div class="row">
    <div class="col-md-6 col-md-offset-3">
        <div class="row">
            <div class="col-md-12">
                <div class="jumbotron">
                    <h1>This is the 'App' view</h1>

                    <p>{{appCtrl.greeting}}</p>

                    <p><a class="btn btn-success btn-lg" role="button"
                          ng-click="appCtrl.sayHi()">Say Hi</a></p>
                </div>
                <message-list messages-getter="appCtrl.getMessages"></message-list>
                <br/>
                <button class="btn btn-success"
                        ng-click="appCtrl.messageManager.saveMessages()">Save new</button>
                <button class="btn btn-primary"
                        ng-click="appCtrl.messageManager.getMessages()">Get all</button>
                <button class="btn btn-danger"
                        ng-click="appCtrl.messageManager.deleteMessages()">Delete all</button>
                <p ng-show="appCtrl.messageManager.makingRequest">
                    <br/>
                    <label>Request status:</label>
                    <span>&nbsp;{{appCtrl.messageManager.requestStatus}}</span>
                </p>
            </div>
        </div>
        <br/>
    </div>
</div>

AppController объявлен как «главный» контроллер приложения, поэтому он будет доступен везде как appCtrl. Это удобно, но так же, как и с $rootScope этим лучше не злоупотреблять. В данном приложении мы используем эту возможность для доступа к списку сообщений в дочерних контроллерах.

Использование в шаблоне членов экземпляра контроллера, а не скоупа, защищает нас от трудноуловимых багов — мы всегда точно знаем, что именно будет использоваться. Кроме того, двухсторонний биндинг примитивов в дочерних скоупах будет работать корректно  без дополнительных оберток.

Кстати, если бы мы задали контроллеры и для маршрутов на этапе конфигурации, и непосредственно в шаблонах, то функции контроллеров вызывались бы дважды, что, естественно, нежелательно.

В самом шаблоне, все достаточно просто: показываем «приветствие» из поля greeting, которое меняется по клику на кнопку «Say Hi». Далее у нас подключена директива messageList для вывода списка сообщений. Для запросов к серверу для получения, отправки и удаления сообщений служат три кнопки «Save new», «Get all», «Delete all», а статус выполнения запроса выводится чуть ниже.

Директивы

Директива — это независимый компонент, предназначенный для многократного использования. Стоит заострить на этом внимание, т.к. не нужно создавать директиву на каждую форму приложения — если форма специфична для конкретной страницы, и вы сомневаетесь, что она может понадобиться где-то еще — не надо делать из нее директиву. Если нужно разгрузить шаблон и контроллер — просто вынесите код в частичный шаблон и напишите для него отдельный контроллер.

Директивы можно разделить на два типа: изменяющие поведение элемента (draggable, collapsable и т.д.) и кастомные ui-компоненты (контрол для пагинации, календарь, модальное окно).

Директива должна делать одну и только одну вещь, не нужно собирать мега-комбайны, делающие все на свете.

Рассмотрим код простой директивы, оборачивающей элемент в «гармошку» — collapsable:

(function () {

    var directive = function () {
        return {
            restrict: 'AE',
            link: function (scope, element, attrs) {

                scope.toggle = function () {
                    scope.collapsed = !scope.collapsed;
                }
                scope.title = attrs.title;
            },
            transclude: true,
            templateUrl: 'dummy/components/collapsable/collapsable.html'
        }
    };

    angular.module('dummy.collapsable', [])
        .directive('collapsable', [directive]);

})()

Шаблон директивы:

<div>
    <div>
        <h2>{{title}}
            <button ng-click="toggle()" class="btn btn-default">{{collapsed? 'Expand' : 'Collapse'}}</button>
        </h2>
    </div>
    <div ng-transclude class="accordion-body" ng-show="!collapsed"></div>
</div>

Пример использования:

 <div collapsable title="This is collapsable" class="row">
            <message-list messages-getter="appCtrl.getMessages" class="col-md-12"></message-list>
        </div>

Для создания директивы нужно вызвать метод directive() у модуля, передав туда имя директивы и массив с внедряемыми зависимостями и, собственно, с конфигурационной функцией, определяющей поведение директивы. В данном случае зависимостей нет, поэтому в массиве всего один элемент — функция.

Для данной директивы нам потребовалось задать всего четыре параметра:

1) restrict — определяет, в качестве чего можно использовать директиву, возможные варианты: A — атрибут, E — элемент, и, с недавних пор, С — класс.

2) link — основная функция для «поведенческих» директив, здесь мы имеем доступ к $scope, jqueryLite-обертке элемента, к которому директива применена, и его атрибутам (в виде хэша). link выполняется после того, как манипуляция с DOM (для этой директивы) была завершена.

3) transclude — с этим все не так просто. Если установлен в true, изменяет скоуп директивы так, что его содержимое замещается содержимым родительского скоупа. Таким образом, если и в скоупе директивы, и в скоупе контроллера задать одноименные поля, в шаблоне директивы использоваться будет поле из скоупа контроллера. Этот параметр следует использовать только вместе c директивой ng-transclude, для подстановки фрагмента DOM извне в «тело» директивы — вставленный фрагмент шаблона не потеряет доступ к переменным в родительском $scope, если он их использовал, и нам не придется передавать их в качестве параметра (ну и если не выставить trancslude в true, у вас вылетит Error: orphane ng-transclude directive). В данном случае, список сообщений (message-list) будет «подставлен» внутрь блока div в шаблоне директивы с атрибутом ng-transclude (при этом все текущее содержимое div’a будет удалено).

4) templateUrl — url, по которому будет запрошен шаблон для директивы.

Теперь рассмотрим директиву с изолированным скоупом — messageList:

(function () {

    var directive = function () {
        return {
            restrict: 'E',
            scope: {
                getMessages: '&messagesGetter'
            },
            controller: 'MessageListController',
            templateUrl: 'dummy/components/message/message-list/message-list.html',
            replace: true
        };
    };

    var sortableFields = ['text', 'sender', 'date'];

    var MessageListController = function ($scope) {
        $scope.sortableFields = sortableFields;
        $scope.sortBy = 'date';

        $scope.messages = [];

        $scope.$watchCollection('getMessages()', function (newValue) {
            $scope.messages = newValue;
        });
    };

    angular.module('dummy.message.messageList', [])
        .directive('messageList', directive)
        .controller('MessageListController', ['$scope', MessageListController]);
})();
<div>
    <br/>
    <div class="row" ng-show="messages.length">
        <div class="col-md-4">
            <label>Sort by:</label>
        </div>
        <div class="col-md-7">
            <select class="form-control" ng-options="field as field for field in sortableFields" ng-model="sortBy"></select>
        </div>
    </div>

    <ol>
        <li ng-repeat="message in messages|orderBy:sortBy">
            {{message.toString() + ' || ' + (message.date | date:'short')}}
        </li>
    </ol>

    <span ng-hide="messages.length">No messages found.</span>


</div>

Пример использования:

<message-list messages-getter="appCtrl.getMessages()"></message-list>

Здесь в конфиге директивы три новых параметра:

  1. scope — если сюда передать хэш (даже пустой), у директивы будет изолированный скоуп (неизолированный скоуп прототипически наследует родительский). Заполняется этот хэш таким образом, что ключ — это название свойства у скоупа, а значение — имя атрибута, значение которого будет связано с этим свойством. На то, как именно будут «забиндены» значения атрибутов на свойства в скоупе, влияют префиксы перед именем атрибута:
    • @ — значение из атрибута будет передано в свойство скоупа директивы строкой.
    • & — свойство директивы будет функцией, интепретирующей выражение, содержащееся в атрибуте. Именно этот префикс используется здесь. Таким образом, вызов $scope.getMessages() вызовет appCtrl.getMessages() и вернет массив сообщений.
    • = — свойство скоупа директивы будет «забиндено» на объект, имя которого указано в атрибуте, т.е. будет создана двухсторонняя связь.
    • ? — флаг необязательности наличия значения, используется в дополнение к другим.
  2. controller — имя контроллера, который будет прикреплен к шаблону директивы.
  3. replace — если установлен в true, блок директивы будет заменен контентом шаблона директивы.

В контроллере директивы можно также увидеть использование функции $watchCollection — angular будет следить за состоянием массива или хэша, если его состояние изменится, будет вызвана callback функция с двумя параметрами — newValue и oldValue.

В шаблоне мы выводим список сообщений в messages с помощью ng-repeat, при этом выполняется сортировка по полю, имя которого хранится в sortBy, sortBy при этом задается из select’a.

Для каждого сообщения мы выводим дату, при этом форматирование выполняется с помощью фильтра angular’a date.

Сервисы

Видов сервисов в angular — пять штук:

  1. constant
    (function () {
    
        var UrlConfig = {
            messages: 'api/messages'
        };
    
        angular.module('dummy.config').constant('UrlConfig', UrlConfig);
    
    })();
    

    Неизменяемый объект или примитив. Может быть использован на этапе конфигурации.

  2. value — изменяемый объект или примитив. Не может быть использован на этапе конфигурации.
  3. factory — сервис, возвращающий какой-либо объект (неважно какой). Приведем фрагмент фабрики MessageManagerFactory:
    (function () {
    
        var MessageManagerFactory = function (messageService, $timeout) {
    
            var _filterNew = function (messages) {
                return   _.filter(messages, function (message) {
                    return !message.id;
                });
            };
    
            var _private = {
                hideStatusMessage: ...,
    
                defaultRequestCallback: ...
            };
    
            var MessagesManager = function (messagesContainer, messageService, $timeout) {
                this.messagesContainer = messagesContainer;
                this.messageService = messageService;
                this.timeout = $timeout;
                var me = this;
            }
    
             MessagesManager.prototype.saveMessages = function () {
                var me = this;
                me.makingRequest = true;
                me.requestStatus = 'waiting...';
                me.messageService
                    .post(_filterNew(me.messagesContainer.getMessages()))
                    .success(_private.defaultRequestCallback(me))
                    .error(_private.defaultRequestCallback(me))
                    .finally(_private.hideStatusMessage(me));
            };
    
            MessagesManager.prototype.deleteMessages = function () {
                ...
            };
    
          
            MessagesManager.prototype.getMessages = function () {
               ...
            };
    
            return {
                make: function (messagesContainer) {
                    return new MessagesManager(messagesContainer, messageService, $timeout);
                }
            }
        }
    
        angular.module('dummy.message').factory('MessageManagerFactory', ['MessageService', '$timeout', MessageManagerFactory])
    
    })();

    В фабрике определена функция-конструктор объектов MessageManager, заданы методы прототипа MessageManager, приватные функции вынесены в хэш _private, и объявлена «статическая» приватная функция _filterNew (статическая в том смысле, что не обращается к членам экземпляра MessageManager). На выходе фабрики — литерал объекта с единственным методом — make.

  4. service — отличается от фабрики тем, что при первом использовании функция будет использована как конструктор объекта. Приведем код нашего сервиса сообщений:
    (function () {
    
        var MessageService = function ($http, urlConfig) {
    
            var url = urlConfig.messages;
    
            var Message = function (text, sender, date, id) {
                this.text = text;
                this.sender = sender;
                this.date = date ? date : new Date();
                this.id = id ? id : null;
            };
    
            Message.prototype.toString = function () {
                return this.text + ' [' + this.sender + ']';
            };
    
            this.make = function (text, sender, date) {
                return new Message(text, sender, date);
            };
    
            this.convert = function (dbMessage) {
                return new Message(dbMessage.text, dbMessage.sender, dbMessage.date, dbMessage._id);
            };
    
            this.post = function (messages) {
                return $http.post(url, {
                    messages: messages
                });
            };
    
            this.delete = function () {
                return $http.delete(url);
            }
    
            this.get = function () {
                return $http.get(url);
            };
    
        };
    
        angular.module('dummy.message').service('MessageService', ['$http', 'UrlConfig', MessageService]);
    
    })();

    В сервисе определяется конструктор объектов Message (возможно, не самое лучшее решение, да); url для вызова api сервера берется из constant сервиса urlConfig; для экземпляра самого сервиса определены методы для вызова сервера (post, delete, get), метод для создания экземпляра Message и метод для конвертации из json, пришедшего с сервера, в объект Message (все сообщения с сервера конвертируются, таким образом, все наши сообщения в angular-приложении — экземпляры Message).

  5. provider — сервис, который можно использовать, если требуется некая конфигурация при старте приложения. В таком сервисе должна быть определена функция $get(), результат выполнения которой будет предоставлен клиенту сервиса. В сам провайдер можно добавить какие-либо функции для его конфигурации:
    angular.module('dummy')
    .provider('someProvider', function () {
      var _someConfigurableParam = 0;
    
      this.setParam = function(value) {
        _someConfigurableParam = value;
      };
    
      this.$get = [function () {
    
        return new SomeService(_someConfigurableParam);
      }];
    });

    Создав таким образом провайдер, мы можем использовать его в функции configure на старте приложения и задать значение параметра someConfigurableParam.

Подробнее о наследовании $scope

Скоупы в большинстве случаев (ng-include, ng-switch, ng-controller, ng-transcluded, директивы с параметром scope: true) наследуются прототипически (не уверен, что есть такое слово), из чего следует:

  • Изменение примитива в дочернем скоупе создает новое поле в нем, не оказывая эффекта на этот примитив в родительском скоупе.
  • Изменение поля в объекте в дочернем скоупе изменяет это поле в соответствующем объекте в родительском скоупе.
  • Изменение ссылки на объект в дочернем скоупе создает новую ссылку в нем, ссылка в родительском скоупе не изменяется.

Стоит отметить, что ng-repeat создает новый скоуп для каждой итерации, значения полей которого копируются из соответствующего элемента итерируемой коллекции родительского скоупа. Поэтому не стоит использовать массивы примитивов в ng-repeat, разве что только для read-only.

Скоупы не наследуются прототипически в следующих случаях:

  1. Создание директивы с параметром scope: false (по умолчанию). В данном случае, директива использует родительский скоуп как свой собственный, что естественно нежелательно использовать, т.к. может привести к трудноуловимым багам.
  2. Создание директивы с параметром scope: {}. Как уже говорилось выше, создает изолированный скоуп. Поля такого скоупа никак не связаны с родительскими, однако доступ к его полям (родителя) можно получить через атрибуты-параметры директивы (используя биндинги ‘@’, ‘&’ и ‘=’).

Доступ к родительскому, соседнему или дочерним скоупам можно получить с помощью следующих свойств: $scope: $parent, $$nextSibling, $$childTail и $$childHead.

Подробнее обо всей этой кухне можно почитать здесь.

О применении $scope.$apply()

Вызов этой функции заставляет angular обновить состояние шаблона в соответствии c актуальным состоянием шаблона, однако, если вам пришлось использовать это — скорее всего, вы сделали что-то неправильно: использовали setTimeout вместо $timeout, setInterval вместо $interval, element.on('click') вместо ng-click и т.д. В редких случаях использование $apply оправдано — необходимо изменить модель извне angular-приложения, но мне пока не приходилось с таким сталкиваться. В таких случаях использовать $apply нужно следующим образом (украдено отсюда):

$scope.safeApply = function(fn) {
  var phase = this.$root.$$phase;

  if (phase == '$apply' || phase == '$digest') {
    if (fn && (typeof(fn) === 'function')) {
      fn();
    }
  } else {
    this.$apply(fn);
  }
};

И далее вызываем safeApply() везде, где нам требуется вызов $apply().

Ссылка на репозиторий демо-приложения на гитхабе: ng-help.

Другие статьи по AngularJS: