vendredi 21 décembre 2012

Initialisations avant le routage avec la propriété resolve

On peut avoir besoin dans une application AngularJS de faire des initialisations avant de charger une vue, et surtout avant que le framework instancie son contrôleur. Par exemple si on fait appel à une API nécessitant une authentification, et renvoyant un token pour les requêtes suivantes, on peut vouloir s'authentifier avant tout chargement de vue, pour être sûr d'avoir récupéré le token au préalable.

Comment faire en sorte que le routage d'AngularJS attende la récupération du token d'authentification, qui est bien sûr une opération asynchrone ? C'est ce que permet la propriété resolve du second paramètre de la méthode $routeProvider.when().


La syntaxe de $routeProvider.when() est la suivante :

$routeProvider.when('/view1', {
    controller:'View1Ctrl', 
    templateUrl:'view1.html', 
    resolve: {key1: 'Service1', key2: function2}
});

Cette propriété resolve doit être un objet JavaScript, donc une map clef/valeur, dont chaque propriété est soit une chaîne de caractères, soit une fonction. Dans le cas d'une chaîne de caractères, la valeur de la propriété est remplacée par le service publié sous ce nom-là. Si c'est une fonction, alors la fonction est exécutée, et la valeur de la propriété est remplacée par la valeur de retour de la fonction.

On obtient donc une map d'objets, après récupération des services ou exécution des fonctions indiquées. Parmi ces objets, il peut y avoir des promises. Vous trouverez toutes les explications sur les promises dans le précédent article : l'API Promise d'AngularJS.

S'il y a une ou plusieurs promises, alors AngularJS va attendre que toutes les promises soient résolues avant d'instancier le contrôleur et de changer la route. C'est donc ainsi qu'on va pouvoir attendre la fin d'une ou plusieurs opérations asynchrones, il suffit de passer les promises correspondant à ces opérations, et AngularJS fera le reste.

Faisons un premier exemple avec un service qui est une promise. Le jsFiddle complet est ici. Pour faire simple puisqu'il ne s'agit que d'un exemple, la factory du service utilise $timeout pour créer une promise qui sera résolue au bout de 3 secondes, et renvoie cette promise. C'est-à-dire que l'objet publié sous le nom de service 'WaitInit' est directement la promise renvoyée par $timeout :

app.factory('WaitInit', ['$timeout', function ($timeout) {
    return $timeout(function () {}, 3000);
}]);


Du coup dans la propriété resolve, on peut mettre directement le nom de ce service, et comme c'est une promise, AngularJS va attendre qu'elle soit résolue pour instancier le contrôleur et charger la vue :

$routeProvider.
    when('/', {
        controller:'View1Ctrl', 
        templateUrl:'view1.html', 
        resolve: { wait: 'WaitInit'}
    }).
    when('/view2', {
        controller:'View2Ctrl', 
        templateUrl:'view2.html', 
        resolve: { wait: 'WaitInit'}
    }).
    otherwise({redirectTo:'/'});

C'est ce qu'on constate à l'exécution, il y a un délai de 3 secondes avant que la première vue apparaisse. Ensuite lors des changements de vue il n'y a plus de délai, car le service est toujours la même promise, qui est déjà résolue. On peut donc ainsi, à la place du $timeout de l'exemple, faire les initialisations nécessaires à l'application et attendre qu'elles soient terminées avant d'afficher la première vue.

Voyons comme utiliser l'option des fonctions plutôt que des services, qui est plus générale. Voici un second jsFiddle avec des fonctions anonymes, dont le code est directement dans la propriété resolve, et qui renvoient simplement le service précédent :

        resolve: { func: ['WaitInit', function (WaitInit) {
            return WaitInit;
        }]}

Rien n'oblige bien sûr à utiliser une fonction anonyme, surtout si c'est la même fonction qui sert à toutes les vues, il est beaucoup plus logique de la définir une seule fois et de l'utiliser dans tous les resolve. Il faut juste faire attention à l'injection des dépendances, si la fonction a besoin d'utiliser des services. L'injection doit se faire dans la fonction elle-même. Il n'est pas possible d'injecter des services à la fonction passée comme paramètre de la méthode config(), qui n'accepte que des injections de providers.

Mais si on injecte directement les services à la fonction qu'on passe au resolve, il n'y a aucun problème, comme on peut le voir dans ce troisième jsFiddle :

