AngularJS советы от команды PayPal

Перевод/формат статьи “Sane, scalable Angular apps are tricky, but not impossible. Lessons learned from PayPal Checkout.”

(Очень радует, что на зло всем критикам появляется все больше и больше серьезных приложений на AngularJS)

Все нижеизложенные рекомендации касается только Angular 1.x, мы не говорим по второй версии фреймворка, для которой вероятно существуют свои рекомендации.

Не используйте ng-controller

ng-controller – это что-то типа тумблера для быстрого включения магии Ангуляра, вы добавляете атрибут на страницу – и вы на коне: все сразу стало динамическим, теперь можно использовать переменные из scope:

<div ng-controller="myController">
  <strong>{{foo}}</strong>
</div>
    • Этот паттерн не заставляет нас делать строгое связывание контроллера с шаблоном, которое свойственно для компонент-ориентированных приложений
    • При таком подходе вы можете использовать контроллер только в одном месте и только с определенными правилами того, что вы помещаете в $scope
  • Существует слишком много в корне отличных способов подключения контроллера к приложению, что само по себе создает сложности в последующем разборе кода
[javascript]
$routeProvider.
      when('/phones', {
        templateUrl: 'partials/phone-list.html',
        controller: 'PhoneListCtrl'
      });
[/javascript]

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

Так как же избежать такой неоднородности? Как выбрать один правильный вариант, и ответ..

Делайте все используя директивы

Серьезно, абсолютно все, включая страницы целиком:

[javascript]
myapp.directive('foo', function() {
    return {
        scope: {},
        template: myTemplate,
        controller: function($scope) {
            $scope.foo = 'bar';
        }
    };
});
[/javascript]

Теперь мы можем использовать компонент <foo></foo> для любых случаев:

  • это всегда гарантирует использование одного и того же контроллера и шаблона
  • вы можете передавать атрибуты для изменения поведения
  • вы можете определить коллбэк, который будет вызван, когда в вашем элементе произойдет какое-то событие
  • он имеет изолированный скоуп, то есть: вам не нужно переживать за лексическое окружение родителя

Но как тогда настроить роутер? Да вообще-то довольно просто:

[javascript]
$routeProvider.
      when('/phones', {
        template: '&amp;amp;amp;amp;lt;phone-list&amp;amp;amp;amp;gt;&amp;amp;amp;amp;lt;/phone-list&amp;amp;amp;amp;gt;',
      });
[/javascript]

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

Всегда используйте изолированный scope

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

scope: {}

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

Еще одна забавная вещь предложенная нам AngularJS:

scope: true

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

Связывайте свойство объекта, а не сам объект

AngularJs предоставляет двойное связывание через ng-model. Но при этом вот так лучше не делать:

<input type="text" ng-model="username" />

Видите проблему? Нет? А она есть! Суть в том, что вы пытаетесь связать свойство родительского скоупа, и при этом не обязательно понимаете какой из скоупов это будет.

Это уже где только не обсуждалось, отметим лишь только то, что, если вы так делаете, вы обрекаете себя на проблемы, которые будет трудно отладить в будущем. Например: для того же случая с ng-if, когда вы создаете новый скоуп и уже совсем не понятно будет ли ваш код продолжать дальше работать или нет.

Так как гарантировать, что ng-model всегда связана со скоупом вашей директивы? Просто запомнить:

  1. Создавайте объект-контейнер внутри вашей директивы
  2. Связывайте ваш инпут со свойством этого объекта, но не свойством объекта $scope

то есть:

<input type="text" ng-model="user.name" />

(прим.: либо всегда используйте синтаксис Controller As)

Ограничьте использование $rootScope

Вы когда нибудь слышали, что глобальные переменные – это зло? Так вот представьте, что $rootScope – это тоже глобальная переменная. По мере того как ваше приложение растет, становится все сложнее отслеживать помещенные туда свойства.

Не храните значение состояния там, лучше..

Храните состояние как можно ближе к элементу

Если у вас есть 3 компонента на странице и они используют одни и те же данные, то просто заверните их в еще один компонент и определите данные на полученном скоупе.

Это очень просто сделать с директивами, определив какие параметры вы хотите принять:

[javascript]
myapp.directive('foo', function() {
    return {
        scope: {
            bar: '='
        },
        template: myTemplate,
        controller: function($scope) {
            console.log($scope.bar);
        }
    };
});
[/javascript]

И передать их соответственно:

<foo bar="baz"></foo>

Забудьте о сервисах и провайдерах

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

Просто используйте фабрики(factories) для всего:

[javascript]
myapp.factory('foo', function() {
  return something;
});
[/javascript]

И теперь вы можете задать foo как зависимость, и фабрика вернет функцию, константу, объект или что-то еще.

