Comment nous avons amélioré les performances de notre extension de 200 %

Le 2 février 2017 dernier, Ann Taylor a écrit l’article 12 Things We Need to See From WordPress Page Builders in 2017 + Who Already Gets It Right sur CodeInWP. Nous améliorons continuellement Page Builder Sandwich depuis plus d’un an maintenant : nous faisons nos recherches, écoutons nos clients, alors nous pensions que Page Builder Sandwich figurerait dans l’article « Who Got it Right » de CodeInWP. Mais ce n’a pas été le cas, à cause des performances. ?

Nous avons donc posé la question à Ann, et elle a répondu :

… J’ai un peu testé la version gratuite. C’est très prometteur : beaucoup de modules, une interface utilisateur plutôt intuitive, une excellente expérience WYSIWYG, tout se déplace facilement par glisser-déposer. Cependant, lorsque j’ai eu une dizaine de modules sur la page, il est devenu difficile de continuer à travailler avec en raison de la faible vitesse d’édition…

Selon elle, Page Builder Sandwich présentait donc des problèmes de performance. Et comme nous sommes toujours à l’écoute des retours, nous avons vérifié cela.

Tester les performances

Nous avions besoin d’un moyen de tester les performances de PBS ; comme PBS s’exécute principalement dans le navigateur via JavaScript, le profileur Timeline de Chrome était l’outil idéal pour cette tâche. Le profileur Timeline permet d’identifier et de mesurer les processus qui s’exécutent sur une page web pendant l’exécution, ce qui est parfait pour détecter les goulots d’étranglement de vitesse.

Nous avons pensé qu’il fallait d’abord tester l’étape la plus simple : passer notre curseur sur plusieurs éléments et lignes. Simple, n’est-ce pas ? Nous étions convaincus que nous ne rencontrerions aucun problème de performance avec une opération aussi basique. Malheureusement, nous avions tort.

Une fois le bouton d’enregistrement enclenché, nous avons déplacé la souris pendant environ 2 secondes sur l’écran, puis nous avons cliqué sur le bouton d’arrêt. Voici la timeline obtenue à partir du profileur :

Et voilà le résultat : c’est rempli à ras bord de goulots d’étranglement de performance !

Il se passe énormément de choses sur le graphique, mais le problème le plus flagrant est qu’il comporte beaucoup de zones violettes (ce qui ne devrait pas être le cas). De plus, ces zones violettes sont visiblement très larges et semblent toutes atteindre le pic du graphique. Vous remarquerez également que le graphique est constamment saturé et que les zones violettes ne retombent presque jamais à zéro. On dirait que le navigateur est toujours occupé à faire quelque chose et ne se repose pratiquement jamais.

Mais que signifient réellement toutes ces zones violettes ?

Le profileur a eu la gentillesse de nous indiquer qu’il s’agit de reflows. Voici la définition de ce qu’est un reflow selon le site Google Developers:

Le reflow est le nom du processus du navigateur web qui recalcule les positions et les géométries des éléments dans le document… le reflow est une opération bloquante pour l’utilisateur dans le navigateur.

Cela signifie donc que déplacer sa souris dans PBS déclenche une tonne de reflows. Pire encore : chaque reflow interrompt momentanément le navigateur pour qu’il puisse terminer ses calculs. Plus de reflows signifie plus de pauses du navigateur, et l’expérience devient encore plus saccadée (janky). En termes plus simples : beaucoup de zones violettes = mauvais.

Il s’avère donc qu’Ann avait raison : nous avons bel et bien un énorme goulot d’étranglement de performance, et notre équipe s’est immédiatement mise au travail pour corriger cela sur-le-champ.

Résoudre notre problème de reflow / saccades

Avant toute chose, nous devions faire des recherches pour découvrir ce qui provoque précisément ces reflows à la base. Nous avons découvert qu’apparemment, beaucoup de choses courantes en JavaScript peuvent pousser le navigateur à déclencher un reflow.

Presque toutes les fonctions permettant d’obtenir la largeur, la hauteur ou la position d’un élément du DOM peuvent déclencher un reflow. Paul Irish a rédigé une liste exhaustive de toutes les propriétés et fonctions en JavaScript susceptibles de provoquer cela. La mauvaise nouvelle est que Page Builder Sandwich utilise un grand nombre de ces propriétés et fonctions, qui provoquent toutes un reflow :

  • elem.getBoundingClientRect()
  • window.getComputedStyle()
  • window.scrollY
  • et bien d’autres encore…

Correctif n° 1 : Mettre en cache les processus de reflow coûteux

La solution la plus évidente est de NE PAS utiliser ces propriétés et fonctions. Cependant, PBS repose lourdement sur elles et nous ne pouvons pas nous en passer.

