AMD loader pour un code JavaScript organisé et performant

Image non disponible

L'écosystème JavaScript grandit. JavaScript côté serveur 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 consorts pour du MVC, raphael.js et ses amis pour les graphismes…). Bref JavaScript devient incontournable et peu de pages Web sauraient s'en affranchir. Revers de la médaille, souvent mal maîtrisé 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 kilooctets 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.

Cet article a été publié avec l'aimable autorisation de Ippon technologies et d'Emmanuel REMY.

Commentez Donner une note à l'article (5)

Article lu   fois.

L'auteur

Profil ProSite personnel

Liens sociaux

Viadeo Twitter Facebook Share on Google+   

I. AMD loader en deux mots : define, require

L'APIApplication Programming Interface 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.

 
Sélectionnez
//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, Classe, variable etc.
  return function() {
    //...
  }
});//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, Classe, 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 rappel (callback), laquelle renverra la « valeur » (au sens large) du module. À 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 chemins possibles pour trouver les modules (en très gros des liens vers les ressources…) :

 
Sélectionnez
//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
});//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 bibliothèque ou d'un framework si on n'en utilise qu'une fraction ? Avec AMD, on gagne en dynamique et 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) :

 
Sélectionnez
<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><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 bibliothèques 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 ?

II. Les loaders AMD existants

Il existe plusieurs implémentations, chacune pouvant prendre en charge complètement les modules qui respectent l'API. La plus connue est RequireJS, puis viennent Curl, Backdraft… 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. À noter que nombre de loaders AMD proposent généralement une 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 chaînages 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 dépôt Github ou dans l'archive jointe.

III. Premier exemple

À 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ères :

 
Sélectionnez
//module client/world
define("world");//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 :

 
Sélectionnez
//module client/hello
//d'abord le module world est chargé, puis sa valeur renvoyée
//est passée comme paramètre txt
define(['./world'], function (txt) {
    return "hello " + txt;
});//module client/hello
//d'abord le module world est chargé, puis 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…

 
Sélectionnez
//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);
         }
    };
});//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.

 
Sélectionnez
<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><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 rappel 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.

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

IV. 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'Claude Leloup 2013-10-07T19:45:04en français ? : chargement d'une bibliothèque JavaScript non mise au format de module AMD ; le fichier est chargé en asynchrone, exécuté, et si la bibliothèque 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 :
 
Sélectionnez
var Mustache =  {}; //ai simplifié cette ligne, variable exportée
(function (exports) {
    exports.name = "mustache.js";
    exports.render = function(...) {...}
    ...
})(Mustache);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 :
     
    Sélectionnez
    curl(
        ['client/hello',
         'js!utils/mustache.js!order!exports=Mustache'
        ],
        function (hello, Mustache /*reçoit la variable exportée */) {
            ...
        }
     );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 IEInternet Explorer, etc.).
     
    Sélectionnez
    curl(
        ['client/hello',
         'css!css/widget.css'
        ],
        function (hello) {
            ...
        }
     );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.
     
    Sélectionnez
    curl(
        ['client/hello',
         'text!template/widget.html'
        ],
        function (hello, txtWidget ) {
            ...
        }
     );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

V. 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 IEInternet Explorer 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), une bibliothèque 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 Twitter 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 tout de suite le problème de la console : il n'existe pas de console sous IEInternet Explorer, on crée donc un module qui crée la console du pauvre (un simple alert).

 
Sélectionnez
//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;
});//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

 
Sélectionnez
<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><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, bien sûr…), donc aucune raison de charger la bibliothèque. En revanche nous déclarons ligne 7 que tout module référencé par « jquery » pointe vers le CDNContent Delivery Network Google, permettant de charger/référencer la bibliothèque. 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 tableau 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 :

Image non disponible

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

 
Sélectionnez
{{#tweets}}
    <div class="{{baseClass}}">
        <h3>{{name}}</h3>
        <img class="avatar" src="{{avatar}}" >
        <p>{{&text}}</p>
        <p class="date">{{date}}</p>
    </div>
{{/tweets}}{{#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 :

 
Sélectionnez
/* twitterWidget est la class de base du widget */
.twitterWidget .avatar {
    ...
}
.../* 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 JSONPJSON with Padding 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 classe qui pourra être instanciée, en bref une fonction. Nommons la classe Timeline.

 
Sélectionnez
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  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;
        }
);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  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, 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.

 
Sélectionnez
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();
    });
};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é :

Image non disponible

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

Image non disponible

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

Image non disponible

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é :

Image non disponible

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

Image non disponible

VI. 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.

VII. La multiplication de modules ne nuit-elle pas aux performances ?

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 dû à 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. Finalement, on obtient un seul fichier minimisé qui regroupe n modules (chaque module conserve son nommage initial incluant son package). C'est très efficace et cela s'inclut parfaitement au sein d'un POMProject Object Model Maven ou d'une usine d'Intégration Continue telle que Jenkins.

VIII. D'autres idées d'utilisation des modules ?

  • Lors d'une phase du développement, on peut facilement fournir un module mock :
     
    Sélectionnez
    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
             ...
           }
    );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.

IX. Les « petits plus » de Curl

Curl introduit quelques fonctionnalités intéressantes issues notamment des Deferred : chaînage de modules (next), fonctions de rappel en cas de succès ou d'erreur de chargement.

 
Sélectionnez
curl(['js!has.js'])
    //on est assuré du chargement préalable de la bibliothèque 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é
       }
    );curl(['js!has.js'])
    //on est assuré du chargement préalable de la bibliothèque 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é
       }
    );

X. Et maintenant ?

Le coût de la mise en œuvre 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. À 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 dépôt Github ou dans l'archive jointe.

XI. Remerciements

Cet article a été publié avec l'aimable autorisation de Ippon technologies et d'Emmanuel REMY. L'article original peut être vu sur le blog d'Ippon.

Nous tenons à remercier Didier Mouronval et Claude Leloup pour leur relecture attentive de cet article,

Vous avez aimé ce tutoriel ? Alors partagez-le en cliquant sur les boutons suivants : Viadeo Twitter Facebook Share on Google+   

  

Les sources présentées sur cette page sont libres de droits et vous pouvez les utiliser à votre convenance. Par contre, la page de présentation constitue une œuvre intellectuelle protégée par les droits d'auteur. Copyright © 2013 Emmanuel REMY. Aucune reproduction, même partielle, ne peut être faite de ce site et de l'ensemble de son contenu : textes, documents, images, etc. sans l'autorisation expresse de l'auteur. Sinon vous encourez selon la loi jusqu'à trois ans de prison et jusqu'à 300 000 € de dommages et intérêts.