Это все что вам нужно знать, когда вы начинаете работать с сервисами.

Забудьте о module.config

Существует не так много строк кода, которые действительно должны быть помещены в секцию module.config. Как правило все настройки могут легко уместиться в секции module.run, и даже, собрав все воедино в run, должно получиться не много кода, потому что за исключением настроек роутера вся инициализация UI может быть выполнена внутри главной(top) директиве страницы.

Разработчики AngularJS зачем-то сделали отличие в использовании ‘provider‘ по сравнению с остальными сервисами(service, factory, config, value). Только провайдеры могут быть доступны на этапе конфигурации, и  только там.

Мой совет: просто забудьте что существуют провайдеры и блоки конфигурации.

Исключением могут быть только встроенные сервисы AngularJS, которые можно настраивать только с помощью провайдеров, например $routeProvider.

Будьте осторожны с использованием событий на scope

Когда у вас есть 2 компонента, которые должны обмениваться данными, вы наверное думаете, что наиболее удобный способ реализовать – это будет генерировать события в одном элементе и считывать в другом (используя $scope.$emit/$broadcast и $scope.$on).

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

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

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

Почувствуйте удобство работы с $exceptionHandler

Это то место, где собираются все не отловленные ошибки вашего приложения. Angular оборачивает все котроллеры, сервисы, промисы и все остальное в блок try/catch и предоставляет глобальный обработчик исключительных ситуаций.

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

Логирование ошибок на сервере

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

Очень надеемся в скором времени выложить наш буфер в опен-сорс. Это крайне необходимый инструмент для анализа ошибок на стороне клиента в продакшене.

Используйте ui-router

У Angular есть свой роутер, но он предоставляет только базовую функциональность и работает исключительно с плоской структурой стейтов.

В свою очередь angular-ui-router позволяет делать неограниченное количество вложенных роутов. Наверняка вам уже приходилось делать что-то типа переключения между вложенными вью, так вот в ui-router это можно сделать из коробки. И так же важно, что все стейты хранятся в одном месте.

Например: в PayPal Checkout у нас такая структура:

  • Корневой стейт
    • Просмотр оплаты
      • Сайдбар
        • Добавление новой кредитной карточки

С использованием ui-router получится следующий url:

#/checkout/review/sidebar/addcard

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

Будьте осторожны с промисами и обработкой ошибок

Промисы используются практически везде в AngularJS, и вы сталкиваетесь с ними с первого же $http запроса. Однако, в некоторых случаях они ведут себя довольно коварно. В первую очередь это связано с тем, что нет единого потока выполнения и нет единого места обработки всех невыполненных промисов.  Рассмотрим такой пример:

[javascript]
$http.get('/foo/bar').then(function(result) {
  console.log('Success!', result);
});
[/javascript]

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

[javascript]
$http.get('/foo/bar').then(function(result) {
  console.log('Success!', result);
}).catch(function(err) {
  console.error(err.stack);
});
[/javascript]

Но на этом история не заканчивается. Обычно в случае появлении ошибки промис просто отменяется. Однако в AngularJS есть различие между отмененным промисом и ошибкой/исключением (thrown error). Если промис отменен, то есть возвращается $q.reject(err), я могу легко отловить ошибку и обработать так, как я захочу с помощью catch(). А вот в случае исключительной ситуации, когда что-то кидает ошибку с помощью throw, то Angular ведет себя довольно неожиданно: да, промис отменяется, как и в стандартной ситуации, и вызывается метод catch(), но даже если есть catch() метод, то ошибка все равно будет отправлена в $exceptionHandler.

То есть, если вы обрабатываете ошибки в обоих местах ($exceptionHandler и catch()), то получится так, что вы обработаете одну и ту же ошибку дважды.

Чтобы уменьшить количество таких случаев, мы вам рекомендуем использовать  “throw” в асинхронных операциях только для случаев, которые нельзя обработать. Для бизнес логики – только $q.reject().

Постарайтесь избегать ленивой загрузки (lazy loading)

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

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

Будьте осторожны с циклом дайджеста(digest cycle)

Я не буду вам рассказывать о производительности AngularJS, сказано уже достаточно. Но общее понимание принципов работы цикла дайджеста действительно важно, когда вы обновляете что-то в scope.

В общем случае: Ангуляр запускает дайджест, чтобы проверить что изменилось в объекте. А, так как объект не имеет своих наблюдателей (observers), то фремворку придется проходить весь объект.

И это может привести к коварной ошибке, когда в $scope у вас есть метод, который каждый раз возвращает новый объект. Например:

[javascript]
$scope.foo = function() {
  return {bar: 'baz'};
};
[/javascript]

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

Лучше сделать так:

[javascript]
var data = {bar: 'baz'};
$scope.foo = function() {
    return data;
};
[/javascript]

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

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