Analyser les performances de rendu de son interface avec du profiling
Généralement quand on parle de performances web, on fait référence au premier chargement de la page. Cet article va se concentrer sur un autre type de performance, à savoir la performance de rendu. Ces dernières correspondent au comportement de l’application pendant que l’utilisateur navigue ou interagit sur celle-ci. Quelques exemples d’actions qui font partie de cette catégorie :
- Scroller sur une page
- Saisir du texte dans un input
- Déclencher une animation
- Ouvrir une modal
- Changer de page (dans le cas d’une SPA)
Des interactions lentes vont donc avoir un impact fort sur l’expérience utilisateur.
Pour vous en convaincre je vous recommande d’aller essayer cette simulation, qui permet de tester le comportement d’un input avec de la latence.
Imaginez également la situation suivante, vous avez investi beaucoup de temps pour obtenir un bon temps de chargement. Vous avez également consacré beaucoup d’énergie pour avoir une super UI et UX. L’utilisateur arrive donc sur la page, avec un téléphone dont le CPU est modeste, il commence à scroller et à naviguer et là c’est la catastrophe, de gros ralentissements commencent à apparaître. Tous vos efforts précédents sont gâchés. Nous allons donc voir ensemble comment éviter cette situation.
Comment mesurer des performances de rendu ?
La métrique utilisée dans ce cas est le taux de rafraîchissement (ou FPS). La valeur normale admise, pour que les interactions soient perçues comme fluides par l’utilisateur, est de 60 images par seconde, soit environ 16.7 ms par frame.
Vous pouvez visualiser cette information en temps réel avec les Chrome DevTools (cette information est également disponible dans les DevTools Firefox). Dans le cadre de cet article on utilisera les Chrome DevTools.
- Ouvrez votre console
- Ouvrez la fenêtre pour lancer une commande (CMD + Shift + P sur Mac ou CRTL + SHIFT + P pour Linux/Windows)
Paramètres des devtools |
- Tapez “FPS” et cliquez sur “Show frames per second (FPS) meter”
Fenêtre d’accès rapide aux fonctions des devtools |
Vous devriez maintenant avoir en haut à gauche un cadre avec l’indicateur de FPS ainsi que d’autres informations.
Indicateur de frame rate |
Comment fonctionne le navigateur pour construire chaque frame ?
Pour afficher chaque frame à l’écran le navigateur va effectuer ce que l’on appelle le pipeline de pixels. Il y a 5 étapes consécutives importantes, sur lesquelles on peut intervenir, qui vont se succéder pour aboutir à la construction de la frame et de l’affichage des pixels à l’écran.
Représentation du pipeline de pixel (source : https://developers.google.com/web/fundamentals/performance/rendering/) |
Le navigateur décompose la construction d’une frame à travers ces étapes, dans un premier temps intéressons nous à chacune d’entre elles :
- JavaScript : Exécution de code JavaScript qui va avoir un impact visuel sur la page.
- Exemple : ajout d’un élément dans le DOM.
- Il est recommandé d’utiliser la fonction requestAnimationFrame() pour les changements asynchrones visuels en javascript (au lieu de setTimeout et setInterval). Elle permet de synchroniser l'exécution du JavaScript au début de la construction d’une frame. Si vous utilisez un framework front (React, Vue, …) cette opération est déjà faite pour vous. Attention tout de même si vous utilisez des librairies externes qui accède directement au DOM.
- Pour les tâches JavaScript excessivement longues, il est recommandé de les déplacer sur un web worker.
- Style : Détermine les règles CSS à appliquer (parmi l’ensemble des feuilles de style du site) à partir des éléments à afficher à l’écran. Au chargement de la page, le navigateur construit ce que l’on appelle le CSSOM qui est une représentation de l’ensemble des sélecteurs CSS de la page sous forme d’arbre. Cette étape a pour objectif d’appliquer le style des parties correspondantes du CSSOM aux éléments qui seront visibles dans la frame que l’on veut afficher.
- Exemple : dans la frame que je veux afficher, j’ai un élément avec le sélecteur “.menu”. Le navigateur va calculer et appliquer le style qui correspond à ce sélecteur pour cet élément.
- Pour améliorer cette phase il faut éviter au maximum les sélecteurs complexes (exemple : .container > div:nth-child(2n)).
- Exemple : dans la frame que je veux afficher, j’ai un élément avec le sélecteur “.menu”. Le navigateur va calculer et appliquer le style qui correspond à ce sélecteur pour cet élément.
- Layout : Calcule la géométrie des éléments à partir des styles précédemment calculés. Cette opération est aussi appelée un reflow. L’idée ici est de prévoir la place dont chaque élément a besoin pour s’afficher à l’écran.
- A noter qu’un changement sur un élément affecte tous ses enfants dans le DOM. Cette opération est donc assez coûteuse.
- Il faut également éviter d’avoir une arborescence trop complexe.
- A noter qu’un changement sur un élément affecte tous ses enfants dans le DOM. Cette opération est donc assez coûteuse.
- Paint : Remplissage des pixels sur une ou plusieurs couche(s). Il s’agit ici de dessiner les textes, les bordures, les couleurs… Cette étape est souvent effectuée sur plusieurs couches, ce qui conduit à la phase composite. L’opération de paint est la plus coûteuse parmi les 5.
- Composite : Il s’agit ici d’assembler les couches, dans le bon ordre, pour obtenir le résultat final à afficher à l’écran. Il est possible de visualiser ces différentes couches dans les DevTools, pour cela ouvrez la fenêtre de commandes de toute à l’heure (CMD + Shift + P ou CTRL + Shift + P) et tapez “Layer” pour trouver l’élément “Show Layers”.
Visualisation du site Octo en couches avec les Chrome DevTools |
L’avantage de peindre l’image en plusieurs couches est d’isoler les changements pour éviter le plus possible de devoir tout repeindre à chaque fois.
Technique : si vous pensez qu’une zone de votre interface sera repeinte régulièrement vous pouvez indiquer au navigateur de lui créer une couche à part avec la propriété CSS will-change: transform. Cette propriété est a appliquer sur le sélecteur de l’élément parent de la zone concernée.
Attention toutefois car créer une couche consomme de la mémoire, il faut que celle-ci soit justifiée.
Pour en revenir à l'enchaînement des étapes, celles-ci ne seront donc pas toujours toutes exécutées en fonction du changement à effectuer sur la page. On distingue 3 scénarios :
- JS > Style > Layout > Paint > Composite
- toutes les étapes se déclenchent
- cela se produit si par exemple la taille ou la position d’un élément change
- ce cycle est le plus coûteux, il faut donc essayer de le minimiser le plus possible
- toutes les étapes se déclenchent
- JS > Style > Paint > Composite
- l’étape de layout n’est pas nécessaire
- cela se produit si le changement est seulement du style, par exemple un changement sur la propriété background-color
- l’étape de layout n’est pas nécessaire
- JS > Style > Composite
- l’étape de layout et de paint ne sont pas nécessaires
- ce scénario arrive principalement quand on a modifié la propriété opacity ou transform.
- Cette étape étant la plus économique, il faut essayer d’utiliser les 2 propriétés ci-dessus en priorité pour faire des changements visuels.
- l’étape de layout et de paint ne sont pas nécessaires
Le comportement de l’ensemble des propriétés CSS est disponible à cette adresse : https://csstriggers.com
Analyser et résoudre ses problèmes de performances de rendu :
On connaît maintenant notre métrique à mesurer, le FPS, et la façon dont le navigateur construit chaque frame. Notre objectif à présent va être de trouver des chutes de FPS pendant la navigation sur notre interface, de voir quelles phases le navigateur a effectué pendant ces chutes et enfin essayer d’établir une corrélation avec notre code.
Pour cela, on va utiliser l’onglet “Performance” des Chrome DevTools. Ouvrez votre navigateur en navigation privée et assurez-vous qu’il n’y pas d’extension activée pour ne pas avoir de bruits dans vos données. Activez également une limitation sur le CPU (4x slowdown) pour faire ressortir vos éventuels problèmes de performances. Ensuite, lancez l’enregistrement et effectuez des actions sur votre page (scroll, click, …).
Sur le résultat que vous allez obtenir, il y aura beaucoup d’informations affichées en même temps. On va se concentrer sur les plus importantes et voir comment les analyser. Prenons le site d’Octo comme exemple.
Vue global de l’onglet performance après un profiling |
Indicateurs importants après un profiling |
La première ligne correspond aux FPS évoqués précédemment. Les chutes de FPS provoquant un ralentissement visible par l’utilisateur sont normalement indiquées par des petits rectangles rouges au-dessus de la ligne. Ce sont ces zones que l’on va investiguer en priorité.
La deuxième ligne correspond à l’activité du CPU sur le pipeline de pixel évoqué également auparavant. Le code couleur est le même que sur la photo de la section précédente (jaune pour JavaScript, violet pour les phases de style et layout et enfin vert pour les phases de paint et composite).
En dessous vous avez la ligne qui correspond à chaque frame produite par le navigateur. En passant votre souris sur chaque frame vous allez pouvoir voir le temps qu’a pris celle-ci pour être produite. On rappelle que l’on s’attend à un temps de 16.7 ms environ pour être proche des 60 FPS. Si vous trouvez des frames plus longues que d’autres sur cette ligne, elles devraient correspondre à des chutes de FPS par rapport à la première ligne.
Enfin vous aurez la ligne “Main” pour Main Thread. Il s’agit d’un graphique appelé Flamechart qui représente la pile d’appels effectués par le thread principal pour le navigateur. Ce qui va nous intéresser sur ce diagramme, c’est en priorité les cases avec des rectangles rouges en haut à droite (voir image ci-dessous). En effet, le profiler est capable de détecter les appels qui semblent anormaux. On va également rechercher tout évènement anormalement long, un évènement avec une profondeur importante ou encore un déclenchement d’étapes du pipeline de pixel suspect.
Cas d’exemple :
Dans l’exemple ci-dessous, on va investiguer des problèmes mis en avant par le profiler. Ici, le profiler nous dit que des étapes du pipeline de pixel ont été déclenchées à tort. Ce problème s’appelle un forced reflow. Les étapes du pipeline de pixel se déroulent dans l’ordre de manière synchrone puis affichent la frame, il est néanmoins possible de déclencher l’étape de style ou de layout prématurément avec du JavaScript. Cela se produit généralement quand on modifie la taille d’un élément sur la page et qu’on essaye de faire une lecture de la nouvelle valeur juste après.
Waterfall du main thread | Résumé d’un événement par le profiler |
En allant vérifier la ligne de code mise en avant sur le profiler on retrouve bien la situation décrite ci-dessus. En effet, le code ci dessous va essayer de lire une valeur d’un élément qui a été modifié juste avant. Dans ce cas, un enfant de l’élément a été retiré, le navigateur doit donc refaire un layout pour pouvoir donner la valeur offsetHeight de celui-ci.
Code à l’origine d’un forced reflow |
À noter également qu'après avoir effectué un enregistrement avec le profiler, les temps d'exécution de votre code seront affichés à côté de chaque ligne. Cette information peut être très intéressante pour mettre en avant des problèmes de nature plus algorithmique.
Temps d'exécution dans les sources après un profiling |
Conclusion :
Vous savez maintenant que les performances de rendu sont tout aussi importantes que les performances de chargement afin d’avoir la meilleure expérience utilisateur sur votre interface. Il faut également garder à l’esprit que toutes les analyses que vous allez faire le seront avec le CPU de votre machine, d’où l’intérêt de limiter celui-ci si vous êtes sur une machine puissante.
Récapitulatif des techniques que vous pouvez utilisez dès maintenant pour améliorer vos performances de rendu :
- Utiliser les propriétés transform et opacity pour des changements visuels
- Essayer la propriété will-transform pour affecter une zone sur une couche à part
- Utiliser requestAnimationFrame pour les changements visuels asynchrones en JavaScript
- Exécuter les longues tâches JavaScript sur un web worker pour ne pas monopoliser le main thread
- Réduire la complexité de ses sélecteurs CSS et le nombre d’éléments sur lesquels ils sont appliqués
- Réduire la complexité du DOM et éviter les forced reflow
- Utiliser un debounce pour les saisies d’input
En espérant que le profiler vous effraie moins qu’avant. Bonne chance pour votre chasse aux bottlenecks !
Ressources :
https://developers.google.com/web/fundamentals/performance/rendering/
https://developers.google.com/web/tools/chrome-devtools/evaluate-performance/
https://developer.mozilla.org/fr/docs/Outils/Performance/Waterfall
https://developer.mozilla.org/fr/docs/Outils/Performance/Frame_rate
https://github.com/joshwcomeau/talk-2019