Performances Web “Put Scripts At The Bottom” Oui mais comment ?
Put quoi ?
Il y a quelques années de ça, Yahoo! a découvert une règle de performance frontend qui a fait date, et qui fait toujours partie des must-have :
Plus tard Google PageSpeed a inclus la même règle. De fait, une inclusion de JavaScript bloque tout rendu ET téléchargement durant le temps de téléchargement du fichier. Dans le cas d’une mauvaise connexion, vous pouvez même rester sur une page blanche alors que la majorité de la page est téléchargée.
Ici les images attendent le JS. Temps body.onload : 3.7s. (voir toute la timeline)
Cette règle est donc réellement à considérer dans le top 10 des modifications à apporter pour qu'un site semble plus réactif. Les développeurs qui ont vu ça ont fait le test de déplacer les balises <script> du <head> pour les mettre tout en bas de page ... pour se rendre compte que la page "cassait" et que des modifications de leurs scripts seraient trop pénibles à exécuter.
Objectif
Mon but était d’accélérer le rendu de la homepage de mon projet actuel pour les visiteurs sans cache. La page mettait plus de 3s avant que le DOM ne s’affiche, alors que le HTML était reçu en moins d’une demie seconde. Ayant la chance d’avoir la même problématique que Facebook (1 page de garde qui n’a que 3 fonctions : se loguer, s’enregistrer et parler du service), j’ai pu reporter le téléchargement de l’intro flash, réduire de moitié le poids total des JS et CSS et accélérer l’envoi des scripts avec du cache côté serveur. 60ko de JS ça reste lourd pour une seule page, et je pourrais faire encore plus spécifique, mais cela demande plus de temps et dans nos contrées ADSLisées le poids n’est plus le nerf de la guerre.
Ces modifs étaient plutôt lourdes et je leur ai consacré presque 2 journées, sans gain sensible, alors que déplacer une inclusion de JS prend une minute. Il me fallait donc tenter quelque chose pour ce fichier JS inclus dans le <head>. Mon seul problème était les <script> inline qui se lançaient et qui dépendaient de ce fichier.
Pourquoi du Javascript inline ?
Avant que vous ne criez au scandale, il faut savoir que ce site a été développé de manière modulaire : écrire des parties indépendantes de site qui peuvent être inclues n'importe où, y compris via XHR (dit AJAX). On est donc bien forcé à un moment de lancer le Javascript associé à un module et si vous regardez le code source, le JS inline ne fait qu'instancier les classes correspondantes aux modules inclus (login, animation, feedback ...). Pour les modules communs, cette partie de JS est dans le fichier principal (dans l'illustration plus haut, c'est celui de 67ko), pour d'autres plus exceptionnels répartis ailleurs sur le site dans des fichiers séparés qui ne sont appelés que lorsque le module est récupéré en AJAX (technique dite du lazy-loading) .
La concaténation (pour moins de requetes HTTP) et le lazy-loading (pour moins de poids initial) s'opposent mais lorsque l'on fait une quasi application web, il faut essayer de les doser pour obtenir le meilleur des 2 mondes. Dans l'un ou l'autre cas, le cache du navigateur rendra l'expérience fluide.
Voilà donc pourquoi on se retrouve à vouloir exécuter le code que l'on voulait justement inclure après.
Exécuter du code qui n'est pas là ?
La technique se déroule en 5 temps :
- taire les erreurs JS
- la page se charge, les JS inline se lancent et plantent en silence
- remettre le système d'erreur standard
- inclure le fichier
- re-exécuter tous les scripts inline
Concrètement, dans le <head>, à la place du <script src=”“> original :
<script> window.defaultOnError = window.onerror; window.onerror = function() {return true;}; </script>
On a sauvegardé le gestionnaire d’erreur par défaut (window.onerror) pour plus tard et on définit à la place une fonction qui ne fait rien. Les erreurs n’arrivent plus jusqu’à l’utilisateur.
En bas de page, après </html> :
<script> window.onerror = window.defaultOnError; </script> <script src="http://example.com/my.js"></script>
On a remis le gestionnaire par défaut et on a inclus notre fichier JS qui ne gène plus personne. Ensuite on va retrouver les script inline pour les exécuter :
<script> var aInlineJS =document.getElementById('container').getElementByTagName('SCRIPT'), iTotal = aInlineJS.length; for(var i=0; i < iTotal;i++) { eval( oJscripts[i].innerHTML ); } </script>
où container serait l’id de ma div principale. Ici on a :
- récupéré tous les éléments de type <script> de la page, qui ne sont pas nos 3 derniers éléments <script> (sinon attention aux boucles infinies)
- lancé un eval sur le contenu de chaque balise
Le vrai code est à peine plus long car il prévoit plus de cas, mais vous avez l’idée. Vous pouvez regarder le code dans la source de cette homepage. Voici notre nouvelle timeline :
Les images sont récupérées en même temps que le JS. Temps body.onload : 1.7s (voir la timeline complète)
Au bout de quelques semaines, Google aussi a la sensation que la page est plus rapide :
On a gagné :
- 2 secondes (50%), sur body.onload (on passe sous la barre arbitraire des 20% des sites les plus rapides choisie par Google webmaster tools)
- 1 seconde (65%) sur le temps avant le 1er clic
- une sensation de vitesse et de fluidité qu'il n'y avait pas avant, car le HTML et quelques images se chargent en moins d'une seconde et il y a moins de pointe de charge CPU
- des places dans le classement Google ? Plusieurs semaines après, en vérifiant notre position pour certains mots clés dans Google nous nous sommes rendu compte qu'on avait gagné des places, parfois plusieurs pages. Difficile d'être sur à 100% que cela vient de là, mais aucun action SEO ou marketing n'avait été mise en place durant cette période.
Génial, je m'y met
Il y a des inconvénients et certaines choses à prendre en compte :
- si il y a une erreur dans vos scripts, votre debuguer vous indiquera comme numéro de ligne celui de l'eval. Je vous conseille donc de réserver cette technique pour la production, et non pour votre environnement de développement.
- document.write() ne marchera pas : il s'exécutera en bas de page ou pire s'exécutera 2 fois. Si vous affichez de la publicité, il est probable que votre régie utilise document.write(). Il n'y a rien à faire à part faire l'appel de la pub tout en bas, puis déplacer la div container au bon endroit, ce qui est excellent pour la perf de manière générale
- si vous comptiez sur JS pour afficher de la publicité ou un widget facebook, cela se fera plus tard qu'actuellement. L'affichage en est d'ailleurs accéléré et se passe sans freeze car le DOM n'est pas modifié alors qu'il est en train de se charger. Mais j'ai déjà vu des commerciaux protester contre ce genre de développement qui accélère l'affichage mais qui rendrait la publicité moins visible. A ce sentiment il vous faut opposer des faits : la performance perçue rapporte réellement de l'argent, plus qu'une publicité qui s'affiche en retard.
- il faut avoir développé préalablement en "non obstrusive javascript", pour que l'utilisateur puisse accéder aux fonctionalités si il clique avant que le JS n'arrive. Exemple : videz le cache et allez cliquer très vite sur le lien "login" en haut à droite. Vous suivrez un lien si JS n'est pas encore là, alors que vous ouvrirez une fenêtre JS avec le même module de login si il est arrivé.
- certains vous diront par réflexe qu'eval is evil, mais en l'occurrence vous exécutez le code d'une source en laquelle vous faîtes déjà confiance : votre HTML. Les risques de XSS ne sont donc pas plus élevés qu'avant (mais corrigez moi si je me trompe, parce que j'ai déjà mis ça en production ...)
- si votre HTML est plus long à s'afficher entièrement que le JS à télécharger (résultat de recherche, page très lourde, très mauvaise optimisation ...), alors il vaut mieux garder votre JS dans le <head>, envoyer rapidement les parties de HTML déjà calculées (au moins la partie <head>, pour commencer ASAP le téléchargement JS/CSS). De cette manière l'utilisateur pourra déjà commencer à interagir avec la page, parties JS comprises, avant même que le HTML soit entièrement calculé
Si vous voyez d’autres limites, j’attend vos commentaires
D'autres techniques ?
Facebook et Google Analytics ont développé des techniques particulières pour exécuter du code alors qu'il n'est pas forcément encore téléchargé.
Google analytics utilise dans sa version asynchrone une astuce tirant parti de la versatilité de JS. Un Array est déclaré et le webmaster utilise .push() en lui passant le nom et les paramètres des méthodes à appeler. Lorsque le fichier ga.js arrive, celui ci remplace le tableau par une classe, exécute toutes les commandes demandées auparavant et .push() sert maintenant à exécuter directement les méthodes.
Facebook pour sa part a expliqué que pour passer de 5s à 2.5s avant le 1er clic, ils ont du faire du très spécifique : ils déclarent en haut de page un petit listener JS, le reste du JS étant en bas de page (145Ko en 16 requêtes). Si l'utilisateur clique vite, ce petit code ouvre une popup JS qui va chercher sur le serveur le HTML et le JS a exécuter.
Dans ces 2 cas, décaler JS en bas de page demandait à changer sa manière de programmer, ce qui fait partie des choses que je voulais éviter. La technique du mute+eval est exactement adaptée à la problématique que j'avais, aussi je serais curieux de voir ses limites sur d'autres types de pages, ou au contraire si elle peut s'adapter chez vous aussi