Formule 1 dans les starting blocks

0/100.

0/100, tel était le premier score quand j’ai fait tourner le test Google Page Speed sur notre nouveau site sous AngularJS. Bon, j’avoue cette note affreuse était due principalement au fait que l’on appelait une dizaine d’images de 5 Mo. Mais une fois la bêtise réparée, la note n’était pas franchement bonne non plus.

Les SPA (Single Page Applications) basées sous des frameworks comme AngularJS ou React nous obligent à revoir totalement l’optimisation de la performance. Dans cet article, je vous propose d’embarquer pas à pas dans toutes les étapes de l’optimisation qui m’ont emmené à une note de 100/100. Chaque point y est expliqué pour que tu puisses toi aussi atteindre le score de 100% sur Google Page Speed Insights.

Attention :
En lisant cet article je suppose que tu maitrises assez bien le JS, Gulp, Git, l’utilisation du terminal et un minimum de language serveur. Il faut aussi les droits admin (sudo) sur ton serveur pour effectuer certaines opérations. J’utilise AngularJS 1.5 sur un serveur Ubuntu/Nginx Digital Ocean. En fonction de ta configuration il peut y avoir des différences.

Tour de chauffe : Optimisation des ressources

Optimiser les images

On commence par les images, car c’est facile et on gagne plein de points ! La règle la plus importante est de livrer des images à la taille adequate. Inutile de livrer une image énorme si elle va être affichée en tout petit. Une fois à la bonne taille il faut l’optimiser, c’est à dire réduire sa taille sans réduire sa qualité. Perso je le fais en mode “old school” à la main avec ImageOptim (sur Mac) mais on peut parfaitement l’intégrer dans une tâche Gulp avec gulp-imagemin.

SASS / CSS : concaténer et minifier

Les requêtes répétées diminuent la performance. On va donc utiliser Gulp pour n’appeler qu’un seul fichier CSS, et on va le minifier pour réduire sa taille. Si vous n’utilisez pas Bower ou npm pour gérer vos dépendances, c’est le moment de s’y mettre car cela simplifie énormément nos appels pour les ressources CSS et JS.

Pour cela on utilise les modules gulp-sass, gulp-autoprefixer, gulp-clean-css, gulp-rename. Voici la task :

var gulp = require('gulp'),
    browserSync = require('browser-sync').create(),
    sass = require('gulp-sass'),
    rename = require("gulp-rename"),
    autoprefixer = require('gulp-autoprefixer'),
    cleanCSS = require('gulp-clean-css'),

gulp.task('sass', function (done) {
    gulp.src(['src/scss/main.scss', 'chemin/vers/un-autre.css', 'encore/un/fichier.css'])
        .pipe(sass())
        .on('error', sass.logError)
        .pipe(autoprefixer({
            browsers: ['last 2 versions'],
            cascade: false
        }))
        .pipe(gulp.dest('./assets/css'))
        .pipe(cleanCSS({compatibility: 'ie8'}))
        .pipe(rename({extname: '.min.css'}))
        .pipe(gulp.dest('./dist/css'))
        .on('end', done);
});

Voilà, on met tout ça dans un fichier main.min.css bien compressé qu’on appelle dans le <head>.

Scripts : concaténer et minimifier

Sur mes projets AngularJS, j’adore avoir un fichier pour tout : chaque controller, chaque service dispose de son propre fichier. Mais pas question d’appeler tous ces fichiers sur la prod ! Comme pour le CSS on va générer un seul fichier compressé qui sera appelé depuis l’index.html. Notre tâche Gulp qui va s’occuper du travail se servira des modules gulp-concat et gulp-uglify.

Attention l’ordre dans lequel on va concaténer les scripts est important : d’abord les dépendances tierces, l’app.js, les services et ensuite les controllers et autres fichiers JS utilisés en bout de ligne.

