Echtzeit-DOM-Kollektionen, Layout-Probleme und die Erklärung zur Attributlücke
Erfahren Sie, warum der DOM ein in Echtzeit dargestellter Baum ist, wie dies dazu führt, dass Schleifenelemente übersprungen werden und Layouts erzwungen werden, sowie warum benutzerdefinierte Dateneigenschaften getAttribute benötigen.
Der DOM hat den Ruf, langsam zu sein, und die übliche Erklärung ist, dass es sich einfach um eine aufwändige Struktur handelt. Diese Erklärung ist größtenteils falsch. DOM-Operationen sind recht schnell; das Problem entsteht durch ein gängiges Programmiermuster, bei dem innerhalb einer Schleife immer wieder Informationen vom DOM abgerufen und Anweisungen gegeben werden, sich zu ändern. Um zu verstehen, warum das so ist, benötigt man zunächst ein genaues Verständnis dafür, was der DOM eigentlich ist – und dieses Verständnis erklärt auch zwei weitere Verhaltensweisen, die Entwickler häufig verwirren: Schleifen, die Elemente überspringen, sowie benutzerdefinierte Attribute, die den Wert undefined zurückgeben.
Der DOM ist ein lebender Baum, keine Kopie Ihres HTML
Wenn der Browser HTML parsen lässt, speichert er die Markierung nicht als Text. Stattdessen erstellt er für jedes Element ein Objekt und ordnet diese Objekte entsprechend der Markierung an, wodurch ein Baum entsteht. Betrachten wir ein kleines Produktkatalog:
<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>
Daraus erstellt der Browser einen Baum lebender Objekte, auf die JavaScript zugreifen kann: #catalog enthält zwei .product-Elemente, und jedes dieser Elemente wiederum enthält ein <h3> sowie ein <span>. Der entscheidende Punkt ist, dass dieser Baum keine einmalig erstellte Beschreibung ist, die anschließend beiseitegelegt wird. Es handelt sich vielmehr um die Struktur, die der Browser zum gegenwärtigen Zeitpunkt darstellt. Wenn man eine Eigenschaft eines dieser Knotenobjekte ändert, spiegelt die Seite diese Änderung sofort wider; es gibt keinen separaten Speichervorgang oder Aufruf zur Neudarstellung.
document.querySelector(".product h3").textContent = "Desk Lamp (Sale)";
Diese Anweisung ist keine Anfrage, die der Browser später im Auftrag des Benutzers verarbeiten soll. Die Zuweisung an sich stellt bereits die Änderung dar. Wenn man diese „lebende“ Eigenschaft des DOM im Hinterkopf behält, hat man das nützlichste mentale Modell dafür – und gleichzeitig auch die Quelle für einen Fehler, den fast jeder zu irgendeinem Zeitpunkt macht.
Warum eine Schleife über eine dynamische Sammlung Elemente überspringt
Nehmen wir an, Sie möchten jede Produktkarte löschen, die als ausverkauft markiert ist. Die offensichtliche Schleife sieht so aus:
const outOfStockCards = document.getElementsByClassName("out-of-stock");
for (let i = 0; i < outOfStockCards.length; i++) {
outOfStockCards[i].remove();
}
Sie scheint korrekt zu sein, lässt aber dennoch jedes zweite Element unberücksichtigt. Der Grund dafür ist, dass getElementsByClassName kein festes Array zurückgibt. Es wird eine dynamische HTMLCollection bereitgestellt, die sich mit jeder Änderung des Dokuments anpasst. Sobald .remove() die erste Karte entfernt, ist die Sammlung um ein Element kürzer und jede verbleibende Karte rückt einen Index nach unten. Der Schleifenzähler geht weiter voran, sodass das Element, das gerade in Index 0 gerutscht ist, nie besucht wird. Die Größe der Sammlung ändert sich während der Schleife, wodurch etwa die Hälfte der Elemente unberücksichtigt bleibt.
Die Lösung besteht darin, vor den Änderungen eine statische Kopie anzufertigen:
const outOfStockCards = Array.from(document.getElementsByClassName("out-of-stock"));
for (const card of outOfStockCards) {
card.remove();
}
Array.from erstellt ein Snapshot der laufenden Sammlung als gewöhnlichen Array, sodass spätere Löschungen das Iterationsobjekt nicht verkleinern können. Eine einfachere Option ist document.querySelectorAll(".out-of-stock"), das von Anfang an eine statische NodeList zurückgibt und keine Umwandlung erfordert. Genau diese Vorhersehbarkeit ist einer der Gründe, warum querySelectorAll die älteren Such-APIs in den meisten aktuellen Codebasen verdrängt hat. Wenn Sie unbedingt mit einer laufenden Sammlung arbeiten müssen, vermeidet das Rückwärtsiterieren vom letzten Index ebenfalls Verschiebungen – allerdings ist ein statischer Snapshot in der Regel klarer.
Layout-Thrashing: Der eigentliche Grund, warum DOM-Code langsam wirkt
Nun zum eigentlichen Leistungsproblem. Die Berechnung des Layouts, also die genaue Größe und Position jedes Elements, ist aufwändig. Browser vermeiden es, dies öfter als nötig zu tun: Wenn Sie Styles ändern, berechnen sie das Layout nicht sofort neu. Stattdessen sammeln sie die ausstehenden Änderungen und berechnen das Layout einmal, kurz bevor der nächste Frame dargestellt wird.
Diese Strategie funktioniert nur, solange Ihr Code es zulässt. Einige Eigenschaften und Methoden, darunter offsetWidth, offsetHeight und getBoundingClientRect(), benötigen aktuelle Geometriedaten, um einen Wert zurückzugeben. Ihr Ablesen zwingt den Browser dazu, das Layout synchron auszuführen, da er die Breite eines Elements nicht ohne Berechnung angeben kann.
Die folgende Schleife wechselt für jedes Kartenelement abwechselnd zwischen Lesen und Schreiben:
// 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
});
Jede Iteration liest eine Breite ein und schreibt anschließend eine neue. Die Leseoperation kann nicht auf veralteten Daten beruhen, da die im vorherigen Schritt angeordneten Stiländerungen das Ergebnis beeinflussen könnten. Deshalb leert der Browser die ausstehenden Änderungen, berechnet erneut das Layout, gibt die Zahl zurück – und bereits in der nächsten Zeile wird dieses Layout wieder ungültig gemacht. Dieser Zyklus wird als Layout-Thrashing (oder erzwungenes synchrones Layout) bezeichnet und ist das, was die Leute tatsächlich meinen, wenn sie sagen, der DOM sei langsam. Der DOM führt teure Aufgaben weitaus häufiger durch, als notwendig wäre, weil der Code dies ständig verlangt.
Trennen Sie die Arbeit in zwei Durchgänge auf: zuerst alle Lesevorgänge und danach alle Schreibvorgänge:
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
});
Nun wird die Layout-Berechnung nur einmal durchgeführt, wenn die erste Breite abgerufen wird, und die anschließenden Abfragen verwenden dasselbe Ergebnis, da sich in der Zwischenzeit nichts geändert hat. Die Schreibvorgänge werden dann in einer Warteschlange zusammengefasst und vor dem nächsten Malen verarbeitet. Der DOM ist dadurch nicht schneller geworden; der Code zwingt ihn einfach nicht mehr, bei jeder Iteration dieselbe Berechnung erneut durchzuführen.
Daraus ergeben sich einige praktische Hinweise:
- dieselbe Regel gilt für andere geometrische Abfragen wie
clientWidth,scrollTopundgetComputedStyle(). - In größeren Anwendungen finden Abfragen und Schreibvorgänge oft in verschiedenen Funktionen oder Komponenten statt, wodurch Probleme auftreten können, selbst wenn keine einzelne Schleife verdächtig erscheint. Browser-Performance-Tools heben erzwungene Layout-Anpassungen hervor, was deren Aufspüren erleichtert.
requestAnimationFrame ist eine gängige Methode, um sie unmittelbar vor der Darstellung zusammenzufassen.Eigenschaften und Attribute sind nicht immer dasselbe
Eine weitere Überraschung ergibt sich aus der Beziehung zwischen HTML-Attributen und JavaScript-Eigenschaften. Sie spiegeln sich in der Regel gegenseitig wider, doch dieses Spiegelbild hat Grenzen – und diese Grenzen spielen eine Rolle, sobald man benutzerdefinierte Daten hinzufügt. Nehmen wir dieses Element:
<div class="product" data-sku="LAMP-2201"></div>
Und diesen Code, der es auf drei verschiedene Arten liest:
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
Standardattribute, die vom Browser erkannt werden, wie href, src und class, werden automatisch als entsprechende Eigenschaften bereitgestellt. class erscheint als className, weil class in JavaScript ein reserviertes Wort war. Benutzerdefinierte Attribute erhalten diese Behandlung nicht, einschließlich jener, die mit data- präfixiert sind – dem Standardmechanismus zur Speicherung eigener Metadaten in Elementen. Es gibt keine el.sku-Eigenschaft, weshalb die Abfrage undefined zurückgibt; daher müssen getAttribute und setAttribute verwendet werden, um den Wert zu lesen oder zu ändern. Zur zusätzlichen Erleichterung stellen Browser data--Attribute auch über das dataset-Objekt bereit, sodass el.dataset.sku denselben Wert zurückgibt. Die Inkonsistenz ist gering, führt aber genau dazu, dass es zu Verwirrung kommt.
Zum ersten Mal, wenn Sie erwarten, dass ein benutzerdefiniertes Attribut sich wie href verhält, erhalten Sie undefined.
Ein mentales Modell anstelle von drei Fallstricken
Diese Verhaltensweisen sind keine separaten Randinformationen. Sie alle ergeben sich aus einer einzigen Tatsache: Der DOM ist eine laufende Struktur, die dargestellt wird, anstatt statischer Daten, die nur einmal ausgefüllt werden.
- Live-Kollektionen ändern sich, während Sie sie durchlaufen, daher sollten Sie sie abfotografieren oder
querySelectorAllverwenden, bevor Sie Änderungen vornehmen. - Das Lesen von Geometriedaten zwingt den Browser dazu, sofort die Darstellung zu berechnen, daher sollten Sie vor Schreibvorgängen mehrfach lesen.
- JavaScript-Eigenschaften dienen aus Bequemlichkeit über Attributen und spiegeln nicht alle davon wider, daher sollten Sie auf benutzerdefinierte Daten über
getAttributeoderdatasetzugreifen.
Sobald man das DOM als lebenden Baum und nicht als langsame Datenstruktur betrachtet, sind solche Phänomene keine Überraschungen mehr, sondern vorhersehbare Folgen. Für einen umfassenderen Überblick darüber, wie in einem Framework-Kontext aus Zustandsänderungen Pixel erzeugt werden, siehe wie React Zustandsänderungen in Bildpunkte umwandelt.
Verwandte Artikel
- React Rendering Explained: Zustandsänderungen in Bildpunkte — Erfahren Sie, wie Reacts Render-, Abgleich- und Commit-Phasen mit dem Layout-, Mal- und Kompositierungsprozess des Browsers verbunden sind, um Bildpunkte zu erzeugen.