Scopes et Controllers AngularJS

Beaucoup vous présenteront la création de controller AngularJS comme suit :

module.controller("MyController", function($scope){
    $scope.myvar = "YES";
});

Ceci est la méthode la plus courante, mais aussi la moins pratique de mon point de vue.

Le controller ci-dessus se comporte comme une fonction allant simplement initialiser le scope AngularJS sur lequel il est attaché.

C’est une méthode relativement simple, mais qui est potentiellement source d’erreurs. Dans cet article, je vous présente l’une des anomalies les plus courantes sur cette déclaration de controller et comment l’éviter.

Pour une meilleure compréhension, je vais d’abord présenter rapidement les scopes AngularJS et la création des scopes enfants.

Les scopes AngularJS

Lors de l’exécution d’AngularJS, ce dernier va créer les différents contextes de travail appelés scopes. Ils sont organisés sous forme d’une arborescence d’objets, avec tout en haut $rootScope.

Le fait de récupérer les propriétés du parent implique que les modifications effectuées sur les variables enfants ne seront pas répercutées sur la variable parent.
Si l’une des variables du scope parent est un objet, le scope enfant va récupérer la référence vers cet objet. Toute modification d’une variable contenue dans cet objet se fera donc sur la même entité.

Le problème

Le problème est que les controllers ne sont pas les seuls à créer des scopes AngularJS, beaucoup de directives le font aussi, et parfois il peut être dur de savoir si le scope sur lequel on travaille est bien celui que l’on souhaite. Et même si le doute n’est pas présent au moment où l’on écrit son code, rien n’empêche, plus tard, quelqu’un d’autre, de rajouter un petit contenu anodin qui va simplement créer un scope intermédiaire et provoquer un bug non souhaité.

Un exemple avec un simple ng-If : http://jsfiddle.net/wsf7k5w9/3/

<div ng-controller="MyController">
    SCOPE parent :
    <input type="text" ng-model="myvar" />
    {{ myvar }}
    <button ng-click="alert()">Afficher myvarbutton>
    
    <div ng-if="true">
        SCOPE enfant : 
        <input type="text" ng-model="myvar" />
        {{ myvar }}
        <button ng-click="alert()">Afficher myvarbutton>
    div>
div>
angular.module('myApp',[])
// Déclaration du controller utilisé dans le HTML
.controller("MyController",function($scope) {
    // Création de la variable
    $scope.myvar = 'TOTO';
    // Faire un alert javascript avec myvar
    $scope.alert = function(){
        alert($scope.myvar);
    };
});

Dans l’exemple ci-dessus, nous nous retrouvons avec deux champs textes branchés tous les deux sur la même variable initialisée dans le controller.
Mais chacun de ces champs texte est dans un scope différent à cause d’un ngIf placé au milieu, ce dernier créant un scope.

Il suffit de modifier le premier champ pour se rendre compte que la variable est bien la même initialement. En modifiant le parent, les modifications seront bien répercutées sur la variable enfant.
Par contre, si vous essayez de modifier le deuxième champ se trouvant dans le scope enfant, vous pourrez voir que les variables prennent maintenant un chemin différent, et cela sera presque définitif.
Appelons maintenant une fonction définie par le controller dans le scope et affichant la variable. Ce petit test nous permet de savoir où la fonction travaille, et si elle fonctionne au bon endroit. Il faut donc modifier le deuxième champ pour avoir deux valeurs différentes, puis de cliquer sur les boutons appelant la fonction du controller.

Vous devriez voir assez rapidement que la fonction travaille toujours sur le scope parent.

A cause donc d’une simple directive présente dans le modèle HTML, on se retrouve à modifier une variable différente de celle souhaitée.

Dans une architecture simple, comme celle-la, on ne voit pas forcement le problème, mais quand on souhaite gérer, par exemple, un gros formulaire, cela devient vite difficile de savoir à qui appartient la variable que l’on souhaite éditer et si, lors de l’envoi du formulaire, toutes les modifications seront bien prises en compte.

La mauvaise solution