gulp.task('scripts', function () {
   return gulp.src([
      './bower_components/angular/angular.js',
      '...plus de dépendances tierces'
      './js/app.js',
      './js/services/SuperCoolService.js',
      './js/controllers/ExampleCtrl.js',
      './js/filters.js',
      './js/directives.js'
    ])
    .pipe(concat('all.js'))
    .pipe(gulp.dest('./dist/js'));

});

Et bim ! Voilà pour les ressources. Normalement vous ne devrez pas avoir de problème concernant les ressources HTML. Si c’est le cas vous pouvez chercher le module Gulp qui se chargera de compiler vos templates.

Petit rappel sur les conventions Git :
On ne commite jamais de fichiers compilés. Il faudra donc les répertorier dans le .gitignore.

Optimisations sever-side

Cette section explique la configuration à réaliser sur NGINX. Si tu utilises Apache, la philosophie reste la même mais la manip est différente (les configs se font principalement sur .htaccess sur Apache). Tu peux trouver facilement les étapes à réaliser en cherchant sur internet.

Autoriser la compression

Certains serveurs viennent avec GZIP désactivé. Il suffit donc de l’activer en modifiant le fichier de configuration, avec la commande sudo nano /etc/nginx/nginx.conf. Attention, le fichier de configuration peut être situé ailleurs.

