Comprendre Angular en le refaisant de zéro à Devoxx France 2014

Dans l’article Devoxx France 2014 – Vous y étiez ? Nous aussi !, nous vous annoncions que SQLI était présent à la grand-messe des développeurs francophones. Voici un billet relatif aux sessions qui nous ont le plus marquées.

Hello World : Angular de zéro au Devoxx

Lors de Devoxx France 2013, l’Université sur AngularJS présentée par Thierry Chatel avait enthousiasmé l’auditoire.

Un an plus tard, à Devoxx France 2014, Angular JS fait partie des technos les plus en vogue, au même titre que Docker, Java 8 ou bien encore de la programmation réactive.

Preuve en est, de nombreux talks étaient dédiés au framework JavaScript de Google : Angular JS from scratch : comprendre Angular en le refaisant de zéro, Models and Rest APIs on AngularJS with Restangular, Karma et Protector : testons vos applications Angular JS, au secours mon code AngularJS est pourri et Reactive Angular.

Par ailleurs, au cours de sessions de live coding, Angular était fréquemment utilisée comme technologie de présentation. Parmi les speakers, l’adoption d’Angular fait l’unanimité et supplante ses rivaux de l’an passé, à savoir BackboneJS, EmberJS ou bien encore KnockoutJS.

Chez SQLI aussi, Angular a naturellement trouvé sa place parmi les développeurs web et les projets que nous mettons en œuvre. Le duo Angular + Spring MVC a particulièrement fait ses preuves. En juin dernier, était organisé un workshop permettant à nos collègues parisiens de se former à ce framework.

A Devoxx France 2014, le Hand’s-on-Lab « Angular JS from scratch : comprendre Angular en le refaisant de zéro »  proposé par Matthieu Lux et Olivier Huber nous aura permis d’approfondir nos connaissances et de découvrir les mécanismes se cachant derrière la magie d’Angular.

Angular from scratch

Ce Lab a eu un beau succès : une salle comble 10 minutes avant son début et une place sur le podium des meilleures sessions de la matinée.

Pour coder les différents exercices sans avoir à se tourner régulièrement vers les solutions, de solides connaissances en JavaScript étaient nécessaires : héritage par prototype, constructeur, portée du this, couteau suisse underscore (each, clone, isEqual)…

Par ailleurs, pour apprécier la démarche, une connaissance minimale d’Angular était indispensable.

Durant les 3 heures du Lab, 11 des 12 étapes prévues initialement (la dernière étant en bonus) ont pu être implémentées. Timing donc parfaitement respecté.

Les slides du Lab, le code source de départ, les solutions et les tests unitaires sous Jasmine sont disponibles dans le repo Github angular-from-scratch.

L’objectif de ce billet est de vous accompagner dans la réalisation du Lab. Il se focalisera sur les mécanismes permettant de recoder le traditionnel « Hello World » d’Angular. Vous y trouverez donc une version édulcorée du code du Lab. Garde-fous contre les boucles infinies et comparaisons par valeur n’y seront pas abordés.

Afin de mieux comprendre où s’inscrivent les différentes étapes qui permettent de réimplémenter Angular, ce billet s’appuiera sur le schéma présenté par Olivier Huber au début du Lab :

Réimplementer Angular

Le code complet de ce billet est disponible dans jsfiddle.

Afin de pouvoir plus facilement se référer au code source d’Angular, le nom des méthodes et des objets utilisés dans ce Lab reprend volontairement ceux d’Angular.

 

Etape 1 : le $scope

Connu de tout développeur Angular, le scope est l’objet central du framework. Il permet de mettre à disposition des vues et des contrôleurs le modèle de données de l’application. Contrairement à d’autres frameworks comme Backbone, vous pouvez y placer des objets JavaScript standard (POJSO).

La particularité du scope est de pouvoir être observé. A l’instar du pattern Observer, des watchers peuvent s’enregistrer et être à l’écoute de tout changement sur le modèle, dans sa globalité ou sur une partie donnée. Les watchers sont tout simplement matérialisés par un tableau JavaScript :

function Scope() {
    this.$$watchers = [];
}

En général, Angular instancie pour vous le scope des différentes vues composant une page.
Pour les besoins du Lab, nous l’instancions manuellement :

var scope = new Scope();

Nous y définissons un objet labs comportant 2 propriétés :

scope.labs = {
    titre: "AngularJS from scratch",
    date: new Date()
}

La page HTML référence le titre objet de l’objet labs :

<h1 class="page-header" ng-bind="labs.titre">AngularJS from scratch</h1>
<input type="text" ng-model="labs.titre"/>

Nous reviendrons sur les directives ng-bind et ng-model lors des étapes 10 et 11.

Etape 2 : Scope.$watch

Comme vu dans l’étape précédente, le scope contient un tableau de watchers. Le but de cette étape est d’implémenter une fonction $watch permettant d’ajouter un watcher dans le tableau $$watchers du scope. Quiconque le souhaite pourra alors surveiller une donnée du scope.