Comme nous l’avons vu dans l’explication des scopes AngularJS, si la variable définie sur le scope est un objet, la copie de variable se fera par référence.

Voilà donc l’exemple présent ci-dessus utilisant cette méthode pour corriger le problème : http://jsfiddle.net/xx5va8cs/1/

<div ng-controller="MyController">
    SCOPE parent :
    <input type="text" ng-model="ctrl.myvar" />
    {{ ctrl.myvar }}
    <button ng-click="alert()">Afficher myvarbutton>
    
    <div ng-if="true">
        SCOPE enfant : 
        <input type="text" ng-model="ctrl.myvar" />
        {{ ctrl.myvar }}
        <button ng-click="alert()">Afficher myvarbutton>
    div>
div>
angular.module('myApp',[])
// Déclaration du controller utilisé dans le HTML
.controller("MyController",function($scope) {
    // Création de l'objet contenant la variable
    $scope.ctrl = {
        myvar : 'TOTO'
    };
    // Faire un alert javascript avec myvar
    $scope.alert = function(){
        alert($scope.ctrl.myvar);
    };
});

Vous pouvez faire les même tests que précédemment, en faisant cela vous devriez remarquer qu’il n’y a ici plus d’erreur, la variable est toujours modifiée correctement.

Mais en regardant le template on ne sait pas trop d’où provient le ctrl, ni à quoi il sert vraiment. La déclaration de ctrl directement dans le controller nous empêche aussi d’inclure le même controller dans un scope enfant du controller sans écraser la référence vers cet objet.

Cette solution fonctionne donc, mais elle n’est pas forcement très élégante ni très pratique à l’usage.

La solution

Pour pallier de très nombreux problèmes, dont certains sont évoqués au-dessus, quelque chose d’assez simple existe : le controllerAs. Vous pourrez voir ci-dessous notre controller créé de cette manière.

Le controller en tant qu’objet : http://jsfiddle.net/utm0s61x/1/

<div ng-controller="MyController as ctrl">
    SCOPE parent :
    <input type="text" ng-model="ctrl.myvar" />
    {{ ctrl.myvar }}
    <button ng-click="ctrl.alert()">Afficher myvarbutton>
    
    <div ng-if="true">
        SCOPE enfant : 
        <input type="text" ng-model="ctrl.myvar" />
        {{ ctrl.myvar }}
        <button ng-click="ctrl.alert()">Afficher myvarbutton>
    div>
div>
angular.module('myApp',[])
// Déclaration du controller utilisé dans le HTML
.controller("MyController",function() {    
    // Création de la variable
    this.myvar = 'TOTO';
    // Faire un alert javascript avec myvar
    this.alert = function(){
        alert(this.myvar);
    };
});

Dans les modifications visibles, nous pouvons voir, côté html, l’ajout d’un alias dans la directive ngController. Cet alias est ensuite repris dans les différents appels aux variables dans le template.
Dans le javascript, on voit le controller comme un objet, attachant donc nos variables à ‘this’. Cela nous permet donc prototypage et héritage pour ceux qui le souhaitent.

Conclusion

Le controller créé via un controllerAs apporte certains avantages :

  • Une notation objet, pouvant se lier à l’utilisation de prototype javascript, le controller appelant le constructeur de l’objet à sa création, et permettant donc l’héritage et l’application de code objet plus complexe.
  • On peut choisir l’alias du controller directement dans la vue, sans devoir toucher au code javascript. Cela permet donc d’avoir une inclusion récursive du controller en lui donnant le même alias, si l’on souhaite écraser le parent, ou en changeant l’alias, si l’on souhaite qu’il existe en parallèle.
  • Un code javascript du controller plus propre. Nous ne sommes plus liés à l’injection de $scope dans le controller et nous ne sommes pas obligés d’inclure le contenu dans une variable servant au final de protection à la création de nouveau scope.
  • L’ajout de contrainte dans le controller, comme le fait de ne pas connaître l’alias défini dans le template, nous force à bien écrire certaines parties.

Laissez un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *