AMD loader pour un code Javascript organisé et performant

Javascript JungleL’écosystème Javascript grandit. Javascript côté server avec Node.js, Javascript côté client avec des frameworks de plus en plus élaborés (jQuery, Dojo, Mootools…) et des projets associés pour répondre à nos problématiques récurrentes (backbone.js et consors pour du MVC, raphael.js et ses amis pour les graphismes etc…). Bref Javascript devient incontournable et peu de pages web sauraient s’en affranchir. Revers de la médaille, souvent mal maitrisé et mal utilisé dans son contexte de page web il s’attire les foudres de nombreux développeurs. Qui n’a jamais pesté contre ses dizaines de Ko de scripts qui sont emmenés avec une page web, ralentissant d’autant son téléchargement, obligeant à déplacer les balises <script> en fin de page ? Qui n’a jamais perdu quelques cheveux lors de la gestion des dépendances entre les scripts et leur ordre de chargement ? Qui n’a jamais transpiré quand on lui a annoncé que sur une même page devront cohabiter jQuery, Mootools et Prototype ? Ou pire, deux versions différentes de jQuery ? Dans cet article nous allons découvrir une API en plein essor, solution globale à tous ces soucis, indispensable mais trop peu utilisée quotidiennement par nos soins: Asynchronous Module Definition (AMD loader). Ou comment définir des modules javascript (techniquement chaque module est un fichier .js) qui pourront être ensuite chargés en parallèle en mode asynchrone, dans leur propre scope – donc sans conflit, en même temps que leurs dépendances, offrant ainsi une scalabilité souvent décriée.

AMD loader en deux mots: define, require

L’API est constituée de deux fonctions: define qui définit un module en renvoyant une valeur ou une fonction, et require qui est similaire mais se contente d’effectuer un simple callback.

//Code écrit dans le fichier package1/moduleC.js
//définition ici du module package1/moduleC
//moduleC dépend de moduleA défini dans le package2, et de moduleB défini dans package1
define(["package2/moduleA", "./moduleB"], function(depA, depB) {
  //utilisation de depA et depB

// Renvoyer ce que le module définit: Objet, Class, variable etc...
  return function() {
    //...
  }
});

La notion de package (ou de namespace) correspond à celle en java: une arborescence. Dans l’exemple précédent, le loader AMD va donc charger en asynchrone les fichiers package2/moduleA.js et package1/moduleB.js , et une fois ces dépendances résolues les injectera sous la forme de paramètres dans la fonction de callback, laquelle renverra la “valeur” (au sens large) du module. A noter qu’on peut aussi donner un nom optionnel au module (dans ce cas c’est le premier paramètre de define) mais que cette pratique n’est pas recommandée pour des raisons d’unicité.

Quand on souhaite ensuite utiliser un ou plusieurs modules pour mener des actions (par exemple au sein d’une page web), on utilise alors la fonction require et un objet de configuration qui indique notamment les paths possibles pour trouver les modules (en très gros des liens vers les ressources…):

//Objet de config, exemple du loader Curl
curl = {
    paths: {
        curl: '../src/curl/',
        package1: '../projet/package1'
    },
    locale: 'fr'  /* sert pour les aspects i18n, voir plus bas */
};
//on "requiert" le module package1/moduleC, qui sera chargé depuis
//../projet/package1/moduleC.js, ce qui va induire l'exécution
//du code précédent donc le chargement des dépendances etc...
require(["package1/moduleC"], function(depC) {

   //utilisation ici de depC
});

On saisit rapidement les avantages d’une telle architecture modulaire: code correctement organisé,  assurance de voir les dépendances résolues, chargement facilité des seuls modules concernés (chaque module n’est évidemment chargé qu’une seule fois) , dynamiquement, voire dans l’ordre souhaité. En effet, pourquoi charger l’ensemble d’une librairie ou d’un framework si on n’en utilise qu’une fraction ?  Avec AMD on gagne en dynamique, on réduit les temps de chargement d’un facteur pouvant aller jusqu’à 10…

