Performance-Probleme im DOM beheben

JavaScript Performance
Kommentare

Mit JavaScript konnte man schon immer 320 km/h fahren. Nur, dass es jetzt jeder weiß. Was nicht zwangsläufig bedeutet, dass wir deshalb alle gleich erstklassige Formel- 1-Piloten wären. Schnelle Prozessoren gaukeln uns vor, dass wir schnelle Programme hätten. Das mag bei kleinen Programmen funktionieren, mit größeren fliegen wir aus der Kurve. Wir brauchen Training und ein tieferes Verständnis der Sprache, um skalierbare Applikationen programmieren zu können. Performance war vernachlässigbar, als es nur darum ging, skriptgesteuert die Hintergrundfarbe eines HTML-Dokuments zu verändern. Jetzt – speziell im Hinblick auf DOM-Manipulationen und die neuen Möglichkeiten von HTML5 – ist die Frage nach effizientem Code nicht länger unter den Teppich zu kehren.

Üblicherweise sind es die immer gleichen Aspekte beim Umgang mit dem DOM, die sich negativ auf die Performance auswirken:

  1. Extensive DOM-Manipulationen (zum Beispiel, wenn neue Elemente eingesetzt werden)
  2. Suboptimale Ansätze beim Selektieren bestimmter DOM-Elemente
  3. Zu viele vom Script erzeugte Reflows

Ein Reflow vollzieht sich immer dann, wenn die Veränderung einer Eigenschaft eines bestimmten DOM-Elements es erfordert, die Anzeige im Browser zu erneuern, auch wenn sich die Struktur des Dokuments selbst nicht ändert. Egal, ob Sie die Sichtbarkeit eines Elements verändern, die Hintergrundfarbe wechseln oder eine Rahmenfarbe zuweisen – jedes Mal wird ein Reflow angestoßen. Dies ist eine kostspielige Aktion, weil alle Elemente im Hinblick auf sie betreffende Veränderungen untersucht und ggf. ebenfalls verändert werden müssen. Aber auch die Veränderung der Größe des Browserfensters oder die Veränderung der Dokumentenstruktur hat Reflows zur Folge. Die Rahmenfarbe eines Elements zu verändern, sieht nach einem harmlosen Eingriff aus, tatsächlich ist es Schwerstarbeit für den Browser, weil jede noch so einfache Veränderung Elternelemente, Kinder, nachfolgende Elemente, unter Umständen das ganze Dokument betreffen. Was immer durch eine Veränderung andere Veränderungen nach sich ziehen könnte, muss in die Betrachtung einbezogen werden. Ein verheerender Umstand, besonders bei mobilen Endgeräten mit limitierter Rechenpower. Opera z. B. wartet mit Reflows bis ausreichend Änderungen gemacht, genug Zeit verstrichen oder das Ende des Threads erreicht ist, erst dann werden Änderungen ausgewertet – und resultieren dann womöglich nur in einem Reflow. Auch wichtig in diesem Zusammenhang: Manche Elemente verhalten sich bei einem Reflow performanter als andere – Tabellenzellen z. B. verbrauchen mehr Zeit als Blockelemente. In jedem Fall gilt: Performanceoptimierung im Bereich DOM-Manipulationen bedeutet in erster Linie, die Anzahl der Reflows zu minimieren. Aber der Reihe nach. Zunächst sollten Sie versuchen, die Anzahl der DOM-Elemente zu minimieren. Egal, ob Reflow, Traversierung oder Manipulationen – je weniger Elemente, desto besser! So verschaffen Sie sich, z. B. in der interaktiven Firefox-Konsole, schnell einen Überblick:

document.getElementsByTagName("*").length

Verringern Sie die Suchmenge

