GrabDuck

Опыт разработки и проектирования на AngularJS

:

Всем привет!

В нашей компании, помимо разработки собственной СУБД, также занимаются заказными разработками по самым разным направлениям, от суровых java-энтерпрайз приложений до небольших мобильных приложений. Наши команды стараются использовать передовые технологии и фреймворки. И как раз я являюсь представителем одной из таких команд. Сегодня хочу поделится опытом разработки на AngularJS и парой мыслей о проектировании веб приложения с использованием этого фреймворка.


За время, которое я занимаюсь разработкой, мне приходилось сталкиваться с различными подходами к написанию приложений. Кто-то оборачивает простые вещи в очень странные обертки, так что автору кода приходится в дальнейшем прибегать к «комплементарному декаплингу эксплицируемых компонент» (с). Есть люди, которые, наоборот, нисколько не заморачиваются с архитектурными изысками и пишут код «здесь и сейчас», не заботясь о дальнейшем сопровождении и психическом здоровье коллег. Мне кажется, что код всё же должен быть в меру наполнен абстракциями, и при этом легко делиться на логические модули и легко читаться. Знакомство с AngularJS пару лет назад принесло понимание, как это может неплохо выглядеть в javascript'е.

Требования к приложению


Можно много спорить о достоинствах и недостатках AngularJS, оставим эти споры за рамками заметки, остановимся только на главном вопросе — можно ли использовать AngularJS в серьезном приложении?

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

С другой стороны находится производительность как краеугольный камень любого одностраничного веб-приложения. Во многом производительность AngularJS зависит от того, насколько глубоко вы в него погрузились и насколько правильно используете те или иные компоненты фреймворка. Это не скрывают и создатели AngularJS и честно рассказывают, как все устроено внутри и как можно избежать проблем со скоростью. Главная суть в том, что фреймворк — это инструмент, а каждому инструменту подходит какой-то определенный тип задач.

Итак, давайте посмотрим, какие задачи AngularJS не поможет нам решить:

  1. У вас приложение с большим количеством (тысячи) элементов, которые постоянно добавляются, удаляются и перемещаются на одной странице. Это может быть, например, игра или анимационное приложение.
  2. Ваше приложение оперирует на клиентской части большим количеством “сырых” данных — постоянно их преобразует, что вынуждает изменять модели и соответственно перерисовывать их отображение.
  3. У вас есть готовый код, написанный, например, при помощи JQuery и не отличающийся грамотностью, т.е. представляет собой попросту говоря “лапшу”. Приведение такого кода в соответствие с концепциями AngularJS может быть слишком трудоемко.

В других случаях я не вижу препятствий использовать этот фреймворк в своих проектах. Если у вас есть еще примеры неудачного использования AngularJS, приведите их в комментариях, я обязательно пополню этот список.

Проектирование веб-приложения.


