Performance côté client avec Rails & Heroku

À ChooseYourBoss on développe une appli web tout ce qu’il y a de plus classique : HTML5, JS, CSS3 + quelques API (Linkedn, Viadeo, Google Maps, Google Analytics, etc). Côté serveur on est en Rails sur Heroku. Bref, rien d'exceptionnel quoi.

Puis un jour, on a jeté un œil sur le graphe de temps de chargement de notre appli - merci Google Analytics. Et là le drame : une moyenne de plus de 5 secondes pour la page d'accueil, et je ne vous parle pas sur mobile. On se dépêche alors d'aller faire un petit tour sur Google Pagespeed, notre score : 44/100 (bof bof).

On décide d’investiguer point par point nos hypothèses, en voici un résumé.

Les faux problèmes – a.k.a. de la pub pour Rails

1 JavaScript pour tous les gouverner. Ça marche aussi avec le CSS!

On est en Rails 3.2, on a donc l'asset pipeline qui nous compresse nos JS et CSS en un seul fichier, merci Rails.

Tu rendras ton JS & tes CSS minimal (& illisible)

De même, l'asset pipeline est notre ami, il minifie les JS et CSS tout seul. Ça enlève tous les caractères inutiles, sans changer le fonctionnel, pour réduire les temps de transfert. Accessoirement ça sert aussi d’offuscation sur notre code. Encore merci Rails.

Et les vrais problèmes ?

Tu utiliseras le cache du navigateur

Là pas merci Heroku. Avant, il y avait varnish qui était devant vos serveurs et il faisait la magie tout seul. Sur la nouvelle stack Heroku (Cedar), varnish a disparu. Il faut donc le faire à la main. Ici on a plusieurs solutions :

  • Mettre les assets sur un CDN
  • Utiliser un autre système de cache

On a préféré la deuxième solution, en mettant en cache nos assets dans un memcache. Ce n’est pas compliqué à faire, il y a un tutoriel. On peut se permettre de mettre une durée de cache très grande, car à chaque déploiement Rails regénère les assets avec un nom différent. Et donc le navigateur ne gardera en cache que les derniers assets.

On a choisi cette solution pour une seule raison. On n’a pas de compte AWS, pas forcément les moyens d’en avoir un rapidement (process d’achat). On a donc opté pour la solution la plus rapide à mettre en place.

Tu compresseras tes JS/CSS

Là c'est facile, il suffit de rajouter un engine à rails qui va gziper les requêtes sortantes si le client cible supporte le gzip. On rajoute donc juste Rack::Deflater dans le fichier config.ru :

# This file is used by Rack-based servers to start the application.
require ::File.expand_path('../config/environment',  __FILE__)
use Rack::Deflater # Required for Heroku CEDAR stack
run ChooseYourBoss::Application

Des sprites tu créeras

Dans le but de minimiser le nombre de requêtes entre le client et le serveur, on a voulu mettre en sprites nos images. Les sprites à la main c’est c'est long, ennuyeux, sujets à erreur, et à recommencer dès que l'on rajoute une image. Heureusement pour nous, la gem compass permet de faire des sprites automatiquement. En plus, c'est cool, on fait du SASS et compass est en SASS.

Son utilisation est assez simple :

  • Rajouter compas comme dépendance, ainsi que oily_png pour la génération du sprinte en png

  • Mettre toutes les images à spriter dans un répertoire : sprites/ pour nous

  • Dans un fichier SASS :

  • Indiquer que l'on veut qu'il optimise l'agencement des images dans notre sprite : $sprites-layout: smart;

  • Inclure toute les images de notre répertoire : @import "sprites/*.png";

  • Générer le css / sprite : @include all-sprites-sprites;

Tout ceci va générer une classe CSS par image, avec ce qu'il faut dedans. Ex : Mon image sprites/toto.png va générer la classe .sprites-toto.

Pour plus de détails, la doc peut vous éclairer.

Du chargement JS en bas de page tu feras

Un grand classique, on a mis nos JS tout à la fin de notre page HTML, ce qui permet de ne les charger qu'après le chargement de l’HTML et du CSS.

Du chargement JS asynchrone tu ferras

C'est bien le JS en bas de page, mais à cause nos boutons like Facebook et follow Twitter, on chargeait respectivement 200ko et 100ko de JS en plus dans notre page, ce qui bloquait son chargement. On a donc passé le chargement de ces 2 boutons en asynchrone. Attention, nos exemples sont en CoffeeScript.

Pour Facebook :

id = 'facebook-jssdk'
ref = document.getElementsByTagName('script')[0]
return if document.getElementById(id)
js = document.createElement('script')
js.id = id
js.async = true
js.src = "//connect.facebook.net/en_US/all.js#xfbml=1"
ref.parentNode.insertBefore(js, ref)

