jeudi 31 octobre 2013

Scope isolé dans les directives AngularJS


Second sujet que j'ai traité au Meetup chez Google, il s'agit des scopes isolés dans les directives.  Les slides se trouvent ici, et une vidéo a été mise en ligne là ; mon intervention commence par un sujet sur l'usage des services, et celui-ci est à la suite.

Le scope isolé est un outil bien pratique mis à notre disposition par AngularJS pour faciliter la création de widgets. Mais il ne faut pas l'utiliser n'importe quand, ni en faire n'importe quoi.


L'arbre des scopes


Les scopes d'AngularJS sont des objets qui servent de contexte d'évaluation des expressions contenues dans les templates.

Ils forment un arbre, dont la racine est le seul scope de l'application qui est aussi publié comme un service, sous le nom $rootScope. Ce $rootScope est associé à l'élément contenant toute l'application AngularJS, celui sur lequel on met la directive ngApp : ça peut être l'élément <html> lui-même, ou le <body>, ou un <div> à l'intérieur, peu importe.



La structure arborescente des scopes est calquée sur celle des éléments de la page HTML. Chaque scope est lié à un élément du DOM, mais la réciproque n'est pas vraie. Il n'y a pas un scope pour chaque élément, seulement pour certains des éléments de la page HTML, parce qu'on a placé sur ces éléments une directive AngularJS qui crée un nouveau scope. C'est le cas des directives ngController, ngView, ngRepeat, etc.

Quand une directive crée un scope sur un élément HTML, ce scope aura pour parent le premier scope rencontré en remontant les éléments HTML.


Scope d'un élément


