Reflows et repaints : pourquoi votre page semble saccadée
Vous mesurez la hauteur d'un élément avec offsetHeight, vous l'utilisez pour fixer la largeur d'un autre, vous relisez cette largeur et vous fixez la hauteur d'un troisième, et voilà que cette fonction de quinze lignes fait à elle seule s'effondrer votre fréquence d'images. Le coupable n'apparaît presque jamais comme du « JavaScript lent » à la lecture naïve du code. C'est le navigateur qui recalcule silencieusement toute la mise en page, plusieurs fois, à cause de l'ordre dans lequel vous avez demandé les choses.
Ce qu'est réellement un reflow
Un reflow (aussi appelé layout) est le recalcul par le navigateur de la géométrie de la page (où se trouve chaque élément, quelle est sa taille, comment le texte se replie) parce que quelque chose qui affecte les positions ou les tailles a changé : un changement de classe modifiant width, un nouvel élément inséré dans le DOM, ou même une police qui finit de se télécharger. Un repaint est moins coûteux : la géométrie n'a pas bougé, mais des pixels doivent être redessinés parce qu'une couleur ou une ombre a changé. Les reflows sont les plus coûteux, car un seul changement de mise en page peut se propager en cascade : redimensionner une boîte peut décaler tout ce qui se trouve en dessous et à côté.
Les navigateurs le savent, donc par défaut ils regroupent le travail de mise en page. Si vous modifiez cinq propriétés de style d'affilée, le navigateur ne recalcule pas la mise en page cinq fois : il attend la prochaine image, fait un seul passage, puis passe à autre chose.
Le piège : lire des propriétés de mise en page
Ce regroupement s'effondre dès que vous lisez une propriété dépendant de la mise en page après en avoir écrit une. Des propriétés comme offsetHeight, offsetWidth, getBoundingClientRect() et scrollHeight ne peuvent recevoir une réponse correcte que si la mise en page interne du navigateur est à jour : en lire une force donc un recalcul immédiat et synchrone, qui annule l'optimisation par regroupement.
const box = document.querySelector('.box');
box.style.width = '300px'; // write
const height = box.offsetHeight; // read — forces a synchronous reflow right here
box.style.height = height + 'px'; // write
Une seule paire écriture-puis-lecture comme celle-ci n'est pas catastrophique. Les vrais dégâts surviennent quand ce schéma s'exécute dans une boucle :
const items = document.querySelectorAll('.item');
items.forEach((item) => {
item.style.width = '100px'; // write
const height = item.offsetHeight; // read — forces reflow, every iteration
item.style.height = `${height * 2}px`;
});
On appelle cela le « layout thrashing » : écrire, lire, écrire, lire, chaque lecture forçant le navigateur à refaire un travail de mise en page qu'il vient de faire, encore et encore, une fois par élément. Cent éléments, ce sont cent reflows synchrones forcés pour ce qui aurait dû en être un seul.
La correction : regrouper les lectures, puis regrouper les écritures
Une fois le schéma repéré, la correction est presque mécanique : séparer toutes les lectures de toutes les écritures.
const items = document.querySelectorAll('.item');
// Read phase — collect everything first
const heights = Array.from(items).map((item) => item.offsetHeight);
// Write phase — apply everything after
items.forEach((item, i) => {
item.style.width = '100px';
item.style.height = `${heights[i] * 2}px`;
});
Il y a maintenant exactement un lot de lectures (le navigateur peut toutes les satisfaire avec la mise en page qu'il possède déjà, puisque rien n'a changé entre-temps) et un lot d'écritures (que le navigateur peut différer et fusionner en un seul reflow avant le prochain rendu).
Planifier les écritures avec requestAnimationFrame
Pour les changements de mise en page déclenchés par des gestionnaires de scroll ou de redimensionnement, enveloppez l'écriture dans requestAnimationFrame pour qu'elle intervienne juste avant le cycle de rendu du navigateur au lieu d'entrer en concurrence avec lui :
let ticking = false;
window.addEventListener('scroll', () => {
if (ticking) return;
ticking = true;
requestAnimationFrame(() => {
const scrollY = window.scrollY; // read
header.style.transform = `translateY(${Math.min(scrollY, 100)}px)`; // write
ticking = false;
});
});
L'indicateur ticking empêche de mettre en file plus d'une image de travail en attente : sans lui, un défilement rapide peut empiler des dizaines de callbacks qui se déclenchent tous à la suite.
Préférer les propriétés qui évitent complètement la mise en page
Certaines propriétés CSS peuvent être animées sans déclencher de mise en page ni même de rendu : le navigateur peut les confier directement au compositeur. transform et opacity sont les deux qui comptent le plus en pratique :
/* Triggers layout on every frame — avoid animating this */
.slide-in {
transition: left 0.3s ease;
}
/* Compositor-only — cheap regardless of how large the element is */
.slide-in {
transition: transform 0.3s ease;
}
// Instead of animating `left`, `top`, `width`, or `height`...
el.style.left = '200px';
// ...animate transform, which doesn't touch layout at all
el.style.transform = 'translateX(200px)';
Pour un élément qui bouge ou s'estompe, c'est généralement un remplacement direct, et c'est le changement au meilleur rendement que l'on puisse faire sur une animation saccadée.
Repérer un thrashing que vous ne soupçonniez pas
Le panneau Performance des DevTools le signale directement : les reflows enregistrés apparaissent comme des barres violettes « Layout », et Chrome signale spécifiquement un « Forced reflow » dans la console quand un script en déclenche un, avec une trace de pile pointant la ligne exacte qui a lu une propriété de mise en page en plein lot. Enregistrez quelques secondes de l'interaction qui paraît lente et cherchez un motif en dents de scie de barres violettes ; une longue plage jaune (script) sans violet jusqu'à la fin de l'image signifie que vous ne faites pas de thrashing, mais un simple calcul lent : un autre problème, avec une autre solution.
À retenir
Le navigateur regroupe volontiers le travail de mise en page pour vous, jusqu'à ce que vous lisiez une propriété géométrique en plein lot et le forciez à rattraper son retard plus tôt. Séparez lectures et écritures, différez les écritures vers requestAnimationFrame quand elles sont pilotées par des événements continus, et préférez transform/opacity à left/width/height partout où l'effet visuel le permet. Rien de cela n'exige un framework ni une bibliothèque : tout tient à l'ordre dans lequel les opérations se produisent.