La seule solution qui s’offrait à nous était de réduire au maximum le nombre d’appels à ces fonctions. La partie la plus difficile résidait dans le fait que nous utilisions ces propriétés et fonctions à de nombreuses reprises dans différentes parties de PBS, et la plupart du temps, nous les utilisions plusieurs fois sur le même élément du DOM. Pour résoudre cela, nous avons créé notre propre mécanisme de mise en cache pour chaque propriété/fonction problématique.

Par exemple, la fonction getBoundingClientRect nous donne les mesures actuelles en pixels du haut, de la gauche, de la largeur, de la hauteur, de la droite et du bas d’un élément du DOM. Nous utilisons constamment cette fonction pour calculer l’emplacement où tracer les contours des éléments dans PBS. Voici la fonction de mise en cache que nous avons créée pour cela :

// Cache : nos client rects sont conservés ici, nous pouvons utiliser ceci pour les effacer plus tard.
var elemsWithBoundingRects = [];

window.pbsGetBoundingClientRect = function( element ) {

	// Vérifier si nous avons déjà obtenu le client rect auparavant.
	if ( ! element._boundingClientRect ) {

		// Si ce n'est pas le cas, le récupérer puis le stocker pour une utilisation ultérieure.
		element._boundingClientRect = element.getBoundingClientRect();
		elemsWithBoundingRects.push( element );
	}
	return element._boundingClientRect;
};
​

Au lieu d’appeler getBoundingClientRect, nous utilisons désormais pbsGetBoundingClientRect. Désormais, chaque fois que le client rect est interrogé, nous stockons cette valeur pour référence ultérieure, limitant ainsi le nombre de déclenchements de reflow.

Nous avions également besoin d’un moyen de vider le cache lorsque les client rects stockés deviennent invalides. La valeur du client rect stocké change lorsque l’utilisateur fait défiler la page ou ajoute/modifie/supprime des éléments à l’écran. Nous avons donc créé une fonction de vidage du cache que nous pouvons appeler chaque fois que nous avons besoin de valeurs de client rect actualisées :

var clearClientRects = function() {
	var i;
	for ( i = 0; i < elemsWithBoundingRects.length; i++ ) {
		if ( elemsWithBoundingRects[ i ] ) {
			elemsWithBoundingRects[ i ]._boundingClientRect = null;
		}
	}
	elemsWithBoundingRects = [];
};​

Nous avons également appliqué ce même mécanisme de mise en cache à d’autres appels de fonctions coûteux que nous utilisons couramment.

Correctif n° 2 : Regrouper les lectures et les écritures à l’aide de FastDom

L’application du correctif n° 1 a amélioré les performances de PBS, mais cela ne suffisait pas.

En faisant quelques recherches supplémentaires sur Google, nous sommes tombés sur un article de Wilson Page sur la prévention du « layout thrashing ». Wilson explique :

Le Layout Thrashing (reflow) se produit lorsque JavaScript écrit puis lit violemment et à de multiples reprises dans le DOM, provoquant des reflows du document.

Apparemment, il ne suffit pas de limiter les appels aux propriétés et fonctions spécifiques déclenchant des reflows. Lorsque vous effectuez plusieurs « lectures » et « écritures » successives sur le DOM, cela déclenche également un reflow du navigateur. Voici un scénario classique, directement tiré du site de Wilson :

// Lecture
var h1 = element1.clientHeight;

// Écriture (invalide la mise en page)
element1.style.height = (h1 * 2) + 'px';

// Lecture (déclenche un reflow)
var h2 = element2.clientHeight;

// Écriture (invalide la mise en page)
element2.style.height = (h2 * 2) + 'px';

// Lecture (déclenche un reflow)
var h3 = element3.clientHeight;

// Écriture (invalide la mise en page)
element3.style.height = (h3 * 2) + 'px';

// Le document subit un reflow à la fin de la frame​

L’exemple ci-dessus déclenche 3 reflows car les dimensions de toute la page sont invalidées après une écriture. La correction suggérée pour cela consiste à réorganiser le code pour regrouper toutes les lectures et écritures afin que l’ensemble ne déclenche qu’un seul reflow du navigateur :

// Lecture
var h1 = element1.clientHeight;
var h2 = element2.clientHeight;
var h3 = element3.clientHeight;

// Écriture (invalide la mise en page)
element1.style.height = (h1 * 2) + 'px';
element2.style.height = (h2 * 2) + 'px';
element3.style.height = (h3 * 2) + 'px';

// Le document subit un reflow à la fin de la frame​

L’extrait ci-dessus donne l’impression qu’il est facile de résoudre le problème. Mais en réalité, dans PBS, la lecture et l’écriture d’éléments déclencheurs de reflows interviennent à de nombreux endroits différents et à des moments très divers pendant le cycle de vie de PBS. Réorganiser des milliers de lignes de code JavaScript n’est pas envisageable.