Мы выбрали инструмент, определились, что он подходит нашей задаче, теперь давайте приступим к проектированию.

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

  • Модуль — контейнер, который хранит в себе компоненты, решающие одну или несколько задач.
  • Сервис — компонент, который хранит в себе переиспользуемый код или объект и позволяет выделять общую логику для работы других компонентов. Это могут быть операции над объектом, хранилища данных, кэш и пр.
  • Директива — компонент, который представляет собой переиспользуемый виджет или специфичный код для работы с DOM-деревом браузера и стилями.
  • Контроллер — компонент, содержащий специфичную логику (в т.ч. и UI логику) для работы конкретной страницы или ее части.

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

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

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

  1. Постоянно следите за кодом и не бойтесь его рефакторить. Мне приходится часто сталкиваться с мнением “работает — не трогай”, но такой подход не ведет к развитию проекта и со временем в модуле начинает накапливаться код, который становится неуправляемым. Примером может послужить один сложный контроллер главной страницы, который рано или поздно разбухнет до стадии “чтобы исправить баг мне нужно очень много часов”.
  2. Если у вас сложная директива, которая требует постоянного взаимодействия с бизнес-логикой (например, интерактивная карта с метками и геолокацией), создайте к ней свой сервис. Этот сервис будет работать как интерфейс, который можно передать в другие сервисы или контроллеры, что значительно упростит код и инкапсулирует логику взаимодействия.
    Пример
    angular.module('googleMap', [])
        // наш сервис (интерфейс)
        .factory('GoogleMapService', function () {
            var mapInstance;
            return {
                currentLocation: {
                    lat: "",
                    lng: ""
                },
                createMapInstance: function (mapNode, options) {
                    mapInstance = {
                        map: new google.maps.Map(mapNode, options.mapOptions),
                        geocoder: angular.isDefined(options.geocoder) ? new google.maps.Geocoder() : undefined
                    };
                    return mapInstance.map;
                },
                setCenter: function (lat, lng) {
                    mapInstance.map.setCenter(lat, lng);
                },
                setZoom: function (value) {
                    mapInstance.map.setZoom(value);
                },
                setCurrentLocation: function (lat, lng) {
                    this.currentLocation.lat = lat;
                    this.currentLocation.lng = lng;
                    // ... логика установки
                },
                getCurrentLocation: function () {
                    return this.currentLocation.lat != "" && this.currentLocation.lng != "" ? new google.maps.LatLng(this.currentLocation.lat, this.currentLocation.lng) : undefined;
                },
                geocodeLocation: function (lat, lng, callbackSuccess, callbackError) {
                    if (angular.isDefined(mapInstance.geocoder)) {
                        mapInstance.geocoder.geocode({location: new google.maps.LatLng(lat, lng)}, function (results, status) {
                            if (status == google.maps.GeocoderStatus.OK) {
                                callbackSuccess(results[0] ? results[0].formatted_address : '');
                            } else {
                                if (status === 'OVER_QUERY_LIMIT') {
                                    callbackError('Exceeded the map usage limits per second');
                                }
                            }
                        })
                    }
                },
                destroy: function () {
                   // ... уничтожаем карту
                }
                // другие методы
            }
        })
        // наша директива
        .directive('googleMap', ['GoogleMapService', function (GoogleMapService) {
            return function (scope, element, attrs) {
                //map options
                var mapOptions = {
                    zoom: angular.isDefined(GoogleMapService.getCurrentLocation()) ? 10 : 4,
                    center: angular.isDefined(GoogleMapService.getCurrentLocation()) ? GoogleMapService.getCurrentLocation() : new google.maps.LatLng(39.164141, -102.304687),
                    mapTypeId: google.maps.MapTypeId.ROADMAP
    
                };
    
                //Google API map object
                var map = GoogleMapService.createMapInstance(element[0], {
                    mapOptions: mapOptions,
                    autocomplete: attrs.autocomplete,
                    geocoder: attrs.geocoder
                });
    
                scope.$on('$destroy', function () {
                    GoogleMapService.destroy();
                });
    
                // остальная визуальная логика по карте
            }
        }]);
    
    // пример использования
    angular.module('someModule', [])
        .controller('$scope', 'GoogleMapService',
        function ($scope, GoogleMapService) {
            GoogleMapService.setCurrentLocation($scope.lat, $scope.lng);
            GoogleMapService.geocodeLocation($scope.lat, $scope.lng, function (result) {
                $scope.address = result;
            }, function (errorMessage) {
                $scope.errorMessage = errorMessage;
            });
            GoogleMapService.setZoom(10);
        });
    
    ...
    
    <div id="map" class="map" google-map autocomplete="location" geocoder></div>
    <input id="location"  type="text" placeholder="Please input place name or click on the map">
                  


  3. Активно используйте внутренний контроллер директивы для сокрытия логики работы вашего виджета. Вместо внешнего управления директивой, попробуйте убрать все “потроха” внутрь.
  4. Избегайте управления DOM-деревом напрямую в контроллере. Иногда это кажется проще, чем написать отдельную директиву, однако для получения структурированного кода необходимо следовать этой рекомендации. К тому же, сам AngularJS “из коробки” предоставляет большое количество готовых мини-директив, помогающих в задачах вроде “показать элемент, если… ” и “добавить класс, если… ”
  5. С умом используйте двойное связывание, а там где это возможно, используйте одностороннее связывание. Переизбыток отслеживаемых переменных может привести к падению скорости. Если у вас не предполагается изменение переменной, используйте одностороннее связывание
  6. Если по какой-либо причине прямая связь между контроллером, сервисом и директивой невозможна, используйте паттерн “Наблюдатель” (википедия и шина событий в AngularJS). Тем не менее, важно этим не злоупотреблять, потому что зарегистрировать и получить событие можно в каждом контексте ($scope) любого контроллера. Избыток таких конструкций усложняет понимание и отладку кода. Для глобальных системных событий вместо использования $broadcast в конкретном контексте лучше подписывать события на $rootScope и иницииривать на нем же через $rootScope.$emit. (спасибо serf за дополнение)
  7. Избегайте использования корневого контекста ($rootScope), старайтесь изолировать логику внутри одного контроллера или связки контроллер-сервис. Корневой контекст работает на всё приложение целиком, поэтому, например, добавленные туда функции отслеживания ($watch) будут срабатывать при каждом цикле ($digest), когда изменяется переменная в разных местах приложения.
  8. Разберитесь (если еще не разобрались) и используйте механизм promise (“обещаний”). Это простой и наглядный способ избавиться от спагетти-кода при вызове асинхронных функций. Также, одним из интересных применений “обещаний” является сообщение дочерним контроллерам о выполнении асинхронных запросов (через механизм наследования контекста).
    Пример
    <div ng-controller="Controller1">
        <div ng-controller="Controller2"></div>
    </div>
    
    ...
    
    function Controller1($scope, DataLoaderService) {
        ...
        // данные загружаются асинхронно
        $scope.dataLoaded = DataLoaderService.get(...);
        ...
    }
    ...
    function Controller2($scope, DataLoaderService) {
         ...
        $scope.dataLoaded.then(function(result) {
          // вызов функции произойдет когда завершится запрос из Controller1
            ...
        });
        ...
    }
                   