Il suffit d’ajouter (ou de décommenter en enlevant le “#”) dans les accolades du http les lignes suivantes. Vérifiez bien qu’elles ne sont pas déjà dans le fichier avant de les ajouter :

gzip on;
gzip_disable "msie6";
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;

Exploiter la mise en cache du navigateur

Fichiers locaux:
Pour nos visiteurs réguliers, ou du moins ce qui ont déjà visité de site, il est intéressant de ne pas les forcer à télécharger les choses qu’ils ont déjà en cache. On va donc indiquer un délai d’expiration très long pour nos ressources.

Dans NGINX il faudra aller dans le fichier de conf de l’app/du site en question, pour moi /etc/nginx/sites-available/monapp (en remplaçant mon app par le nom de l’app ou du site). Le code suivant est à placer à l’intérieur d’un block server (délimité par des accolades).

location ~*  \.(jpg|jpeg|png|gif|ico|css|js|woff|svg|pdf)$ {
        expires 365d;
    }

Partis tiers:
Les resources JS partis tiers (third-party) avec une date d’expiration rapide peuvent poser problème car vous n’avez pas la main dessus. Ironiquement, une d’entre elles est Google Analytics ! Heureusement on peut la facilement remplacer par GA LITE, une librairie optimisée et mise en cache de Google Analytics. Il suffit de remplacer le code de Google Analytics par ces lignes :

<script src="https://cdn.jsdelivr.net/ga-lite/latest/ga-lite.min.js" async=""></script><script>
var galite = galite || {}; galite.UA = 'VOTRE-ID-GOOGLE-ANALYTICS';
></script>

Notez bien le “async” dans la balise script. Cela signifie que le JS sera chargé de façon asynchrone et ne bloquera pas l’affichage. Nous l’utiliserons également par la suite.

Pour les autres dépendances, vous pouvez chercher sur internet si certains CDNs peuvent vous les servir. Vous pouvez également les héberger sur votre serveur. Nous utilisons par exemple TypeKit pour les polices, qui dispose d’une version asynchrone qui ne bloque pas l’affichage.

Cela n’a pas un impact énorme pour la performance réelle. Par contre vous ne pourrez pas atteindre le 100/100 Page Speed sans régler ces problèmes.

Pre-rendering du DOM

OK, là on rentre vraiment dans le sujet. Goole nous demande d’Éliminer les codes JavaScript et qui bloquent l’affichage du contenu au-dessus de la ligne de flottaison. Ce qu’il veut dire, c’est que le DOM doit attendre le chargement complet de notre bundle JavaScript pour être affiché, et ça prend du temps.

Nous allons donc envoyer très rapidement le HTML puis charger notre fichier JS pendant que le visiteur a déjà quelque chose en face de lui. Cela améliorera les premiers visuels du site apparaitront beaucoup plus rapidement.

Décaler le démarrage d’AngularJS

Si vous avez bien suivi les conventions d’AngularJS, vous avez un attribut “ng-app” sur votre balise <body> ou <html>. On va commencer par le retirer.

Nous voulons pouvoir choisir à quel moment nous allons démarrer AngularJS. En l’occurence, nous voulons démarrer manuellement AngularJS une fois que tout est chargé. Nous utiliserons la fonction angular.bootstrap (rien à voir avec le framework Twitter Bootstrap).

On remplace donc l’appel classique de notre bundle par un appel asynchrone qui exécutera la fonction startUpAngular une fois celui-ci téléchargé.

<script>
    function startUpAngular(){
        window.stateName='';
        angular.bootstrap(document.getElementsByTagName("html")[0], ['myApp']);
        window.prerenderReady = true;
    }
</script>
<script type="text/javascript" src="/dist/all.js" onload="startUpAngular()" async></script>

Générer le DOM server-side avec Prerender

Notre objectif est de livrer directement du HTML à l’utilisateur quitte à le modifier par la suite avec AngularJS. Pour cela nous allons héberger une version de Prerender qui va délivrer des “snapshots” du site. Cela signifie que Prerender va executer le Javascript côté serveur (en utilisant PhantomJS) afin de délivrer directement une page HTML au client.

Page d'accueil de Prerender.io
prerender.io propose une version self-hosted gratuite jusqu’à 250 pages. Dans ce tutoriel, nous hébergeons nous-même les snapshots des pages.

Prerender est utilisé principalement pour des fins de SEO car les bots des moteurs de recherche ne comprennent pas encore bien les SPA. Cette ci nous allons l’utiliser car nous délivrerons ainsi un HTML tout prêt avant le chargement d’AngularJS.

Prerender est open-source donc nous l’installons directement sur notre serveur :

$ git clone https://github.com/prerender/prerender.git
$ cd prerender
$ npm install

Par défaut le module removeScriptTags est activé et vire nos scripts de la page. Nous allons le désactiver en éditant le fichier server.js et en commentant (avec deux slashs “//”) cette ligne :

server.use(prerender.removeScriptTags());

Nous n’avons plus qu’à lancer la tâche :

node server.js

Voilà notre tâche lancée. Pour l’instant on va laisser le terminal ouvert afin de ne pas interrompre le process. Nous verrons par la suite comment faire tourner la tâche en background.

Par défaut Prerender s’execute sur https://localhost:3000. On va donc rediriger les requêtes vers cette URL afin que Prerender génère lui-même le DOM. Retournons à notre fichier de configuration de notre site : sudo nano /etc/nginx/sites-available/monapp (en remplaçant monapp par le nom de votre app).

location /prerender {
        try_files $uri $uri/ /$query_string;
    }

    location / {
        try_files $uri @prerender;
    }

     location @prerender {
            set $prerender 1;

            # les ressources ne seront pas livrées par prerender
            if ($uri ~* "\.(js|css|xml|less|png|jpg|jpeg|gif|pdf|doc|txt|ico|rss|zip|mp3|rar|exe|wmv|doc|avi|ppt|mpg|mpeg|tif|wav|mov|psd|ai|xls|mp4|m4a|swf|dat|dmg|iso|flv|m4v|torrent|ttf|woff|svg|eot)") {
                set $prerender 0;
            }

            if ($http_user_agent ~ "Prerender") {
                set $prerender 0;
            }


            if ($prerender = 1) {
                # on revoie le DOM générer server-side
                rewrite .* /$scheme://$host$request_uri? break;
                proxy_pass http://localhost:3000;
            }             
            if ($prerender = 0) {             
                  rewrite .* /index.html break;             
            }     
    }

Ne pas oublier de redémarrer NGINX avec la commande pour que les modifications soient prises en compte.

Si on retourne sur notre site en tant que visiteur, le site devrait marcher. Pour faire le test, on doit voir le HTML complet dans le code source de votre page (sur Chrome : Afficher > Options pour les développeurs > Code source).

Mettre la réponse en cache avec Redis

On se rendra vite compte que le serveur met du temps à charger la page. En allant sur Google Page Speed, on voit que nous avons un nouveau problème : Réduire le temps de réponse du serveur. Et oui, car PhantomJS met du temps à executer notre Javascript.

Nous allons donc utiliser le plugin Prerender Redis Cache qui utilise Redis pour mettre les réponses en cache. On va donc retourner une version mise en cache de notre page au lieu de la générer à chaque requête. Cela va nous faire économiser du temps de réponse serveur. Dans notre dossier prerender :

npm install prerender-redis-cache --save

Puis dans server.js ajouter la ligne

server.use(require('./node_modules/prerender-redis-cache'));

On relance notre task prerender :

node server.js

Pour rafraichir le cache, il suffit de faire une requête POST sur l’URL en question (avec Postman par exemple).

Automatiser la task

Maintenant il faut automatiser l’execution de cette task car si on coupe notre terminal notre site ne sera plus accessible. Pour cela nous allons utiliser Supervisor qui va s’occuper de ça pour nous.

sudo apt-get install supervisor

Accédons aux fichiers de config supervisor et créer le notre :

cd /etc/supervisor/conf.d
sudo nano prerender-worker.conf

Voici le contenu du fichier :

[program:prerender-worker]
process_name=prerender-worker
command= node /chemin/vers/dossier/prerender/server.js --sleep=3 --tries=3 --daemon
autostart=true
autorestart=true
user=root
numprocs=1
redirect_stderr=true
stdout_logfile=/chemin/vers/dossier/prerender/prerender-worker.log

Le “user” représente l’utilisateur qui va executer la task. La ligne “numprocs” indique le nombre de process simultanés que vous voulez lancer. Lancer 1 process par CPU est une bonne chose.

Ensuite on va l’activer avec :

sudo supervisorctl reread
sudo supervisorctl update
sudo supervisorctl start prerender-worker:*

Afficher en priorité le contenu visible

Critical CSS

Ce qui obsède particulièrement Google, c’est le contenu au-dessus de la ligne de flottaison, donc le contenu visible sans scroller. On l’appellera le contenu ATF (Above The Fold) puisque en anglais ça sonne toujours plus cool.

De la même façon que le Javascript plus haut, le navigateur attend de recevoir les styles CSS des éléments avant de les afficher. Nous allons donc insérer du code CSS des éléments ATF directement dans la balise <head> du HTML. Comme ça le navigateur pourra nous afficher le 1er écran directement pendant qu’il charge le fichier CSS. Ce code s’appelle le critical CSS.

Où est la ligne de flottaison ?

Chaque taille d’écran aura une ligne de flottaison à un endroit différent. Chaque navigateur a une hauteur différente, ce qui change la ligne de flottaison. On peut donc dire qu’il n’y a pas une mais plusieurs lignes de flottaison. On peut néanmoins donner une hauteur approximative en pixels pour les desktops et une autre pour les smartphones.

On va d’abord récupérer le style des éléments ATF dans un fichier CSS. Pour ma part, j’ai réalisé cette sélection à la main. J’ai crée un fichier home-atf.html que j’ai mis dans mon dossier src/html/. Ce fichier est un copier-coller du code HTML des éléments ATF. Sur le site de buddyweb.fr, nous affichons une image full-screen en couverture, ce qui fait qu’il y a très peu de code.

<div class="cover cover--home  m-b-xxl" id="js-cover--home" full-height>
    <div class="container-full bg-gradient  absolute">
    </div>
    <div class="container  relative" full-height>
        <h1 class="text-light  cover__title" translate="home.title"></h1>
    </div>
</div>

Une fois ce fichier crée, nous allons utiliser une tache Gulp pour extraire le CSS de ces éléments. On utilise le module gulp-uncss pour extraire le style des éléments, puis gulp-clean-css pour minimifier et gulp rename pour changer le nom.

Dans notre exemple, en plus du contenu de la home-atf.html il faut prendre en compte le header qui est lui-aussi un affiché ATF :

var rename = require("gulp-rename"),
    cleanCSS = require('gulp-clean-css'),
    uncss = require('gulp-uncss');

gulp.task('createHomeCriticalCss', function () {
    return gulp.src('./assets/css/main.min.css')
        .pipe(uncss({
            html: ['./src/html/home-atf.html', './templates/partials/header.html'],
            ignore:  ['.bg-gradient']
        }))
        .pipe(cleanCSS({compatibility: 'ie8', keepSpecialComments : 0}))
        .pipe(rename('critical.css'))
        .pipe(gulp.dest('./assets/css'));
});

Vous pouvez vérifier que le fichier critical.css a bien été crée et qu’il contient les déclarations de style des éléments désirés.

Nous allons ensuite l’injecter dans notre fichier index.html pour l’affichage de la home. Pour cela, encore une tâche Gulp et du module gulp-inject-string pour injecter le contenu de notre fichier critical.css dans l’index.

var inject = require('gulp-inject-string'),
    fs = require('fs');

gulp.task('insertHomeCriticalCss', function () {
    var criticalStyles = fs.readFileSync('./assets/css/critical.css', 'utf8');
    return gulp.src('./templates/index.html')
        .pipe(inject.before('', '')) 
        .pipe(rename('index.html'))
        .pipe(gulp.dest('')); });

Pour executer les 2 tasks à la suite (et non pas en parallèle), on peut utiliser le module gulp-sequence.

Nous pouvons appeler le paquet CSS dans le footer et notre fichier index.html et prêt pour être compilé et offrir une meilleure expérience à nos visiteurs !

Critical JS

Vous avez peut être remarqué l’attribut full-height sur certaines <div> de code ATF. C’est une directive AngularJS (voir le CodePen) qui permet d’afficher la couverture en full-height. Malgré le HTML qui a été généré en sever-side, notre image de couverture est modifiée client-side pour avoir la même hauteur que l’écran. Le problème dans notre configuration c’est que l’image de couverture ne va être “full-height” qu’une fois le bundle JS chargé.

Il nous faudra donc, tout comme pour le critical CSS, intégrer des scripts en inline dans le HTML. Les seuls scripts concernés sont ceux qui sont nécessaires à l’affichage des éléments ATF. Dans notre exemple nous allons donc devoir ré-écrire notre directive AngularJS en Vanilla JavaScript. On insère donc le JS directement dans le footer, et ça donne ça :

<script>
var windowHeight = window.innerHeight;
document.getElementById('js-cover--home').style.minHeight = windowHeight + 'px';
document.getElementById('js-cover--home').style.maxHeight = windowHeight + 'px';
document.getElementById('js-cover--home').style.minHeight = 'hidden';
document.getElementById('js-cover-container--home').style.minHeight = windowHeight + 'px';
document.getElementById('js-cover-container--home').style.maxHeight = windowHeight + 'px';
document.getElementById('js-cover-container--home').style.minHeight = 'hidden';
</script>

100/100 Page Speed

Page de test de Google Page Speed
Capture d’écran de la note de 100/100 sur Google Page Speed Insights

Félicitations si tu es arrivé jusqu’ici ! Si tout est correct tu devrais atteindre le score de 100/100 sur Google Page Speed avec AngularJS.

Ces optimisations ont été réalisées en se basant uniquement sur les préconisations de Google Page Speed Insights. Il y a encore d’autres choses que j’aurai pu faire pour améliorer la performance mais qui n’impactent pas sur le score : réduire le nombre de fonts utilisées et réduire mes dépendances JS (je charge jQuery juste pour une directive par exemple).