dimanche 9 décembre 2012

Mettre les contrôleurs d'AngularJS dans un module

Dans les petits exemples d'utilisation du framework AngularJS publiés sur le site officiel ou ailleurs, les services, filtres et directives sont organisés en modules, mais les contrôleurs sont des fonctions JavaScript globales.

Mais si c'est un raccourci acceptable pour un petit exemple, ce n'est pas une bonne pratique d'avoir des fonctions définies dans le contexte global, en l'occurrence l'objet window du navigateur. Le contexte global étant le même pour tous les fichiers JavaScript chargés dans une page web, moins on y met de choses, et moins on a de risques de collisions avec les bibliothèques qu'on charge. Donc une fonction globale, ce n'est certes pas aussi grave qu'une variable globale, mais quand on peut il vaut mieux éviter.

Et justement, contrairement à ce que laissent penser les exemples en question, une application AngularJS n'a en rien besoin de définir des fonctions dans le contexte global. Les contrôleurs peuvent être placés dans des modules, comme les services, les directives et les filtres. Dans une grosse application, il y a tout intérêt à les organiser en modules comme le reste.

Pour mettre un contrôleur dans un module, c'est tout simple, il suffit de le définir avec la méthode controller de l'objet module. Bon en fait ça a quand même quelques petits impacts.

Voyons comment modifier l'exemple “Wire up a Backend” du site officiel. Les contrôleurs y sont définis comme des fonctions globales :

angular.module('project', ['mongolab']).
  config(function($routeProvider) {
    $routeProvider.
      when('/', {controller:ListCtrl, templateUrl:'list.html'}).
      when('/edit/:projectId', {controller:EditCtrl, templateUrl:'detail.html'}).
      when('/new', {controller:CreateCtrl, templateUrl:'detail.html'}).
      otherwise({redirectTo:'/'});
  });

function ListCtrl($scope, Project) {
  $scope.projects = Project.query();
}

function CreateCtrl($scope, $location, Project) {
  $scope.save = function() {
    Project.save($scope.project, function(project) {
      $location.path('/edit/' + project._id.$oid);
    });
  }
}

function EditCtrl($scope, $location, $routeParams, Project) {
  var self = this;
  Project.get({id: $routeParams.projectId}, function(project) {
    self.original = project;
    $scope.project = new Project(self.original);
  });
  $scope.isClean = function() {
    return angular.equals(self.original, $scope.project);
  }
  $scope.destroy = function() {
    self.original.destroy(function() {
      $location.path('/list');
    });
  };
  $scope.save = function() {
    $scope.project.update(function() {
      $location.path('/');
    });
  };
}

On peut les mettre dans le module principal de l'application comme ceci (jsfiddle ici) :

angular.module('project', ['mongolab']).
  config(function($routeProvider) {
    $routeProvider.
      when('/', {controller:'ListCtrl', templateUrl:'list.html'}).
      when('/edit/:projectId', {controller:'EditCtrl', templateUrl:'detail.html'}).
      when('/new', {controller:'CreateCtrl', templateUrl:'detail.html'}).
      otherwise({redirectTo:'/'});
  }).
  controller('ListCtrl', ['$scope', 'Project', 
                          function ($scope, Project) {
      $scope.projects = Project.query();
  }]).
  controller('CreateCtrl', ['$scope', '$location', 'Project', 
                            function ($scope, $location, Project) {
      $scope.save = function() {
        Project.save($scope.project, function(project) {
          $location.path('/edit/' + project._id.$oid);
        });
      }
  }]).
  controller('EditCtrl', ['$scope', '$location', '$routeParams', 'Project',
                          function ($scope, $location, $routeParams, Project) {
      var self = this;
      Project.get({id: $routeParams.projectId}, function(project) {
        self.original = project;
        $scope.project = new Project(self.original);
      });
      $scope.isClean = function() {
        return angular.equals(self.original, $scope.project);
      }
      $scope.destroy = function() {
        self.original.destroy(function() {
          $location.path('/list');
        });
      };
      $scope.save = function() {
        $scope.project.update(function() {
          $location.path('/');
        });
      };
  }]);

L'utilisation des contrôleurs dans la vue se fait exactement de la même façon. Par contre comme vous pouvez le voir, dans le $routeProvider, il faut maintenant indiquer les contrôleurs comme des chaînes de caractères plutôt que comme des fonctions globales, justement parce que les fonctions globales n'existent plus.

La liste des arguments pour l'injection des dépendances (entre crochets et avant la définition de la fonction elle-même) n'est nécessaire que pour la minification des fichiers JavaScript. Mais c'est une bonne habitude de la mettre systématiquement, plutôt que de devoir tout reprendre le jour où l'on veut minifier les fichiers.