Machen wir uns zunächst mit den nativen Funktionen vertraut, um bestimmte Elemente oder Listen von Elementen aus dem DOM zu selektieren. Auch wenn die meisten Entwickler  in ihrer täglichen Arbeit mächtige Selector-APIs wie jQuerys Sizzle oder Mootools’ Slick verwenden, ist ein tieferes Verständnis dieser Funktionen unbedingt notwendig, denn auch Bibliotheken wie Sizzle oder Slick sind daraufhin optimiert, komplexe Selektoren wann immer möglich auf native Funktionen zurückzuführen. Wir werden zu einem späteren Zeitpunkt darauf noch näher eingehen. Beginnen wir mit einem (zugegebenermaßen schlechten) Beispiel, ein unbekanntes Element im DOM zu finden, von dem wir nur wissen, dass es ein bestimmtes Attribut hat:

// slow, searches every element
var all_elems  = 
      document.getElementsByTagName("*"),
    all_length = all_elems.length;

for (var i = 0; i < all_length; i++) {
  if (all_elems[i].hasAttribute("someattr")) {
    // do something
  }
}

Zwei wesentliche Faktoren verzögern die Ausführung des obigen Codes. Zum einen wird absolut jedes Element des Dokuments untersucht, ohne jegliche Eingrenzung der Suchmenge. Zum anderen gibt es keine Abbruchbedingung für den Fall, dass das gesuchte Element gefunden wurde. Wenn wir zunächst einmal den Suchraum begrenzen (vielleicht, weil wir wissen, dass das gesuchte Element unterhalb eines div-Elements mit der ID inhere liegt), dann ist unser Beispiel schon signifikant schneller:

var all_elems = document.
      getElementById("inhere").
      getElementsByTagName("*"),
    all_length = all_elems.length;

for (var i = 0; i < all_length; i++) {
  // first test for valid node type
  if ((all_elems[i].nodeType === 1) && 
      all_elems[i].hasAttribute("someattr")) {
    // do something, then break out if we 
    // found what we were looking for
    break;
  }
}

Für den Augenblick genügt es, wenn Sie dafür sensibilisiert sind, so wenig wie möglich manuell über DOM-Elemente zu iterieren. Es gibt bessere Möglichkeiten, das Document Object Model zu traversieren als rekursiv childNodes-Collections zu untersuchen. Mehr über clevere Selektoren und native Funktionen finden Sie im Abschnitt über jQuery. Und es ist auch nichts Falsches daran, eine Selector-Engine wie Sizzle, Slick oder Qwery zu verwenden (viele schlaue Köpfe arbeiten daran!), solange Sie kritisch bleiben und ein ungefähres Verständnis dafür entwickeln, was unter der Haube passiert. Und um ein paar Listenelemente aus einem Dokument zu klauben, muss man nicht die Spezifikation des neuen Selectors API studiert haben, wirklich nicht!

[header = Arrays statt Collections]

Arrays statt Collections

Die nativen Funktionen getElementsByTagName und getElementsByClassName geben wider Erwarten kein Array zurück, sondern so genannte HTML Collections (auch NodeLists genannt). Auch document.forms und document.images sind solche HTML Collections. Und diese Sonderform einer Liste ist – gemäß DOM-Level-1-Spezifikation – live. Das bedeutet, wenn Sie an anderer Stelle ein DOM-Element verändern, das Teil Ihrer selektierten Liste ist, so ist diese Veränderung auch unmittelbar am DOM-Element in Ihrer HTML Collection erkennbar. In diesem feinen Unterschied verstecken sich zahlreiche Stolpersteine im Umgang mit dem DOM. Mal ganz davon abgesehen, dass diese Vorgehensweise natürlich ausgesprochen speicher- und zeitintensiv ist. Denn auch wenn HTML Collections wie Arrays erscheinen, so sind sie doch etwas völlig anderes, nämlich das Resultat einer spezifischen Query. Jedes Mal, wenn Sie auf ein bestimmtes Objekt dieser Liste lesend oder schreibend zugreifen, wird die Query erneut ausgeführt, was auch Einfluss auf periphere Aspekte wie das length-Attribut hat. HTML Collections verhalten sich wie Arrays in dem Sinne, dass sie eine Liste von Elementen exponieren, die über numerische Indices erreichbar sind und auch über eine Eigenschaft length verfügen, leider sind sie aber aus den oben genannten Gründen vergleichsweise langsam. Und darüber hinaus anfällig für Endlosschleifen:

