Tuesday, 1 July 2014

AngularJS Best Practices

No Big, Giant, Fat Controllers


  • Wrap business logic into services
  • Controllers should only be used to set up scope and handle view interactions
  • No DOM Manipulation inside Controllers
  • Use service and directives instead

This controller is trying to take over the world

 
angular.module('awesome-app')

  .controller('itemController', ['$scope', '$http',
    function ($scope, $http) {
      function transform (items) { /* ... */ }
      $scope.getItemList = function () {
        //Retrieve list of from remote API server
        $http.get('/api/item').then(
          function success (success) {
            $scope.items = transformItems(res.items);
          },
          function error (error) {
            alert('No item for you!');
          });
      };
......better
angular.module('awsome-app')
  .service('itemRetriever', [$q, $http, function ($q) {
    function transform () { ... }
    this.get = function () {
      var deferred = $q.defer();
      $http.get('/api/items').then(function success (res) {
        ...
      });
      return deferred.promise;
    };
  });
  .controller('itemController', ['$scope', itemRetriever
  function ($scope, itemRetriever) {
    itemRetriever.get().then(function (transformedItems) {
      $scope.items = transformedItems;
    });
    $scope.addItem = function (item) {...};
    /* ... */

Group your code into bundles (modules)
* Why? Separating functionalities into modules makes it easier to isolate and troubleshoot errors during testing
* Keep modularization horizontal instead of vertical

Vertical Modularization
angular.module('app', []);
angular.module('services', []);
angular.module('filters', []);
angular.module('controllers', ['services', 'filters']);
......better
angular.module('app', []);
angular.module('company-api', []);
angular.module('products', ['company-api']);
angular.module('personnels', ['company-api']);
...

All External References Should Come from Dependency Injections

* Put or wrap global variable and constants into angular.value() or angular.constant()
* Inject them as needed
Bad for tests
  .service('myService', function () {
      ...
      ThirdPartyLib.externalMethod(...);
      ...
    });
......better
   angular.module('app')
     .factory('ThirdPartyLib', function ($window) {
       return $window.ThirdPartyLib;
     });
  .service('myService', ['ThirdPartyLib',
    function (ThirdPartyLib) {
      ...
    }]);

Small Controllers, Small Services

* Break controllers into smaller sub-controllers if it gets too large
* Separation of Concerns & Single Responsibility
* Eliminate the word "and" in your service's description
* Services should be loosely coupled
Too tighly coupled
angular.module('app').service('productService',
  ['$http', 'cartService', 'statsEngine'
    function ($http, CartService, statsEngine) {
      this.getProduct = function () {...}
      this.updateProduct = function (id, product) {...}
      this.getProductStatistics = function (id) {...}
      this.addProductToCart = function (id) {...}
      ...
  }]);
Would be combersome to mock all the dependencies in tests
......A Better Approach
  angular.module('app')
   .service('productLoader',
     ['$http', function ($http) {
       this.getProduct = function () {...}
       ...
    }]);
    .service('productUpdater',
      ['$http', function ($http) {
        this.purchaseProduct = function () {...}
        ...
     }]);
    .service('productStats',
      ['$http', function ($statEngine) {
        this.getProductStats = function () {...}
        ...
      }]);

Use Routes + Resolve Pattern

* Available in $routeProvider or AngularUI Router
* Define/divide resource loading by routes. Expose state on routes
* Good for E2E testing
Route Definition
angular.module("app")
  .config(["$routeProvider",
    function routeConfig($routeProvider) {
      $routeProvider.
      when("/", {
          controller: "itemController",
          templateUrl: "view/items.html",
          resolve: {
            items: function ($itemLoader, $q) {
              var deferred = $q.defer();
              itemLoader.loadItems().then(
                 function (items) {
                  deferred.resolve(helper);
              });
              return deferred.promise;
            }
          }
      });
  }]);
angular.module("app")
  .controller('itemController', ['items',
    function (items) {
      $scope.items = items;
  }]);
"items" will be available when controller is initialized

Tips


Use $window, $location, $interval, instead of window, location, setInterval
So that dependencies are isolated 
Use $log.info(), .debug(), .error(), etc. instead of console.log
So that your tests won't be litered with log messages
Use angular.copy, angular.extend, angular.forEach
Wrap external js library into their own minimal services
Use $interval instead of $timeout
So that E2E testing won’t return a timeout error

References


Angular Best Practices and anti-patterns https://github.com/angular/angular.js/wiki/Best-Practices
Joe Eames, AngularJS Best Practices, Pluralsight.com
Miško Hevery, Writing Testable Code, http://googletesting.blogspot.ca/2008/08/by-miko-hevery-so-you-decided-to.html
Mark Ethan Trostler, Testable JavaScript, O'Reilly Media, Jan 2013

No comments:

Post a Comment