Scope.prototype.$watch = function (watcherFn, listenerFn) {
    var watcher = {
        watcherFn: watcherFn,
        listenerFn: listenerFn,
        last: undefined
    };
    this.$$watchers.push(watcher);
}

La fonction $watch est ajoutée dans le prototype du Scope. Toute instance de Scope hérite ainsi de cette fonction. La ligne this.$$watchers.push(watcher); ne pose aucune difficulté.

Un watcher est caractérisé par 3 éléments :

  1. une fonction watcherFn indiquant quelle donnée du modèle l’appelant souhaite observer,
  2. une fonction de rappel listenerFn appelée lorsqu’un changement sera détecté,
  3. une variable interne last permettant de sauvegarder la précédente valeur du modèle et de réaliser le dirty checking.

Voici un exemple d’appel à la fonction $watch :

scope.$watch(function (scope) {
    return scope.labs.titre;
}, function (newValue, oldValue, scope) {
    console.log("La titre a changé de", oldValue, "à", newValue);
});

En pratique, un développeur Angular fait rarement appel explicitement à cette méthode.

 

Etapes 3 et 5 : Scope.$digest et digest loop

La fonction $digest est au cœur d’Angular. Sur le schéma ci-dessus, elle représente la digest loop. Comme son nom l’indique, son algorithme principal consiste à boucler sur le tableau de watchers jusqu’à ce que tous les évènements aient été traités. Par évènement, on entend un changement dans le modèle.

Voici un exemple d’implémentation :

Scope.prototype.$digest = function () {
    var dirty;
    do {
        dirty = false;
        _.each(this.$$watchers, function (watcher) {
            var newValue = watcher.watcherFn(this);
            if (watcher.last !== newValue) {
                watcher.listenerFn(newValue, watcher.last, this);
                watcher.last = newValue;
                dirty = true;
            }
        }.bind(this));
    } while (dirty);
}

Quelques explications peuvent être nécessaires à la compréhension de ce code :

  • l’itération sur le tableau $$watchers est réalisée par la méthode each d’Underscore,
  • la méthode watcherFn accepte comme argument le scope à observer. Ici, un this est passé en paramètre. Sans l’utilisation du bind(this), ce serait le this de l’inner fonction qui aurait été  passé à watcherFn et non le scope sur lequel la méthode $digest est appelée. bind(this) est une technique native JavaScript. Elle permet de forcer le this. Une technique plus répandue est l’utilisation d’un var self=this; avant la déclaration de l’inner fonction. Underscore aurait également pu être utilisé pour gérer cette problématique récurrente en JavaScript,
  • lorsqu’un changement est détecté, la fonction de rappel listenerFn est appelée avec la nouvelle valeur, l’ancienne valeur et le scope.

A chaque fois qu’un $digest est appelé, la fonction watcherFn de tous les watchers est appelée. Cela a un coût. Et c’est pourquoi les auteurs d’Angular encouragent à garder cette fonction la plus légère possible. Appels réseaux et algorithmes complexes y sont à proscrire.

Lors du Lab, 3 améliorations ont été ajoutées :

  1. un premier garde-fou permettant d’éviter un appel infini en levant une erreur après 10 itérations,
  2. un second garde-fou permettant d’éviter des appels récursifs à la méthode $digest (étape 7),
  3. la possibilité d’effectuer des comparaisons par valeur et non pas uniquement par référence. La comparaison de tableaux ou de grappes d’objets devient alors possible (étape 6).

Etape 4 : Scope.$apply

La fonction $apply exécute une expression passée en argument puis lance quoi qu’il arrive un $digest :

Scope.prototype.$apply = function (exprFn) {
    try {
        exprFn();
    } finally {
        this.$digest();
    }
}

Cette méthode est appelée en interne par Angular lorsqu’il a besoin de binder une donnée, par exemple lors de l’utilisation de la directive ng-bind dans les templates. Tous les composants Angular y font appel.

En dehors d’un contexte Angular, cette méthode doit explicitement être  appelée par le développeur. C’est typiquement le cas depuis une callback jQuery.

L’étape 5 ayant été traitée en même temps que l’étape 3 et les étapes 6 et 7 étant facultatives pour l’objectif fixé initialement, nous enchaînons directement à l’étape 8.

Etape 8 : place aux directives

Dans le fragment HTML présenté au début du billet, 2 directives viennent enrichir le HTML sous forme d’attributs : ng-model et ng-bind. Dans Angular, une directive n’est rien d’autre qu’une fonction ou un objet ayant des propriétés bien définies. Pour les besoins du Lab, nous resterons sur le cas simple : la fonction. L’objet $$directives doit permettre d’enregistrer les fonctions associées à ces directives. Pour rappel, un objet JavaScript peut être utilisé de la même manière qu’un tableau associatif (une Map en Java) : à partir de la clé (chaîne ‘ng-bind’) on récupère la valeur (fonction ng-bind).

La fonction $directive permet quant à elle d’ajouter une directive et de lire une directive depuis l’objet $$directives.