Pas encore complètement convaincu ? Cela va venir. Prenons par exemple le footer d’une page web: il peut correspondre à cela (exemple d’un snapshot de l’excellent Tatami à un moment de son développement !):

<script src="http://code.jquery.com/jquery-1.7.2.min.js"></script>
..
<script src="/assets/js/bootstrap/2.0.2/bootstrap-dropdown.js"></script>
...
<script src="/assets/js/raphael/2.1.0/raphael-min.js"></script>
<script src="/assets/js/mustache/mustache.js"></script>

<script src="/assets/js/tatami/constants.js"></script>
<script src="/assets/js/tatami/standard/tatami.utils.js"></script>
<script src="/assets/js/tatami/standard/tatami.tweets.js"></script>
<script src="/assets/js/tatami/standard/tatami.users.js"></script>
<script src="/assets/js/tatami/standard/tatami.ajax.js"></script>
<script src="/assets/js/tatami/standard/tatami.js"></script>

Notons déjà que tous ces scripts se chargent en mode synchrone (on cumule les temps de chargement). Ensuite, comme au sein de la page web Tatami certaines fonctionnalités peuvent être gérées dynamiquement, sans rechargement de la page (graphismes pour les statistiques etc..), il est donc nécessaire de charger aussi les librairies de raphael et mustache. On décèle ici le hic: ces dépendances sont  chargées inutilement si l’utilisateur ne demande pas à utiliser les fonctionnalités associées.  En utilisant un loader AMD, non seulement tous ces scripts seraient chargés en  asynchrone  (donc chargements en parallèle), mais en plus on chargerait mustache et raphael uniquement en cas de besoin.

Convaincus ?

Les loaders AMD existants

Il existe plusieurs implémentations, chacune pouvant prendre en charge complètement les modules qui respectent l’API. Le plus connu est RequireJS, puis viennent CurlBackdraft, …  Cas particulier: s’il n’a pas échappé à certains que node.js porte aussi une notion de modules, sachez que celui-ci utilise une autre forme de loader, CommonJS (précurseur, initiateur de AMD) fonctionnant sur le même principe mais moins bien adapté au web.  A noter que nombre de loaders AMD proposent généralement un couche qui les rend compatibles avec node.js. Pour la suite nous utiliserons Curl, qui mérite d’être connu car il présente l’avantage de proposer en complément des chainages et des deferred. Pour autant, même si Curl expose la fonction require, ces extensions légères imposent de disposer d’une autre fonction pour qu’il n’y ait pas de confusion, ce sera donc la fonction curl. Retenons que curl = require.

Tous les exemples sont consultables dans ce repo Github.

Premier exemple

A l’aide de trois modules affichons un “Hello world” – pour commencer sagement.

Créons un premier module client/world dans le fichier client/world.js, qui ne fait que renvoyer une chaine de caractère:

//module client/world
define("world");

Créons maintenant un second module client/hello dans le fichier client/hello.js, qui dépend de client/world :

//module client/hello
//d'abord le module world est chargé, puis ensuite sa valeur renvoyée
//est passée comme paramètre txt
define(['./world'], function (txt) {
    return "hello " + txt;
});

Créons maintenant un module utils/string dans le fichier utils/string.js, qui fournit quelques fonctions de manipulation de String indispensables…

//module utils/string
//aucune dépendance pour ce module, on pourrait omettre
//le tableau vide en premier paramètre
define([], function() {
    //ce module retourne un objet directement exploitable
    return {
         capitalizeFirst : function (str) {
             return str.charAt(0).toUpperCase() + str.slice(1);
         },
         isEmpty : function(str) {
             return /^[\s\xa0]*$/.test(str);
         }
    };
});

Reste maintenant à créer une page web qui va afficher un “Hello world” après son chargement.

<html>
<head>
<script>
    //on configure curl (son répertoire de base pour les extensions et plugins
    //(voir plus loin)
    curl = {
        paths: {   curl: '../src/curl/' }
    };
</script>
<script src="../src/curl.js" type="text/javascript"></script>