Heureusement pour nous, Wilson proposait également une solution dans son article. Il a créé un petit script utilitaire pour aider à regrouper ces lectures et écritures, appelé FastDom.

Voici un extrait de code de Page Builder Sandwich qui utilise FastDom et intègre également notre correctif n° 1 ci-dessus :

// Regrouper les lectures provenant d'autres threads.
fastdom.measure( function() {

	// Utiliser notre client rect mis en cache.
	var rect = window.pbsGetBoundingClientRect( elem );

	// Regrouper les écritures provenant d'autres threads.
	fastdom.mutate( function() {
		elem.style.top = ( rect.top + window.pbsScrollY() ) + 'px';
		elem.style.height = rect.height + 'px';
		elem.style.left = rect.left + 'px';
		elem.style.width = rect.width + 'px';
	}.bind( this ) );
}.bind( this ) );​

Ce que fait FastDom, c’est regrouper tous les appels fastdom.measure et tous les appels fastdom.mutate et les exécuter de manière asynchrone, regroupant ainsi efficacement toutes les lectures et écritures pour réduire l’ensemble des reflows dans le navigateur.

Correctif n° 3 : L’accès aux objets Style est coûteux

Nous avons également découvert à l’aide de jsPerf que certaines fonctions de récupération de style étaient plus rapides que d’autres.

Dans PBS, nous avons généralement besoin d’obtenir la valeur actuelle d’un style spécifique d’un élément. Par exemple, nous utilisons habituellement ceci pour obtenir la marge supérieure :

var styles = window.getComputedStyle( elem );
var marginTop = styles.marginTop;​

Cependant, nous avons découvert que procéder ainsi est bien plus rapide :

var marginTop = elem.style.marginTop;​

Néanmoins, elem.style.marginTop et getComputedStyle sont très différents. Le premier ne nous donne que le style en ligne de l’élément et ne prend en compte aucune feuille de style ; tandis que le second prend en compte à la fois les styles en ligne et les feuilles de style.

De plus, même lorsque le résultat de getComputedStyle était mis en cache, cela restait particulièrement lent. En réalité, c’est l’accès à l’objet de style lui-même qui s’avère être un processus lent.

Heureusement pour nous, PBS ajoute des styles en ligne aux éléments, et comme les styles en ligne ont la priorité (la plupart du temps) sur les feuilles de style, il y a des situations où récupérer la valeur du style en ligne suffit amplement. Ainsi, si nous combinons les deux méthodes ci-dessus, nous pouvons concevoir un code dont l’exécution est bien plus rapide :

var marginTop = elem.style.marginTop;
if ( ! marginTop ) {
	var styles = window.getComputedStyle( elem );
	marginTop = styles.marginTop;
}​

Les résultats des améliorations du code

Nos 3 correctifs ci-dessus ont nécessité beaucoup de recherche, de tests et d’implémentation effective (et ont retardé le lancement de notre fonctionnalité de polices), mais au final, le jeu en valait la chandelle.

Revenons à notre test initial consistant à déplacer la souris sur l’écran pendant 2 secondes. Voici l’ancienne timeline, lente et saccadée :

Et voici à quoi ressemble notre nouvelle timeline après les améliorations de performances :

Comme vous pouvez le constater, il y a moins d’éléments violets sur le graphique. Et il y a une différence notable dans la quantité de processus exécutés par le navigateur. Les rectangles du bas sont également plus fins, ce qui signifie que les processus s’exécutent plus rapidement qu’auparavant.

Si vous comparez le volume de zones violettes hautes et larges sur l’ancien et le nouveau graphique, nous constatons une hausse phénoménale de 200 % des performances ! Nous constatons également des gains de performances sur tous les plans. Pas mal du tout !

Enseignements et leçons à retenir

Pour être honnête, nous ne pensions pas que PBS souffrait de problèmes de performance avant qu’Ann ne l’évoque. Principalement parce que nous utilisions des machines dédiées au développement et que les goulots d’étranglement de performance n’étaient pas aussi évidents. Avec le recul, la vérification des performances aurait dû figurer parmi les priorités absolues. Mais au moins maintenant, à compter de la version 4.3, c’est réglé. ?

Pour ceux qui souhaitent améliorer les performances de leur propre extension WordPress, en particulier si vous utilisez également beaucoup de JavaScript, voici ce que je retiens de cette expérience :

  1. Limitez/mettez en cache vos appels aux propriétés/fonctions qui déclenchent un reflow. Utilisez ceci comme guide : What forces layout/reflow. The comprehensive list.
  2. Regroupez les « lectures » et « écritures » apportées aux styles du DOM. Utilisez FastDom pour le faire à votre place.
  3. Utilisez le profileur Timeline de Chrome et assurez-vous d’avoir le moins de reflows (zones violettes) possible.