Faire bouger sa page web, c'est mettre des paillettes dans les yeux de ses utilisateurs.

Dans les deux premières parties, nous avons vu comment fonctionne un navigateur afin de mieux comprendre comment choisir les propriétés CSS que l'on doit animer. Cela a permis d'améliorer à la fois l'étape de Layout et l'étape de Painting. Cependant, aujourd'hui il n'est pas possible de se contenter d'animations CSS lorsqu'on a du contenu dynamique. Il faut passer par JavaScript. On va donc devoir comprendre aussi cette partie là pour pouvoir l'optimiser.

Pour rappel, cette série dédiée aux animations performantes est découpée en trois parties :

  1. Animer les bonnes propriétés CSS
  2. Préparer le navigateur aux animations
  3. Faire les choses dans le bon ordre en JS (Vous êtes ici.)

Ces trois parties sont assez théoriques et expliquent ce qu'il faut éviter et pourquoi. Il faudra attendre la deuxième série sur les animations pour parler d'implémentations concrètes.

TL;DR

Si vous devez récupérer les données liées au style d'un élément du DOM, faites le en une seule fois et au début de votre animation. Une fois que cette étape est réalisée, laissez le navigateur orchestrer lui même les mises à jour en utilisant la méthode :

  • requestAnimationFrame

Mais pourquoi ? Qu'est-ce que ça apporte ?

Pourquoi est-ce que le Layout est un problème en JavaScript ?

Comme nous avons pu le voir dans la première partie, le layout est une étape couteuse lié aux balises CSS utilisées. Il n'y aurait donc pas de raison que cela impacte le JavaScript.

Pourtant, lorsque une animation est faite en JavaScript, nous avons besoin de nous fier à l'état du DOM pour adapter la qualité des animations. Et ceci déclenche une étape de Layout. Le risque est alors de la déclencher trop souvent.

Concrètement, c'est le cas lorsqu'on veut interrompre une animation en cours pour en déclencher une nouvelle. Nous analysons l'état du DOM pour que l'animation suivante parte de ce même état.

Une animation JS qui ne s'en préoccuperait pas ressemblerait à ça (faites un double clic sur un des boutons) :

Mais si on fait attention, elle ressemblerait à ça :

Cependant, si vous allez voir dans les DevTools, dans le deuxième cas de figure, vous auriez une timeline qui ressemble à ceci :

Illustration du Layout Thrashing
Les indicateurs de Layout ont des petits drapeaux triangulaires en haut à droite

Et si vous passez la souris sur ces petits drapeaux rouges, vous pouvez lire : Forced reflow is likely a performance bottleneck. En d'autres termes, dans le code JavaScript il y a une opération qui oblige le navigateur à recalculer le layout. Comment est-ce possible ?

Cela s'explique par le fait qu'accéder à des propriétés du DOM n'est pas gratuit. Regardons plutôt ce que fait le code suivant :

const buttons = document.querySelectorAll('button');

buttons.forEach(button => {
  let width = button.offsetWidth;
  button.style.width = width * 2 + 'px';
});

Pour le premier élément de la boucle, on demande d'accéder au DOM et de récupérer la largeur du bouton en cours. Dès qu'on l'a, on met à jour le style du bouton. Cependant, ce qu'il est important de noter, c'est qu'en faisant cela, on n'a pas encore mis à jour la page côté client. On a juste précisé qu'il fallait mettre à jour la largeur.

On a fini de traiter notre premier élément, on passe au second. On demande donc la largeur du deuxième bouton. Le problème, c'est que le navigateur sait que depuis son dernier Layout, une donnée du style a changé (la largeur du premier bouton). Ainsi, pour ne pas mentir, il va déclencher un nouveau Layout pour être sûr que la largeur du deuxième bouton soit toujours bonne. Alors seulement, il envoie la valeur. Une fois que c'est fait, on décide de mettre à jour la largeur du deuxième bouton. Et donc lors du passage sur le troisième élément de la boucle, on aura à nouveau une phase de Layout.

On appelle ça le Layout Thrashing : on redemande constamment le Layout alors que le premier nous suffit.

En quoi est-ce important pour faire des animations performantes ? Ca l'est parce que pour déterminer comment animer notre contenu, on va régulièrement avoir besoin d'intérroger le DOM, notamment en début d'animation. Or, pour que l'utilisateur ne percoivent pas de délais dans le démarrage d'une animation, il faut réussir à tout préparer en seulement 100ms-150ms. Et si on recalcule trop souvent le Layout, ces 100ms peuvent rapidement être dépassées.

Sur l'exemple suivant, vous pouvez voir que l'animation saute au début (c'est d'autant plus flagrant sur mobile). C'est parce qu'on a dépassé le budget de 150ms. Vous pouvez l'identifier dans vos DevTools dès lors que la partie concernant le layout (en violet) fait concurrence à celle concernant le JavaScript (en jaune) dans la répartition des temps de calcul.

NB : Sur mobile, l'animation est assez lente dans son ensemble à cause du nombre d'éléments animés en même temps. J'ai été obligé de faire ceci pour que le Layout Thrashing soit visible sur desktop. Gardez toujours à l'esprit que vos animations seront presque toujours fluides sur desktop. C'est sur mobile que c'est plus difficile.