<script type="text/javascript">

curl(
    [
     'utils/string',
     'client/hello',
     //on attend la fin de chargement du DOM pour exécuter la fonction,
     //ce module ne renvoie pas de valeur
     'domReady!'
    ],
    /**
    * fn: l'objet renvoyé par le module string
    * hello: la valeur (chaine de caractères) renvoyée par le module hello
    */
    function (fn, hello) {
         document.getElementById("welcome").innerHTML = "Hello world apparait ci-dessous: "
                                                        + "<br> "
                                                        + fn.capitalizeFirst(hello);
    }
 );
</script>
</head>
<body>
    <p id="welcome"></p>
</body>
</html>

Notons le module spécial préexistant, “domReady!“, qui s’assure que le DOM a bien fini d’être chargé. Notre fonction de callback sera donc appelée uniquement quand toutes les dépendances auront été résolues ET le DOM chargé (finalement domReady! est une dépendance comme une autre). Pas belle la vie ? Si nous avons un module utils/string qui grossit, on peut très facilement le segmenter en plusieurs modules (des sous-modules) et ne charger que ceux qui sont réellement nécessaires.

curl(
    [
     'utils/string/substitutions',
     'utils/string/regexp',
     'utils/string/display',
     'client/hello',
     'domReady!'
    ],
    function (substr, regexp, disp, hello) {
       ...
    }
 );

Les plugins sont prévus dans l’API

L’api prévoit la possibilité d’ajouter des plugins. Sincèrement c’est aussi là que la magie opère et offre de nombreuses possibilités pour construire proprement ses applications. L’utilisation d’un plugin se déclare ainsi: ‘nom_plugin!ressource_cible!options‘ . Les plugins “de fait”, fournis par tous les loaders:

  • js!librairie.js” : chargement d’une librarie javascript non mise au format de module AMD; le fichier est chargé en asynchrone, “exécuté”, et  si la librairie dispose d’une variable globale, alors elle peut être exportée comme valeur du module. Par exemple le moteur de template Mustache est écrit ainsi :
var Mustache =  {}; //ai simplifié cette ligne, variable exportée
(function (exports) {
    exports.name = "mustache.js";
    exports.render = function(...) {...}
    ...
})(Mustache);

Une fois Mustache copié dans un répertoire utils, nous pouvons l’utiliser de cette façon :

curl(
    ['client/hello',
     'js!utils/mustache.js!order!exports=Mustache'
    ],
    function (hello, Mustache /*reçoit la variable exportée */) {
        ...
    }
 );

Voir l’exemple

  • css!stylesheet.css“: chargement d’un module feuille de style (dépendance) injectée ensuite directement dans la page en cours. Avant d’injecter les styles, le plugin pourra les normaliser selon le navigateur cible (les hacks connus pour IE etc…).
curl(
    ['client/hello',
     'css!css/widget.css'
    ],
    function (hello) {
        ...
    }
 );

Voir l’exemple

  • text!template/widget.html“: chargement d’un module de texte  (dépendance) injecté ensuite sous la forme d’un paramètre String
curl(
    ['client/hello',
     'text!template/widget.html'
    ],
    function (hello, txtWidget ) {
        ...
    }
 );

Voir l’exemple

  • i18n!nls/application“: chargement d’un module i18n  (dépendance) injecté ensuite sous la forme d’un objet (clé, valeur) disposant des traductions pour la locale déclarée dans la configuration ou celle configurée dans le navigateur si elle existe.
  •  mais aussi d’autres loaders moins consensuels : json, cs (CoffeeScript), less, font, image etc…

Et si on mixait l’ensemble ?

Maintenant que nous avons vu le fonctionnement, reste à voir ce que cela donne concrètement dans le cadre d’un exemple, simple, mais relativement complet. Je vous propose de construire un widget de type Timeline Twitter qui affiche les n derniers tweets d’un compte Twitter.