Quand on crée une directive, qui sera déclenchée sur un élément HTML soit en lui ajoutant un attribut soit par le nom de l'élément lui-même, on fournit un objet de définition de la directive. Il y a trois options possibles quant à la propriété scope de cet objet  :

  • false pour ne rien faire (c'est la valeur par défaut si la propriété scope est absente)
  • true pour créer un scope enfant
  • {...} (un objet JavaScript avec des propriétés) pour créer un scope isolé

Le scope isolé tout comme le scope enfant a un scope parent, référencé par sa propriété $parent. La différence c'est l'héritage : seul le scope enfant hérite des données de son scope parent, le scope isolé n'en hérite pas, c'est pour ça qu'il est dit "isolé". Il ne s'agit pas de gros sous, mais de l'héritage par prototype de JavaScript. Quand l'interpréteur JS ne trouve pas une propriété dans un objet, il va la chercher dans son prototype. Puis éventuellement dans le prototype du prototype, tant qu'il n'est pas arrivé au bout de la chaîne des prototypes, ou qu'il n'a pas trouvé la propriété.

Le scope parent est positionné par AngularJS comme prototype de ses scopes enfants, mais pas des scopes isolés. Du coup un scope enfant hérite automatiquement des données de son scope parent, y compris celles que le parent a hérité de ses propres parents. Il est donc inutile d'aller chercher des données explicitement dans le scope parent en utilisant la propriété $parent, puisqu'elles sont héritées. Au contraire, le scope isolé n'héritera d'aucune donnée, et on peut dans certains cas (assez rares) avoir besoin de récupérer son scope parent via sa propriété $parent.

Ce qu'il est très important de comprendre, c'est que même si c'est au niveau d'une directive qu'on indique si elle doit créer un scope enfant ou un scope isolé, ce scope est créé pour l'élément HTML. Ce sera dont le scope récupéré par toutes les directives placées sur cet élément, et aussi celles placées sur ses éléments enfants s'ils n'ont pas eux-mêmes leur propre scope. Les trois premiers paramètres de la fonction link de la directive, en l'occurrence le scope, l'élément et l'objet contenant les attributs, ne dépendent pas de la directive, et seront les mêmes pour toutes les directives du même élément. Ils ne dépendent que de l'élément lui-même, ce qui fait que toutes les directives d'un même élément reçoivent le même scope, celui de l'élément, et tous les attributs de l'élément HTML.

Evidemment, ça ne peut fonctionner que si les directives d'un élément HTML ne formulent pas de demandes contradictoires. Si une directive demande la création d'un scope enfant, alors que les autres ne demandent rien (leur propriété scope est à false), alors AngularJS crée un scope enfant pour cet élément, qui sera passé à toutes les directives, même celles qui n'ont rien demandé. Mais il est impossible d'avoir sur un même élément une directive demandant un scope enfant et une autre demandant un scope isolé, ou deux directives demandant un scope isolé, chacune avec sa propre définition. Dans ces cas d'incompatibilité, le framework signale une erreur.


Quand et pourquoi un scope isolé ?


La plupart des directives n'ont pas à créer de nouveau scope, que ce soit un scope enfant ou isolé. Quand une directive demande la création d'un nouveau scope, ce n'est pas anodin car ça la rend incompatible avec d'autres directives.

Si elle demande un scope isolé, c'est encore plus contraignant, car ça veut dire qu'aucune des données du scope parent ne sera disponible dans le scope de l'élément sur lequel elle est appliquée. Ça signifie qu'il ne sera pas possible d'utiliser sur ce même élément ou sur n'importe quel élément enfant une autre directive qui prend dans un attribut (généralement du nom de la directive) une expression à évaluer sur le scope. Vu que les données ne seront pas présentes dans le scope, l'évaluation de l'expression ne peut que mal se passer.

Il faut limiter l'usage des scopes isolés à des éléments sans contenu et sur lesquels on ne met pas d'autres directives, du moins pas de directive comme ngShow par exemple, dont l'expression serait alors évaluée sur un scope isolé ne contenant pas les données. Bon le ngShow, on peut toujours le mettre sur un <div> englobant l'élément, mais pour les directives se trouvant dans le contenu de l'élément c'est mort.

L'usage typique d'un scope isolé, c'est dans une directive qui crée un composant complet, un widget comme un agenda, une carte, un graphique.

Alors s'il y a de telles limitations à l'usage d'un scope isolé, pourquoi s'en servir ? Parce que dans les cas où l'on peut le faire, ça simplifie le code de la directive, et ça évite le risque d'impacter involontairement les données du scope parent.

On peut publier tout ce qu'on veut dans le scope isolé, et on peut évaluer toutes les expressions qu'on veut sur le scope isolé, tant qu'on n'accède pas explicitement à sa propriété $parent, on est sûr qu'il n'y aura aucune modification - ni même lecture d'ailleurs - des données du scope parent. Le scope isolé créé par une directive, c'est un peu comme le scope local d'une fonction. A l'exécution d'une fonction, en JavaScript ou dans n'importe quel autre langage, les variables locales définies dans la fonction n'existent qu'à l'intérieur de cette fonction, et il ne peut pas y avoir de collision avec des variables externes à la fonction.

L'analogie va plus loin : les propriétés définies dans le scope isolé vont s'apparenter aux paramètres de la fonction. En définissant une propriété dans le scope isolé, on lui donne un nom local à la directive, enfin plus précisément dans le scope de l'élément donc pour toutes les directives de cet élément s'il y en a plusieurs. Dans la directive, on travaille alors uniquement avec les propriétés définies dans le scope isolé, sans aller chercher manuellement les valeurs des attributs de l'élément.


Bindings des propriétés du scope isolé


Chaque propriété définie dans le scope isolé va être liée par un binding d'un certain type à un attribut de l'élément HTML.

On va avoir trois utilisations différentes pour les attributs de l'élément :

  • des attributs de type texte, qui peuvent éventuellement contenir une ou plusieurs expressions entre doubles accolades {{...}} (comme la directive ngSrc)
  • des attributs de type expression qui servent à faire un binding sur la valeur de l'expression (comme la directive ngBind)
  • des attributs de type expression qui servent à déclencher une action (comme la directive ngClick)

Ces trois cas d'utilisation correspondent aux trois symboles utilisables dans la définition des propriétés du scope isolé :

  • '@' pour un attribut texte
  • '=' pour une expression valeur
  • '&' pour une expression action


@ : attribut texte 


On indique le nom de la propriété, et dans une chaîne de caractères le nom de l'attribut de l'élément HTML précédé par le signe @ :

    scope: { prop1: '@attr1' },

On peut aussi omettre le nom de l'attribut s'il est identique au nom de la propriété, dans ce cas on met seulement le signe '@'.

Prenons un exemple concret :

    <person name="{{user.firstName}} {{user.lastName}}"/>

et

    scope: {
        name: '@'
    }

Ici le scope isolé est défini avec une propriété name, qui référence l'attribut texte du même nom. AngularJS crée un binding monodirectionnel de l'attribut vers la propriété du scope isolé.

Ça veut dire que la propriété name du scope isolé recevra automatiquement la valeur texte de l'attribut référencé, dans laquelle les éventuelles expressions entre {{...}} auront été évaluées. Ici le prénom de l'utilisateur, un espace, puis son nom. Et ce binding est permanent, chaque fois que user.firstName ou user.lastName changera de valeur, celle de la propriété name  du scope isolé sera recalculée. La réciproque n'est pas vraie, le binding est monodirectionnel : si on modifiait à l'intérieur de la directive la valeur de la propriété name du scope isolé, ça n'aurait aucune conséquence sur user.firstName ou user.lastName.

Et bien sûr, l'évaluation des expressions de l'attribut se fait sur le scope parent, pas sur le scope isolé qui n'a hérité d'aucune donnée, et dans lequel ni user.firstName ni user.lastName ne sont définis.


=  : expression valeur


La syntaxe est la même, pour une propriété qui référence un attribut contenant une expression à utiliser comme une valeur, on fait précéder le nom de l'attribut par le signe =, et on peut encore omettre le nom de l'attribut et mettre un signe '=' seul s'il correspond au nom de la propriété.

    scope: { prop2: '=attr2' },

Un exemple :

    <person name="user.lastName"/>

et

    scope: {
        name: '='
    }

Ici on a défini dans le scope isolé une propriété name, qui référence l'attribut name contenant une expression, pour qu'AngularJS crée un binding bidirectionnel entre la propriété du scope isolé et la valeur de cette expression (toujours évaluée sur le scope parent).

Ça signifie ici que la propriété name contiendra toujours la valeur de l'expression indiquée dans l'attribut name. Si la valeur de user.lastName est modifiée dans le scope parent, la propriété name du scope isolée sera recalculée. Et réciproquement, si dans le code de la directive on change la valeur de la propriété name, alors AngularJS mettra à jour user.lastName dans le scope parent. C'est plus simple que de devoir le gérer à la main.


&  : expression action


De la même façon, pour définir une propriété référençant un attribut contenant une expression à utiliser comme une action, on fait précéder le nom de l'attribut par le signe &, et on peut là aussi omettre le nom de l'attribut et mettre seulement le signe '&' s'il correspond au nom de la propriété.

    scope: { prop3: '&attr3' },

Un exemple :

    <delete-button action="remove(user)"/>

et

    scope: {
        action: '&'
    }

Ici AngularJS fournit comme valeur de la propriété action du scope isolé une fonction a exécuter pour déclencher l'action, c'est-à-dire l'évaluation de l'expression contenue dans l'attribut action. Il n'y a pas de binding dans ce cas, c'est juste une fonction à exécuter pour déclencher l'action.

On peut même passer à cette fonction un objet contexte dont les données vont surcharger celles du scope parent. Supposons qu'on exécute ceci dans la directive :

    scope.action({user: previousUser});

Le user sera alors celui de l'objet passé en paramètre, il ne sera pas pris dans le scope parent. Par contre la fonction remove() viendra elle du scope parent, car elle n'est pas fournie dans l'objet contexte passé en paramètre de l'action.


Et sans scope isolé ?


Ces trois différents usages possibles des attributs au moyen de propriétés définies dans le scope isolé sont certes très pratiques, et permettent de simplifier le code de la directive. Mais on a vu avant qu'on ne peut pas utiliser un scope isolé dans tous les cas, loin s'en faut.

Alors comment faire dans la fonction link de la directive quand on doit se passer du côté pratique du scope isolé ? Et bien ce n'est pas beaucoup plus compliqué.

    link: function (scope, element, attrs, ctrl) {
        // ...
    }


Pour un attribut texte, on utilise la méthode $observe() de l'objet attrs contenant les attributs de l'élément, avec le nom de l'attribut à observer :

    attrs.$observe('xxx', function(value) {    // xxx est le nom de l'attribut
        // ...
    });


Pour une expression valeur, on utilise la méthode $watch() du scope, avec comme expression à surveiller la valeur de l'attribut fournie par la propriété correspondante (en camel case) de l'objet attrs :

    scope.$watch(attrs.xxx, function(newVal, oldVal) {
        // ...
    });

Et en sens inverse, pour mettre à jour la valeur de l'expression contenue dans l'attribut, s'il s'agit d'une expression assignable bien sûr, on utilise le service $parse, et la méthode assign() de l'objet qu'il renvoie :

    $parse(attrs.xxx).assign(scope, value);


Pour une expression action, on utilise encore le service $parse, et on appelle la fonction renvoyée par $parse, en lui fournissant le scope en premier paramètre, et éventuellement en second paramètre un objet contexte qui surcharge certaines données du scope.

    $parse(attrs.xxx)(scope, locals);


Même si c'est un peu plus complexe que de manipuler directement les propriétés du scope isolé, ça reste très raisonnable comme quantité de code à écrire.


Exemple de directive avec un scope isolé


Vous pouvez retrouver l'exemple de la directive Google Maps que j'ai montré lors de cette présentation ici sur Plunker.

Dans le code de la directive, on manipule uniquement les propriétés du scope isolé, que ce soit pour placer des watches afin d'impacter la carte affichée quand les données changent dans le scope (parent), ou pour mettre à jour les données du scope d'après les événements de la carte Google Maps.

Voilà, avec tout ça vous avez de quoi jouer, avec ou sans scope isolé.


5 commentaires:

  1. Article intéressant ! J'ai été évidemment confronté à ce problème surtout que la documentation officielle n'explique pas bien les impacts des scopes isolés. Du coup, tous les débutants comme j'étais (ou suis encore ?), créent leurs premières directives avec scope isolés.

    Le cas classique est le cas d'une directive avec scope isolé sur un élément avec ng-model.
    Il est cependant bien de noter qu'il est possible du coup de passer les valeurs à directive avec "$parent.".
    Par exemple :

    < input ng-model="$parent.monModele" directive-avec-scope-isole >

    RépondreSupprimer
  2. Voilà un article qui m'a été bien utile. Merci.

    RépondreSupprimer
  3. Très intéressant!

    Pour un attribut texte d'une directive sans scope isolé,
    vous dîtes d'utiliser la méthode $observe de 'attrs':
    attrs.$observe('xxx', function(value) { // xxx est le nom de l'attribut
    // ...
    });

    Quelle est la différence avec un appel direct à 'attrs.xxx' ?
    merci.

    RépondreSupprimer