1. Introduction
Imaginons une page web contenant un grand nombre d'informations, par exemple une longue liste de produits sur un site de VPC. À côté de chaque produit se trouvent des boutons permettant diverses opérations : ajouter au panier, visualisation détaillée, etc. Rapidement, on se retrouve avec un site pourvu de nombreuses fonctionnalités, et donc une bibliothèque JavaScript assez riche, souvent basée sur un framework efficace mais volumineux tel que jQuery. Le problème est alors le suivant : tant que le navigateur n'a pas terminé de charger et d'interpréter l'intégralité du JavaScript nécessaire aux fonctionnalités de la page, l'utilisateur ne peut pas profiter de son contenu « brut », qui est pourtant ce qui l'intéresse.
Mettons en œuvre ces « mauvaises pratiques » sur un exemple minimaliste, peu contraignant en termes de vitesse d'exécution de la page, mais néanmoins assez révélateur. Pour cela, téléchargeons la dernière version du framework jQuery (version 1.8.2 au moment de la rédaction de cet article) sur le site de jQuery [1], ce qui nous donne le fichier jquery-1.8.2.js, et créons deux autres fichiers : un fichier HTML (example00.html) et un fichier JavaScript (example00.js).
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
<head>
<title>Un exemple de pages a problemes</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<script type="text/javascript" src="./jquery-1.8.2.js"></script>
<script type="text/javascript" src="./example00.js"></script>
</head>
<body>
<div id="divLoading">Chargement en cours...</div>
</body>
</html>
(function (window) {
function ready (msg) {
if (typeof (msg) == "string") {
$("#divLoading").html (msg);
}
}
$(window.document).ready (function () {
ready ("Document "+ window.location.href +" pret !");
});
}) (window);
Notons l'emploi d'une fonction anonyme dans le fichier JavaScript, qui permet de définir une sorte de namespace où l'on peut définir des variables internes sans crainte de collisions (elle ne seront pas exportées dans le reste du code JavaScript de la page). La fonction ready, quant à elle, n'a pas été inlinée et ce dans un but purement pédagogique, comme nous le verrons plus tard. Un outil d'analyse du Web quelconque (Google PageSpeed [2], Yahoo! YSlow [3], etc.) permet alors de dresser le diagnostic suivant : le temps de chargement moyen du document est supérieur à 450 ms sur notre machine d'essai. Bien trop pour un si petit exemple !
Procédons à une analyse plus fine. Parmi ces 450 ms, il faut environ 150 ms pour charger et afficher le HTML de la page, environ 120 ms pour envoyer les requêtes HTTP GET vers les deux fichiers JavaScript et les recevoir, et plus environ 180 ms pour les interpréter. La partie compressible se situe donc au niveau de ces deux derniers intervalles de temps : il est possible d'agir sur les délais de réception et d'interprétation.
2. Minification des fichiers JavaScript
Jetons un coup d'œil au fichier jquery-1.8.2.js. Il comprend beaucoup d'espaces, de lignes vides, de commentaires, des noms de variables, de fonctions et d'arguments explicites... Autant de choses utiles au développement et au debugging, mais totalement inutiles à l'interpréteur JavaScript du navigateur client. De nos jours, il existe plusieurs outils permettant de « minifier » des fichiers JavaScript (et même CSS), de façon à les réduire au strict minimum nécessaire à leur interprétation. Parmi ces outils, on peut notamment citer Google Closure [4], Yahoo! YUI Compressor [5], ou encore UglifyJS [6]. Chacun possède ses avantages et ses défauts, ainsi qu'un nombre de fonctionnalités propres. Dans cet article, nous nous concentrerons sur Closure, qui permet de nombreuses optimisations allant au-delà de la minification, telles que l'inlining de fonctions, par exemple.
Commençons par minifier jQuery. En l'occurrence, les développeurs s'en sont chargés pour nous, et proposent une version déjà minifiée (par UglifyJS), pour laquelle on passe de 266 882 à 93 436 caractères, soit un gain de près de 65% ! Récupérons-la sur le site web de jQuery, et enregistrons-la dans le fichier jquery-1.8.2.min.js.
Allons maintenant plus loin en compressant également notre fichier example00.js, bien que sa petite taille (peu réaliste néanmoins) fera qu'on n'y gagnera pas grand chose.
$ closure --charset UTF-8 --warning_level QUIET --compilation_level ADVANCED_OPTIMIZATIONS --externs jquery-1.8.2.js example00.js
var a=window;$(a.document).ready(function(){var b="Document "+a.location.href+" pret !";"string"==typeof b&&$("#divLoading").html(b)});
Nous avons utilisé ici plusieurs options de compilation. La première, charset, permet de définir l'encodage d'entrée et de sortie, ici UTF-8. warning_level permet d'éviter quelques warnings liés au code de jQuery que Closure ne semble pas particulièrement apprécier... Mais il faut s'assurer que son propre code est « propre » avant de s'en servir. compilation_level permet de choisir le niveau de compilation parmi trois niveaux : WHITESPACE_ONLY (on se contente de supprimer les commentaires, les espaces inutiles, et les retours à la ligne), SIMPLE_OPTIMIZATIONS (on renomme aussi le nom des fonctions, variables et arguments de façon à les raccourcir), et ADVANCED_OPTIMIZATIONS (qui permet des optimisations plus agressives, telles que l'inlining). Et enfin, externs permet de spécifier sur quels autres fichiers JavaScript s'appuie notre fichier, en l'occurrence jQuery, de manière à ne pas minifier les noms des fonctions appelées de ce dernier, comme la fonction $.ready.
Comme on peut le constater, Closure a grandement simplifié notre fichier, et a même inliné la fonction ready. Le gain est assez faible sur un aussi petit fichier, mais on peut imaginer le gain en taille et en vitesse d'exécution sur un fichier de plusieurs milliers de lignes ! Voyons maintenant l'effet sur le temps de chargement de notre premier exemple. Le temps moyen de chargement tombe à environ 380 ms, le gain s'effectuant sur le temps de chargement (environ 70 ms) et le temps d'interprétation (environ 160 ms) des deux fichiers JavaScript.
Ces résultats sont très encourageants, et seraient d'autant plus importants que le JavaScript employé serait complexe. La même logique peut d'ailleurs s'appliquer aux fichiers CSS en utilisant un autre compilateur (Closure ne sait pas le compiler), tel que Yahoo! YUI Compressor ou CSSO [7].
Enfin, terminons cette section par deux astuces. La première : que faire lorsque l'on veut que la fonction ready soit exportée, de manière à pouvoir l'utiliser dans le code HTML, par exemple avec un <a href="javascript: void (0);" onclick="ready ();" /> ? Il existe une astuce pour cela, qui consiste à modifier notre fichier exemple00.js de la manière suivante :
(function (window) {
function ready (msg) {
if (typeof (msg) == "string") {
$("#divLoading").html (msg);
}
}
/* Export symbol. */
window ["ready"] = ready;
$(window.document).ready (function () {
ready ("Document "+ window.location.href +" pret !");
});
}) (window);
Le résultat produit par compilation est alors le suivant :
var a=window;function b(c){"string"==typeof c&&$("#divLoading").html(c)}a.ready=b;$(a.document).ready(function(){b("Document "+a.location.href+" pret !")});
Imaginons maintenant que l'on doive charger plusieurs fichiers JavaScript, et qu'on les compile tous. Il y a alors un risque de collision très élevé entre ces différents fichiers, du fait des variables globales exportées avec des noms très courts, tels que a, b... Il existe cependant un moyen de « forcer » à ne pas inliner la fonction anonyme qui englobe tout notre fichier, grâce à l'option de compilation output_wrapper :
$ closure --charset UTF-8 --warning_level QUIET --compilation_level ADVANCED_OPTIMIZATIONS --externs jquery-1.8.2.js --output_wrapper '(function(window){%output%})(window);' example00.js 2>/dev/null
(function(window){var a=window;$(a.document).ready(function(){var b="Document "+a.location.href+" pret !";"string"==typeof b&&$("#divLoading").html(b)});})(window);
Poussons même le bouchon un peu plus loin, en compilant le résultat obtenu au niveau de compilation SIMPLE_OPTIMIZATIONS, qui ne va pas inliner la fonction anonyme mais qui va renommer son argument et supprimer la variable intermédiaire a inutile :
(function(a){$(a.document).ready(function(){var b="Document "+a.location.href+" pret !";"string"==typeof b&&$("#divLoading").html(b)})})(window);
Avant de passer à la suite, sauvegardons ce résultat dans un fichier example02.js.
3. Mise en cache
La plupart de ces fichiers JavaScript sont statiques, c'est-à-dire qu'ils ne sont pas amenés à changer, sauf cas exceptionnels (maintenance, développements, etc.). Cela peut donc rendre de grands services de confier le soin au navigateur d'en sauvegarder une copie sur le disque dur, afin de s'épargner le temps d'émettre une requête HTTP GET et de récupérer les fichiers depuis Internet.
Une manière simple de procéder est d'utiliser le module headers du serveur web Apache, que l'on active tout simplement en exécutant (en root) :
# a2enmod headers
Il suffit alors de créer un fichier .htaccess à la racine du site web, comprenant :
<ifModule mod_headers.c>
<filesMatch "\.css$">
Header set Cache-Control "max-age=604800, public"
Header set Content-type "text/css; charset=utf-8"
</filesMatch>
<filesMatch "\.js$">
Header set Cache-Control "max-age=604800, public"
Header set Content-type "application/javascript; charset=utf-8"
</filesMatch>
</ifModule>
Ici, le délai d'expiration (max-age) est mis à une semaine (604800 secondes), et peut être ajusté selon les besoins. Cela signifie que, lors d'une première visite, le navigateur client va récupérer les fichiers CSS (.css) et JavaScript (.js) depuis l'Internet, et va les stocker temporairement sur le disque dur pendant une semaine. Lors de tout accès futur dans ce délai, les fichiers en question seront récupérés depuis le disque dur de l'ordinateur client plutôt que depuis l'Internet, écourtant considérablement les délais de chargement.
Immédiatement, le temps moyen de chargement de la page descend à 320 ms, le gain s'étant effectué sur le temps de chargement des fichiers JavaScript.
4. Exécution différée du JavaScript
Revenons à l'exemple du site de VPC mentionné en introduction. Comme nous l'avons vu, il n'est pas nécessaire d'attendre que tous les fichiers JavaScript soient chargés et interprétés pour commencer à naviguer la page. Il est donc utile de différer l'exécution du JavaScript après l'interprétation du HTML.
Il nous faudra alors composer avec deux choses : d'une part, la capacité d'un navigateur à charger plusieurs fichiers JavaScript en parallèle et, d'autre part, les dépendances qu'il peut y avoir entre ces fichiers. Dans notre cas, notre fichier example_02.js dépend entièrement du fichier jquery-1.8.2.min.js et ne peut être exécuté sans que ce dernier ait été interprété. Il convient donc de concevoir un système de dépendances entre les scripts.
Considérons l'exemple de fichier HTML suivant, qui inclut un tel système :
<?xml version="1.0" encoding="utf-8"?>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" xml:lang="fr" lang="fr">
<head>
<title>Un exemple de pages a problemes</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<script type="text/javascript">
/* <![CDATA[ */
(function (window) {
var scripts = [["jquery-1.8.2.min.js"],["example02.js"]];
function load_scripts (scripts, callback) {
var count = scripts.length;
for (var j = 0; j < count; j++) {
if (typeof (scripts [j]) == "string") {
var url = scripts [j];
var script = window.document.createElement ("script")
script.type = "text/javascript";
if (script.readyState) {
script.onreadystatechange = function () {
if (script.readyState == "loaded" || script.readyState == "complete") {
script.onreadystatechange = null;
if (j == count) {
callback ();
}
}
};
} else {
script.onload = function () {
if (j == count) {
callback ();
}
};
}
script.src = url;
window.document.getElementsByTagName ("head")[0].appendChild (script);
}
}
}
function parse_scripts (scripts, i) {
if (typeof (scripts [i]) == "object") {
load_scripts (scripts [i], function () {
parse_scripts (scripts, (i + 1));
});
}
}
if (typeof (scripts) == "object") {
parse_scripts (scripts, 0);
}
}) (window);
/* ]]> */
</script>
</head>
<body>
<div id="divLoading">Chargement en cours...</div>
</body>
</html>
Concentrons-nous sur la variable scripts du code JavaScript inclus au code HTML ci-dessus. Elle se présente sous la forme d'un tableau de tableaux, qui s'interprète de la manière suivante : tant que tous les fichiers de la variable scripts [i] n'ont pas été chargés, on ne tente pas de charger les fichiers de la variable scripts [i + 1]. Ainsi, si l'on avait deux fichiers JavaScript indépendants, a.js et b.js, un fichier c.js qui dépend de a.js, et un fichier d.js qui dépend de b.js et c.js, on aurait pu écrire la variable scripts sous la forme :
var scripts = [["a.js", "b.js"], ["c.js"], ["d.js"]];
Le reste du code fonctionne de la manière suivante : la fonction parse_scripts, qui prend deux arguments (la variable scripts et un index de tableau i), va charger tous les fichiers JavaScript dont le chemin se trouve dans les éléments du tableau scripts [i] par l'intermédiaire de la fonction load_scripts. Une fois tous ces fichiers chargés, un callback sur la fonction parse_scripts va permettre de charger tous ceux du tableau scripts [i + 1], et ainsi de suite jusqu'à ce que la variable scripts ait entièrement été parcourue.
Pour finir, minifions également le code JavaScript intégré au fichier HTML, selon les méthodes décrites précédemment. On obtient alors un temps de chargement moyen d'environ 160 ms, soit à peine 10 ms de plus que le temps nécessaire à interpréter le HTML seul. Le gain final approche les 65%, sur un exemple pourtant très réduit.
Conclusion
Les trois techniques que nous venons de voir (la minification, la mise en cache et l'exécution différée) permettent d'accélérer grandement le temps nécessaire avant qu'un utilisateur puisse naviguer un site web donné. Même si le gain absolu peut s'avérer faible pour les ordinateurs d'aujourd'hui, il n'est souvent pas négligeable sur les smartphones, entre autres.
Notre exemple de gestion des dépendances peut, par ailleurs, être couplé à un script PHP afin de pouvoir les générer de manière dynamique (ce qui est utile lorsque l'on utilise un système de templates), notamment en utilisant la fonction json_encode :
<?php
$javascript = array ();
$javascript [] = array ('a.js', 'b.js');
$javascript [] = array ('c.js');
$javascript [] = array ('d.js');
?>
var scripts = <?php json_encode ($javascript); /* produit [["a.js","b.js"],["c.js"],["d.js"]] */?>;
En cas de grande complexité de l'interface client, il est possible de pousser la logique encore plus loin, en chargeant « à la demande » les portions de JavaScript nécessaires, comme on peut le voir dans le code de Google GMail.
Références
[2] https://developers.google.com/speed/pagespeed/
[3] http://developer.yahoo.com/yslow/
[4] https://developers.google.com/closure/
[5] http://developer.yahoo.com/yui/compressor/
[6] https://github.com/mishoo/UglifyJS
[7] http://css.github.com/csso/