var $$directives = {};
var $directive = function (name, directiveFn) {
    if (directiveFn) {
        $$directives[name] = directiveFn;
    }
    return $$directives[name];
}

La fonction $directive fait à la fois getter et setter pour les $$directives. L’implémentation utilise une technique répandue en JavaScript : lorsque seul le nom d’une directive est passé en paramètre, la fonction agit comme un getter. Lorsque le nom et le code d’une directive sont passés en paramètre, la fonction enregistre la fonction avant de la retourner.

A noter que les développeurs Angular ne manipulent jamais directement ces 2 objets. Ils sont utilisés par le moteur d’injection de dépendance d’Angular.

Etape 9 : $compile le DOM

Cette étape consiste à écrire la fonction $compile chargée de parcourir récursivement les éléments du DOM. Les attributs de chaque élément sont également parcourus. Lorsqu’un attribut correspond au nom d’une directive, la fonction implémentant la directive est exécutée.
Le code est compréhensif :

var $compile = function(element, scope) {
    _.each(element.children, function (child) {
       $compile(child, scope);
    });
    _.each(element.attributes, function(attribute) {
        var directiveFn = $directive(attribute.name);
        if (directiveFn) {
            directiveFn(scope, element, element.attributes);
        }
    });
}

Deux remarques à propos du code :

  1. contrairement à ce que l’on pouvait s’attendre, tous les attributs de l’élément sur lequel est apposée la directive sont passés en paramètre de la fonction directiveFn,
  2. la récursion sur les éléments enfants est lancée avant le parcours des attributs de l’élément. Angular offre le choix avec les propriétés prelink et postlink. De manière générale, postlink est à privilégier.

Pour demander à notre framework maison de parcourir l’intégralité du DOM, une unique ligne de code est nécessaire :

$compile(document.body, scope);

A présent que le framework sait découvrir des directives dans le DOM et appeler la fonction correspondante, il est temps d’implémenter une première directive.

Etape 10 : ng-bind

La directive ng-bind se met à l’écoute sur la donnée pointée par la valeur de l’attribut ng-bind. Lors d’un changement de valeur, l’élément du DOM (dans notre exemple <h1>) est modifié avec la nouvelle valeur.

$directive('ng-bind', function (scope, element, attributes) {
    scope.$watch(function(scope) {
        return eval('scope.' + attributes['ng-bind'].value);    // 'scope. labs.titre'
    }, function(newValue) {
        element.innerHTML = newValue;
    });
});

Bien que controversée, l’utilisation de la fonction eval simplifie ici le code. Au cours du Lab, Matthieu nous a donné son équivalent fonctionnel. Basé sur les fonctions split et reduce, le code devient illisible pour les développeurs ne pratiquant pas ce paradigme.

Etape 11 : ng-model

ng-model est une directive gérant le data-binding bidirectionnel. Appliquée à la balise <input/>, elle permet d’y afficher le contenu du modèle même lorsque celui-ci change et de mettre à jour le modèle lorsque l’utilisateur saisit des données dans le champ de saisie.

S’agissant d’une version ++ de la directive ng-bind, le début de leur implémentation correspondant au premier sens de binding se ressemble :

$directive('ng-model', function(scope, element, attributes) {
    scope.$watch(function() {
        return eval('scope.' + attributes['ng-model'].value);
    }, function(newValue) {
        element.value = newValue;
    });
    element.addEventListener('keyup', function() {
        scope.$apply(function() {
            eval('scope.' + attributes['ng-model'].value + ' = \"' + element.value + '\"');
        });
    });
});

La directive ng-model ajoute un listener d’évènements JavaScript. Lorsque l’évènement ‘keyup’ survient, le modèle est mis à jour à l’intérieur de la fonction $apply. Cette dernière déclenche la digest loop qui notifie la balise ng-bind. C’est par ce mécanisme que lorsque l’utilisateur saisit du texte dans l’input, le titre <h1> est mis à jour en conséquence.

La fonction eval est là encore utilisée. Angular n’y fait pas appel car il possède son propre parseur.

 

Hello World

Une fois ces 2 directives enregistrées, un changement du titre du labs met simultanément à jour le titre <h1> et le champs de saisie <input> :

scope.$apply(function () {
    scope.labs.titre = "Hello World";
})

Hello World

Conclusion

92 lignes de JavaScript auront été nécessaires pour ré-implémenter une version minimaliste du cœur d’AngularJS. Le code fonctionne sous IE 11, Firefox 28 et Chrome 34. Underscore aura permis de gagner en clarté ainsi que quelques lignes de code.

En l’espace d’un an, Angular n’est plus la techno exotique qu’elle était. Cette technologie a été retenue par nombre de nos clients, dont des grands comptes. Reste à mesurer l’impact de l’arrêt du support des anciens navigateurs tels qu’IE 8 à partir de la version 1.3 d’Angular.

Inscription newsletter

Ne manquez plus nos derniers articles !