Немного полезных инструментов.


Помимо рекомендаций, хотелось бы поделиться полезными инструментами для построения эффективного процесса отладки и разработки. В своей работе мы активно используем:
  1. WebStorm IDE. Думаю, IDE не нуждается в представлении, простая и очень удобная в использовании среда от ребят из JetBrains. Поддержка AngularJS из коробки, включая автоподстановку.
  2. JSDoc 3. Документация на проекте является важным фактором его успешности, поскольку хорошо документированный код проще поддерживать. Уже давно действует стандарт написания документации к javascript — JSDoc — и его можно использовать для документирования кода вашего AngularJS приложения. Для генерации красивых html страничек можно использовать специальный генератор, он прост и требует только Node.js.
  3. Jasmine. Код, написанный на javascript, можно и нужно тестировать. Unit тестирование возможно и в AngularJS, при помощи фреймфорка Jasmine и “запускатора” Karma. Опять же вам потребуется Node.js, а настройка всего окружения не должна отнять много времени и подробно описана у каждого инструмента.
  4. Closure Compiler. Для ускорения загрузки код можно минифицировать с помощью javascript компилятора, а в некоторых местах и оптимизировать. Для AngularJS отлично подходит Closure Compiler (к слову сам AngularJS собирается им же). Отличный гайд как собрать ваше приложение лежит тут. От себя лишь добавлю, что ваше приложение, увы, не заработает в режиме ADVANCED_OPTIMIZATIONS.
  5. ng-annotate. Дополнительный модуль для Node.js, который позволяет автоматически добавлять в код зависимости для инъекций. В результате, можно избавиться от лишнего кода. (спасибо anotherpit за дополнение)
    Пример
    // Обычный код, так объявляется контроллер в AngularJS
    angular.module("MyMod").controller("MyCtrl", ["$scope", "$timeout", function($scope, $timeout) {}]);
    
    // С помощью ng-annotate можно писать так. После прогона через дополнение, код станет таким же, как и выше.
    angular.module("MyMod").controller("MyCtrl", function($scope, $timeout) {});
    


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

Спасибо за внимание!

P.S. Заметка об Angular 1, я специально не стал упоминать Angular 2, поскольку он еще находится в глубокой альфе, и пока применять его в реальных приложениях не рекомендуют сами разработчики.