var div, divs = 
  document.getElementsByTagName("div");

for (var i = 0; i < divs.length; i++) {
  div = document.createElement("div");
  document.body.appendChild(div);
}

divs referenziert eine HTML Collection, deren length-Eigenschaft bei jedem Durchlauf erneuert wird. Jedes Mal, wenn ein neues Element hinzugefügt wird, ändert sich auch die Eigenschaft length, die Schleifenbedingung i < divs.length ist also immer gültig. Schauen wir uns das folgende Beispiel an:

var i,
    divs_nl  = 
      document.getElementsByTagName("div"),
    divs_arr = 
      Array.prototype.slice.call(divs_nl),
    lng      = divs_arr.length;

// divs_nl.constructor  is NodeListConstructor
// divs_arr.constructor is function Array

for (i = 0; i < lng; i++) {
  divs_nl[i].appendChild(
    document.createElement('p')
  );
}

for (i = 0; i < lng; i++) {
  divs_arr[i].appendChild(
    document.createElement('p')
  );
}

Wenn wir die Eigenschaft length cachen, können wir gefahrlos über unsere Liste iterieren. Und wenn wir unsere HTML Collection darüber hinaus noch in ein Array-Konstrukt überführen, erreichen wir eine überwätigende Beschleunigung unseres Codes (Benchmark: http://jsperf.com/nodelist-vs-array-comp). Wie auch immer Sie vorgehen möchten, denken Sie in jedem Fall daran, die Länge Ihrer HTML Collection zu cachen.

[header = Vorsicht, Reflow!]

Vorsicht, Reflow!

Wenn Sie komplexe Inhalte, wie z.B. zusätzliche Tabellenzeilen, dynamisch ins DOM einfügen müssen, dann sollten Sie dies unbedingt offline erledigen. Denn wie Sie ja jetzt wissen, erzeugt jeder manipulative Zugriff auf ein DOM-Element Reflows, was es unter allen Umständen zu vermeiden gilt. Nehmen Sie Veränderungen im DOM also nur dann vor, wenn Sie absolut nicht zu vermeiden sind. Wenn wir also (im Sinne der ursprünglichen Aufgabenstellung) eine Tabelle erweitern möchten, sollten wir die Tabelle zunächst aus dem DOM entfernen (z. B. mit der Methode parentElement.removeChild), dann die notwendigen Veränderungen vornehmen und anschließend die erweiterte Struktur wieder an der betreffenden Stelle ins DOM einsetzen. Hier ein Beispiel mit jQuery:

var table  = $("#some-table"),
    parent = table.parent();
 
table.remove();
table.addLotsAndLotsOfRows();
parent.append(table);

Oder nutzen Sie documentFragment, um unnötige Reflows zu vermeiden. Ein Document Fragment ist im Grunde nichts anderes als eine dokumentenähnliche Struktur, allerdings ohne visuelle Repräsentation im Browser. Und dass es keine visuelle Repräsentation gibt, hat zahlreiche Vorteile, vor allem, dass Erweiterungen der Struktur keine Reflows verursachen. Oder besser gesagt: nur einen einzigen, nämlich wenn Sie das fertige Document Fragment ins Dokument einsetzen. Langsam:

var list = ["foo", "bar", "baz"],
    l    = list.length,
    elem, content;

for (var i = 0; i < l; i++) {
  elem    = document.createElement("div");
  content = document.createTextNode(list[i]);
  elem.appendChild(content);
  document.body.appendChild(elem);
}

Schneller:

var fragment = 
      document.createDocumentFragment(),
    list = ["foo", "bar", "baz"],
    l    = list.length,
    elem, content;

for (var i = 0; i < l; i++) {
  elem    = document.createElement("div");
  content = document.createTextNode(list[i]);
  fragment.appendChild(content);
}

document.body.appendChild(fragment);

Eine weitere Möglichkeit: Modifizieren Sie den Klon eines Objekts und tauschen Sie nach Abschluss der Änderungen Original und Klon aus. Auch diese Technik resultiert in einem einzigen Reflow (diese Technik erweist sich allerdings als problematisch, wenn Ihre Struktur Formelemente enthält, deren Veränderung durch den Nutzer unter Umständen nicht im DOM repräsentiert sind, oder an Kindelementen des geklonten Element-Event-Handler hängen). Hier ein Beispiel:

var original  = 
      document.getElementById("container"),
    cloned    = original.cloneNode(true),
    textlist  = ["lazy", "dog"],
    tx_length = textlist.length,
    elem, content;

cloned.setAttribute("width", "50%");

for (var i = 0; i < tx_length; i++) {
  elem    = document.createElement("p");
  content = document.createTextNode(
    textlist[i]
  );
  elem.appendChild(content);
  cloned.appendChild(elem);
}

original.parentNode.replaceChild(
  cloned,
  original
);

Unangenehmer Nebeneffekt: Ein Element zu verbergen und es später wieder anzuzeigen, verändert unter Umständen die Abmessungen des Dokuments. Und für eine daraus resultierende Veränderung des Scroll Offsets kann ein flackerndes Scrollbar die unangenehme Folge sein. Bei einem positionierten Element hingegen funktioniert diese Technik ausgesprochen gut.

[header = Richtig maßnehmen]

Richtig maßnehmen

Unter Umständen speichert Ihr cleverer Browser Veränderungen eines Elements und führt nur einen einzigen Reflow durch, wenn alle Veränderungen daran getätigt wurden. Kann sein, kann nicht sein. Bleiben Sie aufmerksam, insbesondere dort, wo man Reflows gar nicht erst erwarten würde, z. B. wenn Sie die Abmessungen und Positionen bestimmter Elemente abfragen. Die Abfrage von Eigenschaften wie offsetWidth oder der Aufruf von Methoden wie getComputedStyle resultieren in versteckten Reflows, um die Korrektheit der Werte sicherzustellen. Wenn Sie wiederholt auf solche Messwerte zugreifen, ohne dass eine Veränderung zu erwarten ist, sollten Sie erwägen, die Ergebnisse zu cachen.

var elm = document.getElementById("my_elm"),
    w   = elm.offsetWidth;

elm.style.fontSize = (w/10)+"px";
elm.firstChild.style.marginLeft = (w/20)+"px";
elm.style.left = ((-1*w)/2)+"px";

CSS-Klassen ändern, nicht Eigenschaften

Vielleicht sind Sie schon über die Empfehlung gestolpert, die Veränderung von CSS-Klassen der Veränderung von Stilattributen vorzuziehen, genauer gesagt der Veränderung layoutspezifischer Stilattribute wie width, height, font-size, float etc. Die Verwendung von CSS-Klassen eliminiert nicht die Notwendigkeit eines Reflows, die Anzahl wird jedoch verringert. Wenn Sie mehr als ein Stilattribut verändern möchten, ist die Veränderung einer CSS-Klasse der performantere Weg. Oder nutzen Sie das style-Attribut, um mehrere Veränderungen auf einmal anzuwenden. Hier ein Beispiel:

var elm = document.getElementById("my_elm");
elm.setAttribute(
  "style",
  "background: blue; color: white;"
);

Faustregel: Verwenden Sie Inline Styles nur, wenn Sie etwas animieren oder kleine einfache Veränderungen vornehmen wollen. Anstatt mehrere Attribute verschiedenen Elementen zuzuweisen, sollten Sie besser eine Klasse zuweisen, die Sie bereits in Ihrem CSS definiert haben. Nachfolgend ein Beispiel mit jQuery. Statt:

jQuery(elements).css({
  color:           "red",
  backgroundColor: "yellow",
  border:          "3px solid #000"
});

besser so:

jQuery(elements).addClass("important");
Unsere Redaktion empfiehlt:

Relevante Beiträge

Meinungen zu diesem Beitrag

X
- Gib Deinen Standort ein -
- or -