Comment éviter le Layout Thrashing ?

La première solution qui vient à l'esprit est de décaler les opérations pour que la récupération des données du DOM se fasse d'un coup, puis que la mise à jour du DOM s'effectue dans un deuxième temps. Ainsi, cela donnerait quelque chose de ce style là :

const buttons = document.querySelectorAll('button');

let widths = [];
buttons.forEach((button, index) => {
  widths[index] = button.offsetWidth;
});

buttons.forEach((button, index) => {
  button.style.width = widths[index] * 2 + 'px';
});

Cependant, il est possible d'éviter de trop remanier son code en utilisant requestAnimationFrame.

Concrètement, le rôle de requestAnimationFrame est de décaler un bout de code dans le temps en le rendant asynchrone. Cependant, à la différence de setTimeout, elle a pour particularité de manipuler la pile d'évènement pour que le bout de code soit exécuté en priorité par rapport aux autres évènements en attente.

Ainsi, on récupère chaque information du DOM une par une tout en précisant que dès qu'on a fini, il faudra exécuter la mise à jour du style. Celle-ci sera donc correctement batchée puisqu'elle ne sera plus interrompue par les phases de Layout. Cela donne :

const buttons = document.querySelectorAll('button');

buttons.forEach(button => {
  let width = button.offsetWidth;
  requestAnimationFrame(() => {
    button.style.width = width * 2 + 'px';
  });
});

Si on applique cela à notre exemple de boutons, on constate qu'il n'y a plus le saut au début de l'animation. Victoire !

Et pendant l'animation ?

Pendant l'animation, il faut évidemment garder en tête tout ce que l'on a vu jusqu'à maintenant. Cependant, il y a un dernier point important : la vitesse de mise à jour.

En effet, il serait tout à fait possible de mettre à jour le style des éléments animés dès que l'étape précédente est finie. Cependant, le risque en faisant cela est de surcharger le navigateur. En effet, pour s'occuper de votre page, le navigateur n'a qu'un thread. Sans rentrer dans les détails techniques, ce que ça implique, c'est que si vous monopolisez ce thread pour faire votre animation, tout le reste des opérations sont mises en suspens. Et si ce cas de figure se présente, c'est bien pire qu'une animation qui lag. Par exemple, l'utilisateur n'est plus en mesure de cliquer sur la page.

Afin de résoudre ce problème, il nous faut rendre l'animation asynchrone. Elle doit rester prioritaire, mais elle ne doit pas bloquer l'ensemble du navigateur. On va donc pouvoir utiliser la même méthode que l'on a vu plus haut : requestAnimationFrame.

Voilà ce que ça donnerait à peu de choses près :

// Durée de l'animation
const totalDuration = 300;
// Point de départ de l'animation
const start = performance.now();

// Méthode qui fait passer l'animation à l'étape suivante
const animate = () => {
  // Calcul de l'état d'avancement
  // progress commence à 0 et finit à 1
  const durationFromStart = performance.now() - start;
  const progress = durationFromStart / totalDuration;

  // Changement du DOM ici
  // ...

  // Lancement de la prochaine étape si ce n'est pas fini
  if (progress < 1) {
    requestAnimationFrame(animate);
  }
};

// Lancement de l'animation
requestAnimationFrame(animate);

De plus, l'avantage de cette méthode est que vous serez obligé de calculer l'état de votre animation en fonction du temps passé plutôt qu'en fonction du numéro de l'étape (cf. la variable progress). Ainsi, même si pour l'utilisateur, l'animation est lente, il pourra tout de même intéragir avec le contenu mis à disposition dans un délai raisonable.

Conclusion

Donc si on récapitule tout depuis le début, on peut en tirer 4 règles qui rendront vos animations plus performantes :

  • N'animer que des propriétés qui ne changent pas la taille de vos contenus, afin d'éviter des étapes de Layout.
  • N'animer que des propriétés qui évitent des étapes de Painting (transform, opacity) tout en prévenant le navigateur avec la propriété will-change
  • Eviter de mélanger les étapes de récupération du DOM et de mise à jour de celui-ci en JavaScript
  • Orchestrer ses animations à l'aide de requestAnimationFrame

Le but de cette série était de montrer un petit plus en détail ce qui se passe dans le navigateur pour mieux comprendre les animations et donc pouvoir les optimiser. Mais ce qu'il faut surtout garder en tête, c'est qu'il ne faut pas optimiser de manière prématurée. Ecrivez votre code, analysez le avec les DevTools, puis optimisez le si besoin. Si vous ne respectez pas cet ordre là, vous risquez d'optimiser des parties qui ne vous apporteront pas grand chose.

Et enfin, testez vos applications sur mobile. C'est primordial car c'est là que vous aurez des problèmes de performance.

En espérant que cette série vous aura plu !

Si vous avez la moindre question ou remarque, n'hésitez pas à venir me voir sur Twitter (@JulienPradet) ou via un quelconque autre vecteur de communication. Je serai ravi d'en discuter avec vous.

D'ici peu, je commencerai une nouvelle série sur les animations, mais avec des exemples concrets cette fois. Stay tuned!