Caractéristiques et contraintes imposées de la démo:

  • Code organisé en modules (avec la possibilité d’utiliser un module utils/console pour effectuer quelques sorties, tant sur IE que sur les autres navigateurs)
  • Le widget est autonome (template, css)
  • Le widget utilise jQuery, Mustache pour le système de templates (voir l’article de Ludovic CHAN WON IN sur le blog), une librairie javascript pour le formatage des dates
  • Le widget doit être chargé à la demande, pas avec la page de départ
  • une page web avec un formulaire (une seule zone de texte pour saisir le libellé du compte Tiwitter, et un bouton pour obtenir l’affichage du widget), initialement sans widget affiché

Pour vous faire une idée du résultat attendu, la démo est visible par ici.

Allez, hop c’est parti !

Réglons de suite le problème de la console: il n’existe pas de console sous IE, on crée donc un module qui crée la console du pauvre (un simple alert).

//module utils/console
define([], function () {
 // mock console pour IE
 if (!window.console) console = {};
 if (!('log' in console)) {
    console._msg = [];
    console.log = function (msg) {
        var _msg = this._msg;
        _msg.push([].join.call(arguments, ' '));
        clearTimeout(this._timeout);
        this._timeout = setTimeout(function () {
                          alert(_msg.join('\n'));
                        }, 100);
    };
 }

return window.console || console;
});

Préparons maintenant la page html twitter.html

<html>
<head>
<script>
    curl = {
      paths: {
         curl : '../src/curl/',
         jquery : 'http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min'
      }
    };
</script>
<script src="../src/curl.js" type="text/javascript"></script>

<script type="text/javascript">
    var start = new Date();
    //on s'assure du chargement du DOM et de la console
    curl(['utils/console', 'domReady!'],
           function (console) {
                console.log('Temps de chargement:', new Date() - start);
                document.getElementById("read").onclick = function() {
                    //ICI LE CODE POUR AFFICHER NOTRE WIDGET DANS LA DIV tweetsNode
                };
           }
    );

</script>
</head>
<body>
  <div>
     <label>Lire les 8 derniers tweets de @</label>
     <input type="text" id="user" value="ippontech" />
     <button id="read">Et hop !</button><br>
  </div>
  <div id="tweetsNode" style="margin-top:20px"></div>
</body>
</html>
Nous pouvons noter qu’à aucun moment nous ne faisons référence à notre widget, il ne sera donc pas chargé initialement. Ensuite, nous n’avons pas besoin de jQuery pour l’exécution de cette page (c’est un exemple, biensûr…), donc aucune raison de charger la librairie. En revanche nous déclarons ligne 7 que tout module référencé par “jquery” pointe vers le CDN Google, permettant de charger/référencer la librairie. Enfin, nous utilisons le module “domReady!” pour être certain de disposer de l’élément “read” quand on ajoute le onclick (ligne 19).
Reste maintenant à créer le widget. Dans notre architecture les widgets sont déposés dans un répertoire widget. On définit alors cette arborescence et les modules associés:
  • widget/twitter/timeline (.js), module principal du widget, c’est lui qui définit le widget qui sera instancié depuis la page web
  • widget/twitter/css/widget.css, “module” de feuille de style propre au widget
  • widget/twitter/template/template.html, ”module” de texte représentant le template Mustache

Commençons par le template Mustache qui prend en entrée un objet javascript comprenant notamment un tableaux de tweets, chaque tweet intégrant le nom du compte Twitter, son avatar, le texte et sa date; on souhaite obtenir pour chaque tweet ce style:

Le fichier widget/twitter/template/template.html:

{{#tweets}}
    <div class="{{baseClass}}">
        <h3>{{name}}</h3>
        <img class="avatar" src="{{avatar}}" >
        <p>{{&text}}</p>
        <p class="date">{{date}}</p>
    </div>
{{/tweets}}

Continuons par la feuille de style widget/twitter/css/widget.css:

/* twitterWidget est la class de base du widget */
.twitterWidget .avatar {
    ...
}
...

Nous disposons du rendu, reste à coder le widget. Techniquement il n’y a aucune difficulté c’est un simple JSONP exposé par Twitter (l’appel est en cross-domain) qui renvoie une liste de tweets. Nous filtrerons cette liste pour n’en conserver que les informations nécessaires au template…

Définissons notre module: nous devons donner les dépendances attendues et la valeur renvoyée doit être une class qui pourra être instanciée, en bref une fonction. Nommons la class Timeline.

define(['utils/console',
        'jquery',
        'js!utils/mustache.js!exports=Mustache',
        'text!./template/template.html',
        'css!./css/widget',
        //la class de formatage des dates: elle ajoute une fonction format
        //au prototype de Date
        'js!http://stevenlevithan.com/assets/misc/date.format'],
        function (console, $, mustache, txtTemplate) {
               //Notre class à renvoyer
               function Timeline(/* le nom du compte twitter */ twitterName,
                                /* le nombre de tweets à afficher */ nbTweets,
                                /* le container où afficher le widget */ container) {
                      this.twitterName = twitterName;
                      this.nbTweets = nbTweets;
                      this.container = container;
               }
              //La fonction de rendu de notre widget
              Timeline.prototype.render = function() {
                 $.getJSON("http://twitter.com/statuses/user_timeline.json?callback=?",
                           {
                              screen_name: this.twitterName,
                              count: this.nbTweets
                           },
                           (function(scope) {
                               return function(data) { scope._display(data); };
                           })(this)
                      );
               };
               //la fonction dédiée à l'affichage, "privée"
               Timeline.prototype._display = function(data) {
                        var tweets = $.map(data,function(tweet) {
                             tweet.text = tweet.text.replace(/* regexps en chaine
                               (omises) pour faire le ménage dans le texte reçu */);
                             return {'name': tweet.user.name,
                                     'avatar':tweet.user.profile_image_url,
                                     'text': tweet.text,
                                     'date': (new Date (tweet.created_at))
                                                  .format("dd/mm/yyyy HH:MM:ss")
                             };
                       });
                       //Un peu de travail pour Mustache...
                       var view = { baseClass: "twitterWidget", tweets : tweets };
                       var output = mustache.render(txtTemplate, view);
                       //on affiche le widget avec tous ses tweets
                       $(this.container).html(output);
                };

                //Notre class est maintenant définie, on la renvoie
                return Timeline;
        }
);

Enfin pour finir, il faut afficher notre widget dans la page html; n’oublions pas que le widget est chargé à la demande (une seule fois, la première), lors du clic sur le bouton.

...
document.getElementById("read").onclick = function() {
    //on charge notre widget (module). Il ne sera chargé que sur le premier clic
    curl(['widget/twitter/timeline'],function(TimelineTwitter /* la class renvoyée par
                                              le module */) {
         var user = document.getElementById("user").value;
         //On crée une instance du widget puis on l'affiche
         var tm = new TimelineTwitter(user, 8, document.getElementById("tweetsNode"));
         tm.render();
    });
};
...

(pour vous faire une idée du résultat la démo est visible par ici.)
Et… c’est terminé ! Tout est mis sous la forme de modules, correctement organisé et optimisé pour le chargement. Vérifions…1) Arrivée sur la page: pas de widget affiché

2) Les modules chargés: ni jQuery chargé, ni widget, le strict minimum

 

3) On lance la recherche (donc sans recharger la page)

4) Les modules chargés en complément des précédents: jQuery et les autres dépendances déclarées par le widget, plus la timeline JSON et l’avatar du compte Twitter concerné.

5) Si on effectue une nouvelle recherche (bien sûr toujours sans recharger la page), on constate bien que seule la nouvelle timeline et l’avatar associé au compte twitter sont chargés

Si c’est si bien, pourquoi tous les projets ne sont ils pas compatibles AMD ?

La communauté javascript y vient progressivement. jQuery 1.7 est compatible AMD, Mootools 2 l’est, Dojo aussi, etc… tirant vers le haut les projets liés. Si on prend par exemple le cas de backbone.js (MVC basé sur jQuery), il est quasi indispensable d’avoir cette notion de modules pour associer des widgets MVC. Le projet backbone-aura.js s’en occupe.