Personnellement je trouve qu'enchaîner les appels successifs à la méthode controller du module nuit à la lisibilité du code dès qu'il y a plus d'un ou deux contrôleurs. Je préfère faire des appels séparés, en mettant le module dans une variable. Quoi, une variable globale ? Non, surtout pas, il suffit de mettre le tout dans un anonymous wrapper pour que la variable soit locale et ne pollue pas le contexte global. D'ailleurs je mets l'ensemble du code JavaScript d'une application AngularJS dans des anonymous wrappers, puisque rien n'a besoin d'être global.

Voici ce que ça donne (jsfiddle ici) :

(function () {  // anonymous wrapper

var project = angular.module('project', ['mongolab']);

project.config(function($routeProvider) {
  $routeProvider.
    when('/', {controller:'ListCtrl', templateUrl:'list.html'}).
    when('/edit/:projectId', {controller:'EditCtrl', templateUrl:'detail.html'}).
    when('/new', {controller:'CreateCtrl', templateUrl:'detail.html'}).
    otherwise({redirectTo:'/'});
});

project.controller('ListCtrl', ['$scope', 'Project', 
                                function ($scope, Project) {
    ...
}]);

project.controller('CreateCtrl', ['$scope', '$location', 'Project', 
                                  function ($scope, $location, Project) {
    ...
}]);
    
project.controller('EditCtrl', ['$scope', '$location', '$routeParams', 'Project',
                                function ($scope, $location, $routeParams, Project) {
    ...
}]);

})();​

Une autre variante dans la présentation du code consiste à séparer la fonction du contrôleur de l'appel de la méthode controller, mais toujours dans un anonymous wrapper sinon on retrouve les fonctions globales qu'on voulait éviter (jsfiddle ici) :

(function () {  // anonymous wrapper

var project = angular.module('project', ['mongolab']);

project.config(function($routeProvider) {
  $routeProvider.
    when('/', {controller:'ListCtrl', templateUrl:'list.html'}).
    when('/edit/:projectId', {controller:'EditCtrl', templateUrl:'detail.html'}).
    when('/new', {controller:'CreateCtrl', templateUrl:'detail.html'}).
    otherwise({redirectTo:'/'});
});

project.controller('ListCtrl', ['$scope', 'Project', ListCtrl]);
project.controller('CreateCtrl', ['$scope', '$location', 'Project', CreateCtrl]);
project.controller('EditCtrl', ['$scope', '$location', '$routeParams', 'Project', EditCtrl]);
    
function ListCtrl($scope, Project) {
    ...
}

function CreateCtrl($scope, $location, Project) {
    ...
}
    
function EditCtrl($scope, $location, $routeParams, Project) {
    ...
}

})();​

C'est surtout une question de goût. Du moment qu'on n'écrit plus les contrôleurs comme des fonctions globales, tous les styles se valent.




4 commentaires:

  1. Très propre comme approche.

    Par contre je ne vois pas comment encapsuler le module dans une fonction anonyme si les controleurs, directives et autres fonctions sont répartis dans différents fichiers comme cela sera le cas dès que l'application va grossir.

    RépondreSupprimer
  2. On peut accéder à un module défini dans un autre fichier, il suffit de le récupérer en utilisant angular.module('nomDuModuleExistant'). L'absence du second paramètre de la méthode module (la liste des dépendances) fait qu'Angular renvoie le module existant, au lieu d'en créer un nouveau.

    Mais personnellement je préfère avoir un module Angular différent par fichier JavaScript, et donc plusieurs petits modules plutôt qu'un gros.

    S'il y a plusieurs fichiers qui sont vraiment liés, on peut leur donner des noms préfixés par une sorte de package, par exemple 'grosModule.directives', 'grosModules.services'. Et pour ne pas avoir à les inclure un par un, il suffit de créer dans un autre fichier un module 'grosModule' avec la liste de ses sous-modules comme dépendances, et sans autre contenu. On peut avoir des dépendances en cascade entre les modules, voir cet exemple sur jsFiddle.

    RépondreSupprimer
  3. Excellent!
    C'est un plaisir de développer avec Angular.

    RépondreSupprimer
  4. Je viens de JAVA et Flash, et du coup je n'aime pas beaucoup utiliser des chaines de caractères un peu trop sensible aux fautes de frappe. N'y a t'il pas une méthode proche d'une sorte d'Enum aux propriétés statique genre :

    ProjectEnum.CONTROLLER_EDIT

    Qui contient une chaine de caractère, pour l'utiliser un peu comme ça



    project.controller(ProjectEnum.CONTROLLER_EDIT, ['$scope', '$location', '$routeParams', 'Project',
    function ($scope, $location, $routeParams, Project) {
    ...
    }]);


    Ou alors c'est une "bonne pratique" la chaine simple ?

    RépondreSupprimer