app.config(function($routeProvider) {
    
  function wait(WaitInit) {
      return WaitInit;
  }
  wait.$inject = ['WaitInit'];
    
  $routeProvider.
    when('/', {
        controller:'View1Ctrl', 
        templateUrl:'view1.html', 
        resolve: { func: wait}
    }).
    when('/view2', {
        controller:'View2Ctrl', 
        templateUrl:'view2.html', 
        resolve: { func: wait}
    }).
    otherwise({redirectTo:'/'});
    
});

Voilà pour les différentes façons d'utiliser cette propriété resolve avec des promises, fournies comme des services ou des fonctions. Mais son usage ne s'arrête pas là, et on n'a pas vu à quoi servent les clefs de cette map clef/valeur.

Une fois que les objets de la map ont été récupérés soit depuis les services correspondants, soit en exécutant les fonctions, AngularJS attend que toutes les promises soient résolues, mais il remplace également chaque promise lorsqu'elle est résolue par son résultat, sa valeur de résolution. A ce stade-là, il ne reste plus aucune promise dans la map, et les valeurs de cette map sont disponibles pour l'injection dans le contrôleur de la vue, comme on injecte n'importe quel service, en utilisant la clef associée à chaque valeur.

On peut donc ainsi avoir un même contrôleur qui sert à plusieurs vues, mais avec des valeurs injectées différentes selon la vue. Rappelez-vous quand même que ce qu'on associe à chaque clef du resolve, c'est soit le nom d'un service, soit une fonction renvoyant une valeur ; on ne peut pas y mettre directement la valeur souhaitée, mais il suffit de faire une fonction renvoyant la valeur.

C'est ce qui est fait dans ce dernier jsFiddle, où il n'y a plus de promise, mais où l'on passe au même contrôleur une fonction renvoyant une valeur différente selon la vue :

$routeProvider.
    when('/', {
        controller:'ViewCtrl', 
        templateUrl:'view1.html', 
        resolve: { viewName: function () {
            return "V1";
        }}
    }).
    when('/view2', {
        controller:'ViewCtrl', 
        templateUrl:'view2.html', 
        resolve: { viewName: function () {
            return "V2";
        }}
    }).
    otherwise({redirectTo:'/'});

Et l'injection de viewName dans le contrôleur se fait de façon classique :

app.controller('ViewCtrl', ['$scope', 'viewName', function ($scope, viewName) {
    $scope.view = viewName;
}]);

J'ai utilisé une fonction dans l'exemple, mais pour implémenter quelque chose ressemblant au design pattern Strategy, où l'on passerait du code différent au même contrôleur en fonction de la vue, il est beaucoup plus propre d'écrire chaque stratégie sous la forme d'un service, et d'utiliser resolve avec le nom du service à injecter au contrôleur pour chacune des vues.

6 commentaires:

  1. Encore un post très intéressant, merci :-)

    RépondreSupprimer
  2. Merci pour ce post très intéressant++ ! Verrais tu un moyen élégant de déclarer une fonction à resolver par défaut sur toutes les routes ?

    RépondreSupprimer
    Réponses
    1. Il n'y a pas d'option prévue pour ça dans AngularJS, mais tu peux définir une objet avec la fonction en question, et le passer comme paramètre resolve de chaque route. Ou même définir une fonction qui encapsule l'appel du when, pour lui passer systématiquement le même resolve.

      Si tu as un cas concret, n'hésite pas à lancer un sujet sur le forum. Suivant ce que tu veux faire, il peut y avoir d'autres options plus pertinentes qu'un resolve systématique sur toutes les routes, comme par exemple un contrôleur au-dessus du <ng-view>, qui est alors accessible dans toutes les routes.

      Supprimer
  3. très intéressant... J'aurais néanmoins une question, je suis en train de programmer une appli avec Angularjs et node-webkit, et lors de la minimisation de l'appli, angular perd le scope et me route vers "/", est ce normal? Est ce que la minimisation provoque une changement de route?

    RépondreSupprimer
  4. Article très intéressant, merci !
    Je me demandais : si je fournis à resolve une promise, qui est finalement rejetée plutôt que d'être résolue, y a-t-il un moyen de faire en sorte que la vue ne se charge pas ?

    Exemple typique : l'authentification. Si l'utilisateur est authentifié, OK, on charge la vue dès que la promise est résolue. Mais dans le cas où l'utilisateur n'est pas ou plus authentifié, j'aimerais bien le rediriger vers la page de login. Suis-je obligé d'effectuer ce travail dans le service ou la fonction que j'ai passé dans la propriété resolve ?

    RépondreSupprimer
  5. aaah exactement l'information que je recherchais et problème résolu de mon côté. Merci Thierry !

    RépondreSupprimer