La multiplication de modules ne nuit elle pas aux performance ?

C’est un souci à prendre en considération. Effectivement chaque module étant normalement chargé individuellement on peut craindre une dégradation des performances si le nombre de modules croît. Des dizaines, voire des centaines de modules induiront un temps de latence important du à l’établissement de la connexion HTTP pour chacun. Heureusement les outils à notre disposition évacuent élégamment ce souci. Ainsi les loaders proposent des outils de builds qui assemblent plusieurs modules en un seul fichier qui passera ensuite dans un compilateur (aussi appelé shrinker ou compressor) tel que Google Closure Compiler. Au final on obtient un seul fichier minimisé qui regroupe n modules (chaque module conserve son nommage initial incluant son package). C’est très très efficace et cela s’inclut parfaitement au sein d’un POM Maven ou  d’une usine d’Intégration Continue telle que Jenkins.

D’autres idées d’utilisation des modules ?

  • Lors d’une phase du développement, on peut facilement fournir un module mock:
    curl = {
      paths: {
         curl   : '../src/curl/',
         jquery : 'http://ajax.googleapis.com/ajax/libs/jquery/1.7.1/jquery.min',
         //on redirige le package widget vers ce path qui héberge des mocks
         widget : '../mock/widget/'
      }
    };

    curl(['widget/timeline', 'domReady!'],
           function (TimeLine) {
             //Timeline est ici le mock
             ...
           }
    );
  • Un module peut être remplacé sans délai par un autre module, sans impact pour les développeurs,  si les fonctions exportées portent le même nom
  • Création simplifiée de tests unitaires
  • Création d’un ensemble de ressources utilisables dans tous les projets

Les “petits plus” de Curl

Curl introduit quelques fonctionnalités intéressantes issues notamment des Deferred: chainage de modules (next), fonctions de callback en cas de succès ou d’erreur de chargement.

 curl(['js!has.js'])
     //on est assuré du chargement préalable de la librairie has.js
     //avant d'enchainer...
    .next(['model/client', 'utils/string', 'utils/console'],
          function (client, fn, console) {
        // on prépare les données...
          }
    )
    //... on attend maintenant que le DOM soit prêt et le composant timeline chargé
    .next(['widget/twitter/timeline', 'domReady!'])
    /* on va détecter si une erreur de chargement de module a eu lieu, auquel cas
       on pourra intervenir */
    .then(
        function (Timeline) {
            // tout est ok, Timeline chargée et DOM prêt
        },
        function (ex) {
            // en cas de problème, code exécuté
        }
     );

Et maintenant ?

Le coût de la mise en oeuvre de cette forme de développement est ridicule par rapport à la qualité et aux performances obtenues. Il y a fort à parier que les loaders AMD vont se généraliser dans les projets et que la modularité et la gestion des conflits entre les frameworks vont rapidement progresser, permettant aisément de mélanger des composants techniques ou UI hétérogènes. A titre d’exemple le composant UI dGrid prévu initialement pour le framework Dojo est complètement exploitable en environnement jQuery.

Les exemples sont consultables dans ce repo Github.

TwitterFacebookGoogle+LinkedIn
  • loic

    arf! il y a un problème javascript (probablement un changement d’url twitter) sur la démo github indiquée en lien

    • http://twitter.com/fabianpiau Fabian Piau

      Non pas de soucis, je viens de tester :)

      • loic

        ah bon ? moi j’ai ça dans la console javascript de chrome : jQuery17108291547717526555_1340350049079({“error”:”Rate limit exceeded. Clients may not make more than 150 requests per hour.”,”request”:”/statuses/user_timeline.json?callback=jQuery17108291547717526555_1340350049079&screen_name=loictalbot&count=8&_=1340350049100″})

        • loic

          désolé c’est parce que j’avais ma page twitter ouverte dans un autre tab apparemment. tout fonctionne ! merci