Même chose pour Twitter

id = 'twitter-jssdk'
ref = document.getElementsByTagName('script')[0]
return if document.getElementById(id)
js = document.createElement('script')
js.id = id
js.async = true
js.src = "//platform.twitter.com/widgets.js"
ref.parentNode.insertBefore(js, ref)

De même, on a rajouté l'attribut async sur nos balises script. Ce qui permet de charger les assets en asynchrone, merci HTML5 :

= javascript_include_tag "application", async: true

Du chargement JS asynchrone tu referas

On avait fait de belles optimisations, sauf que notre site était cassé 1 requête sur 2. En effet, notre JS dépend de l'API de Google Maps. Et une fois sur deux l'API était chargée après notre js, donc on obtenait des erreurs. Il a fallut donc trouver une autre solution pour le chargement en asynchrone. Et la, on a utilisé une petite lib nommée head.js, qui permet de faire du chargement asynchrone d'assets, mais de garantir leur ordre d'exécution.

On a donc en rails :

= javascript_include_tag "loader", async: true

loader correspond à un fichier JS qui inclus head.js en premier, puis notre fichier de chargement de JS spécifique, attention c'est du CoffeeScript avec de l'ERB (un language de template pour Ruby) :

@CYB = @CYB || {} #On déclare notre namespace global JS.

<% if Rails.env.production? %>
#Le JS de notre application
application_path = '<%= javascript_path "application" %>'

#Chemins vers nos différentes lib JS
gmaps_path = '<%= "https://maps.googleapis.com/maps/api/js?key=#{GMAPS_BROWSER}&sensor=false" %>'
viadeo_path = 'http://cdn.viadeo.com/javascript/sdk.js'
linkedin_path = 'http://platform.linkedin.com/in.js?async=true'

#Le chargement par head.js
head.js gmaps_path, viadeo_path, linkedin_path, application_path
<% end %>

Le seul problème c’est que tout ça, ne marche qu'en mode production, il faut donc rajouter le bout de code suivant pour avoir tous vos JS en mode dev. Re attention, c'est du HAML :

- options = {}
- options = {async: true} if Rails.env.production?
= javascript_include_tag "loader", options

- #We need this, because head.js can not generate all the JS assets in dev & test
- unless Rails.env.production?
  = javascript_include_tag "https://maps.googleapis.com/maps/api/js?key=#{GMAPS_BROWSER}&sensor=false"
  = javascript_include_tag 'http://cdn.viadeo.com/javascript/sdk.js'
  = javascript_include_tag 'http://platform.linkedin.com/in.js?async=true'
  = javascript_include_tag "application"

Oui, il y a de la duplication, mais c’est facile à corriger. A savoir que ce mécanisme de chargement asynchrone peut être aussi mis en jeu si vous utilisez d’autres médias (ex : une pub, une vidéo, etc.)

Des images tu supprimeras

On avait pas mal d'images sur notre site, et les images (même en sprite) c'est gros. Donc on a tenté au maximum de les remplacer par du CSS. On a abusé des pseudos élément ::before et ::after qui permettent de rajouter des éléments html mais que visible dans le css. Ce qui permet, par exemple de créée des puces pour une liste à partir d'un dessin fait en css, ou d'un texte spécifique. Ce qui nous permet de transformer 2ko d'image en quelques lignes de SASS.

Ex :

#mon_id {
  ul {
    list-style: none;
  }
  li::before {
    content: "\02713";
    margin-right: 10px;
    font-size: 1.3em;
  }
}

Des webfonts tu chargeras en asynchrone (ou pas)

Comme on a un site bleeding edge (web 3.5, HTML10.3 et CSS14.2 au minimum), on utilise des webfonts pour avoir des polices plus sympas. C'est bien beau tout ça, mais c'est long à télécharger. On avait déjà décidé de n'utiliser que des polices venant de Google webfonts, ce qui nous permettait de profiter des CDN de Google. On a donc suivi la procédure sur leur site pour rendre le chargement asynchrone.

Mais, en fait ça donnait un rendu étrange à notre page. En effet, la page était affichée avec certaines polices, et d'un coup elles changeaient toutes pour les webfonts.

On a donc décidé de remettre leur chargement en synchrone.

Conclusion

Toutes ses optimisations nous ont pris entre 2-3 jh et nous ont permis de passer notre score Google pagespeed de 44/100 à 94/100, y compris sur mobile.

Pagespeed nous donne encore quelques points d’amélioration, mais ceux-ci sont majoritairement indépendants de nous. Par exemple, le JS de twitter n’est mis en cache que 30 minutes.

Notre prochaine étape est donc indéterminée. Par contre, nous avons un indicateur de qualité produit que nous ne négligerons plus.