Les collections DOM en temps réel, les problèmes de mise en page et l’explication du manque d’attributs
Découvrez pourquoi le DOM est un arbre affiché en temps réel, comment cela entraîne l’omission de certains éléments dans la boucle et des mises en page forcées, ainsi que pourquoi les attributs de données personnalisés nécessitent la méthode getAttribute.
Le DOM a la réputation d’être lent, et l’explication habituelle est qu’il s’agit simplement d’une structure lourde. Cette explication est en grande partie fausse. Les opérations sur le DOM sont raisonnablement rapides ; ce qui ralentit les choses, c’est un schéma de codage courant qui alterne sans cesse entre la demande d’informations au DOM et l’instruction de le modifier, encore et encore à l’intérieur d’une boucle. Pour comprendre pourquoi, il faut d’abord avoir une vision précise de ce qu’est le DOM, et cette même vision explique deux autres comportements qui confondent régulièrement les développeurs : des boucles qui sautent des éléments, et des attributs personnalisés qui renvoient undefined.
Le DOM est un arbre dynamique, pas une copie de votre HTML
Lorsque le navigateur analyse l’HTML, il ne conserve pas le code comme du texte. Il crée un objet pour chaque élément et imbrique ces objets selon le code, formant ainsi un arbre. Prenons l’exemple d’un petit catalogue de produits :
<div id="catalog">
<div class="product">
<h3>Desk Lamp</h3>
<span class="price">$34</span>
</div>
<div class="product">
<h3>Standing Mat</h3>
<span class="price">$58</span>
</div>
</div>
D’après cela, le navigateur construit un arbre d’objets en temps réel auxquels JavaScript peut accéder : #catalog contient deux éléments .product, et chacun de ces éléments contient un <h3> ainsi qu’un <span>. Le point clé est que cet arbre n’est pas une description générée une seule fois puis mise de côté. C’est la structure que le navigateur affiche à cet instant précis. Si vous modifiez une propriété sur l’un de ces objets nœud, la page en tient compte immédiatement ; il n’y a ni étape de sauvegarde séparée ni appel de rendu supplémentaire.
document.querySelector(".product h3").textContent = "Desk Lamp (Sale)";
Cette instruction n’est pas une demande que le navigateur traitera plus tard à votre place. L’attribution elle-même constitue la modification. Garder en tête cette qualité « en temps réel » est le modèle mental le plus utile pour comprendre le DOM, et c’est également à l’origine d’une erreur que presque tout le monde commet à un moment donné.
Pourquoi une boucle sur une collection dynamique omet des éléments
Supposons que vous souhaitiez supprimer toutes les cartes de produit marquées comme en rupture de stock. La boucle évidente ressemble à ceci :
const outOfStockCards = document.getElementsByClassName("out-of-stock");
for (let i = 0; i < outOfStockCards.length; i++) {
outOfStockCards[i].remove();
}
Elle semble correcte, mais elle laisse silencieusement de côté un élément sur deux. La raison en est que getElementsByClassName ne fournit pas un tableau fixe. Il renvoie une HTMLCollection dynamique qui reflète l’état du document au fur et à mesure que celui-ci change. Dès que .remove() supprime la première carte, la collection perd un élément et toutes les cartes restantes descendent d’un index. Le compteur de la boucle continue de progresser, ce qui fait que l’élément qui vient d’occuper l’index 0 n’est jamais visité. La taille de la collection change en continu pendant la boucle, et environ la moitié des éléments correspondants échappent ainsi à la suppression.
La solution consiste à prendre une copie statique avant de commencer à modifier la collection :
const outOfStockCards = Array.from(document.getElementsByClassName("out-of-stock"));
for (const card of outOfStockCards) {
card.remove();
}
Array.from crée un instantané de la collection en temps réel sous forme d’un tableau ordinaire, de sorte que les suppressions ultérieures ne peuvent pas réduire l’ensemble sur lequel vous itérez. Une option plus simple est document.querySelectorAll(".out-of-stock"), qui renvoie dès le départ une NodeList statique sans nécessiter de conversion. Cette prévisibilité est l’une des raisons pour lesquelles querySelectorAll a en grande partie évincé les anciennes API de recherche des bases de code actuelles. Si vous devez travailler avec une collection en temps réel, itérer à rebours depuis l’index final permet également d’éviter ce décalage, bien qu’un instantané statique soit généralement plus clair.
Problèmes de mise en page : la véritable raison pour laquelle le code DOM semble lent
Passons maintenant au véritable problème de performance. Le calcul du layout, c’est-à-dire la taille et la position précises de chaque élément, est coûteux. Les navigateurs évitent de le faire plus que nécessaire : lorsque vous modifiez les styles, ils ne recalculent pas immédiatement le layout. Ils collectent les modifications en attente et calculent le layout une seule fois, juste avant que la prochaine frame ne soit affichée.
Cette stratégie ne fonctionne que tant que votre code le permet. Certaines propriétés et méthodes, dont offsetWidth, offsetHeight et getBoundingClientRect(), ont besoin de géométries à jour pour retourner une valeur. Les lire force le navigateur à effectuer le calcul du layout de manière synchrone, car il ne peut pas indiquer la largeur d’un élément sans la calculer.
La boucle suivante alterne une lecture et une écriture pour chaque carte :
// forces a full layout recalculation on every single iteration
const cards = document.querySelectorAll(".product");
cards.forEach((card) => {
const width = card.offsetWidth; // read: forces layout
card.style.width = width + 10 + "px"; // write: invalidates layout again
});
Chaque itération lit une largeur puis en écrit une nouvelle. La lecture ne peut pas se baser sur des données obsolètes, car les modifications de style en attente depuis l’itération précédente pourraient affecter le résultat. Le navigateur efface donc les modifications en attente, recalcule le layout, renvoie la valeur, puis la ligne suivante invalide à nouveau ce layout. Ce cycle est connu sous le nom de « layout thrashing » (ou layout synchrone forcé), et c’est ce que les utilisateurs ressentent réellement lorsqu’ils affirment que le DOM est lent. Le DOM effectue des tâches coûteuses bien plus souvent que nécessaire, car le code continue de le solliciter.
Séparez les opérations en deux étapes : toutes les lectures d’abord, puis toutes les écritures ensuite :
const cards = document.querySelectorAll(".product");
const widths = Array.from(cards).map((card) => card.offsetWidth); // all reads, together
cards.forEach((card, i) => {
card.style.width = widths[i] + 10 + "px"; // all writes, together
});
Désormais, le calcul du layout a lieu une seule fois, lors de la première lecture de la largeur, et les lectures ultérieures utilisent ce même résultat car rien n’a été modifié entre-temps. Les écritures sont alors mises en file d’attente et traitées ensemble avant la prochaine mise à jour de l’affichage. Le DOM n’est pas devenu plus rapide ; le code a simplement cessé de le forcer à refaire ce même calcul à chaque itération.
Quelques remarques pratiques en découlent :
- La même règle s’applique aux autres lectures de géométrie telles que
clientWidth,scrollTopetgetComputedStyle(). - Dans les applications plus complexes, les lectures et les écritures ont souvent lieu dans des fonctions ou des composants différents, ce qui peut provoquer des problèmes même en l’absence de boucle suspecte. Les outils de performance des navigateurs mettent en évidence les layouts forcés, ce qui facilite leur détection.
requestAnimationFrame est une méthode courante pour les regrouper juste avant l’affichage.Les propriétés et les attributs ne sont pas toujours la même chose
Une dernière surprise provient de la relation entre les attributs HTML et les propriétés JavaScript. Ils se reflètent généralement l’un l’autre, mais ce miroir a des limites, et ces limites deviennent importantes dès que l’on ajoute des données personnalisées. Prenons cet élément :
<div class="product" data-sku="LAMP-2201"></div>
Et ce code qui lit à partir de lui de trois manières différentes :
const el = document.querySelector(".product");
console.log(el.className); // "product" — standard attributes map to properties directly
console.log(el.sku); // undefined — custom attributes don't
console.log(el.getAttribute("data-sku")); // "LAMP-2201" — this is how you actually reach it
Les attributs standards connus par le navigateur, tels que href, src et class, sont automatiquement exposés en tant que propriétés correspondantes. class apparaît sous le nom de className car ce mot était réservé en JavaScript. Les attributs personnalisés ne bénéficient pas de ce traitement, y compris ceux préfixés par data-, mécanisme standard pour stocker ses propres métadonnées sur les éléments. Il n’existe pas de propriété el.sku, ce qui fait que la recherche renvoie undefined ; il faut donc utiliser getAttribute et setAttribute pour lire ou modifier la valeur. Par commodité supplémentaire, les navigateurs exposent également les attributs data- via l’objet dataset, de sorte que el.dataset.sku renvoie la même valeur. L’incohérence est mineure, mais c’est précisément ce qui crée une confusion
ode>undefined la première fois que vous attendez d’un attribut personnalisé qu’il se comporte comme href. Un modèle mental plutôt que trois pièges
Ces comportements ne sont pas des détails isolés. Ils découlent tous d’un même fait : le DOM est une structure dynamique qui est rendue en temps réel, et non des données inertes que l’on remplit une seule fois.
- Les collections dynamiques changent pendant que vous les parcourez, il faut donc en prendre un snapshot ou utiliser
querySelectorAllavant de les modifier. - Lire des informations géométriques oblige le navigateur à calculer immédiatement la mise en page, il convient donc de lire en batch avant d’écrire.
- Les propriétés JavaScript sont ajoutées par commodité au-dessus des attributs et ne reflètent pas tous ceux-ci, il faut donc accéder aux données personnalisées via
getAttributeoudataset.
Lorsque vous considérez le DOM comme un arbre dynamique plutôt que comme une structure de données lente, ces phénomènes cessent d’être des surprises pour devenir des conséquences prévisibles. Pour une vue plus complète du processus par lequel les changements d’état se transforment en pixels au sein d’un framework, consultez comment React transforme les mises à jour d’état en pixels à l’écran.
Lectures complémentaires
- React Rendering Explained: State Updates to Screen Pixels — Découvrez comment les phases de rendu, de conciliation et d’enregistrement de React s’articulent avec les processus de mise en page, de peinture et de composition du navigateur pour générer des pixels.