L’é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 .. ...
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 raphaeluniquement 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 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. 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 /[1]*$/.test(str); } }; });
Reste maintenant à créer une page web qui va afficher un “Hello world” après son chargement.
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 */) { ... } );
- “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) { ... } );
curl( ['client/hello', 'text!template/widget.html' ], function (hello, txtWidget ) { ... } );
- “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
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).
- 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}}
{{name}}
{{&text}}
{{date}}
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é
- Les modules chargés: ni jQuery chargé, ni widget, le strict minimum
- On lance la recherche (donc sans recharger la page)
- 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é.
- 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.
\s\xa0 ↩︎