Javascript optimieren – Ladedauer und Reaktionszeit

Javascript optimieren und Pageload

Früher waren die Rechner langsam und die Leitungen noch langsamer. Heute haben wir ein schnelles Internet und schnelle Rechner, Handy und Tablett übertreffen die Rechenkapazität älterer Computer-Generationen. Aber Javascript-Programme werden immer aufwändiger und nicht nur den mobilen Geräten geht die Luft aus.

23-02-02 SITEMAP CSS HTML JS Basis JS Web Tutorial SVG

Lesbarkeit vs Größe

Es dauert eine Weile, bis man dahinter kommt, dass die Lesbarkeit von Scripten wichtiger ist als Kürze und Schnelligkeit. Das Sparen bei Variablennamen und das Ausnutzen von Abkürzungen schlagen schnell zurück, wenn Änderungen durchgeführt oder kleine Fehler korrigiert werden müssen. Nach wenigen Tagen sind die verschlungen Wege der Logik vergessen und die Zeit verrinnt beim Nachvollziehen der Gedankengänge.

Minifier wie Uglify und die Komprimierung mit gzip verkleinern den Javascript-Code und beschleunigen Scripte effektiver als Kurzschriften.

jQuery ist eine Bremse

Mit dem Abgang der alten Browser muss jQuery nicht mehr für jede Operation eingesetzt werden, auch wenn jQuery sowieso schon im Projekt geladen ist. Javascript hat in den modernen Browsern bei den Manipulationen des DOM eine hohe Konsistenz erreicht. Natives Javascript (Vanilla Javascript) bietet ebenso komfortable Selektoren wie jQuery, auch wenn querySelector und querySelectorAll mehr Tipparbeit bedeutet. Auf jeden Fall aber ist natives Javascript bei DOM-Operationen deutlich schneller.

Es muss nicht immer $() sein, auch wenn die Schreibarbeit aufwändiger ausfällt.

$('input').keyup (function () {
   if ($(this).val() == 'tata') {
      …
   }
});

wird doppelt so schnell mit dem nativen this

document.querySelector('input').onkeyup = function () {
   if (this.val() == 'tata') {
      …
   }
};

Und das gilt auch hier:

jQuery
$(el).find(selector).length;
Natives Javascript (ohne jQuery)
el.querySelector(selector) !== null

Natürlich müssen wir uns darüber im Klaren sein, dass jQuery mehr bietet als den einfachen Zugriff und die Manipulation von Elementen des DOM. jQuery schützt vor allem vor den Inkonsistenzen und Fehlern der Browser.

CDN - Content Delivery Network

Ein CDN ist kein Patentrezept für kurze Ladezeiten. Lokale Projekte, die im wesentlichen auf ein Land abzielen, und für die es keinen heimischen Server gibt, verlängern die Ladezeit. Ein CDN verkürzt die Ladezeit für eine internationale Webseite, bei lokalen Webseite kann ein CDN genau den umgekehrten Effekt mit sich bringen: Lieferzeit statt Ladezeit.

Ein großer Teil aller Webseiten wird heute von HTTP2 ausgeliefert. Nun ist HTTP2 nicht soooo viel schneller als HTTP1.1, aber HTTP2 multiplext Verbindungen, wo für Dateien vom CDN neue Verbindungen erst einmal aufgebaut werden müssen.

CDNs, von denen wir Open Source-Projekte bekommen, müssen zudem immer im Auge behalten werden (Werbe-Popup: Twitter Share Count).

CSS statt Javascript so weit wie möglich

CSS3 ersetzt viele Effekte, die zuvor aufwändig mit Javascript in die Webseite gesetzt wurden. Für ein animiertes Hamburger-Icon, das mit CSS animiert wird, wird weder ein Icon-Font noch Javascript gebraucht.

Für eine einfache Slideshow reichen schon wenige Zeilen CSS, und schon fliegen die Bilder ein, ganz ohne jQuery und Javascript.

snow-1-large snow-2-large snow-3-large snow-2-large snow-3-large

Selbst aufwändigere Slideshows laufen ohne Javascript: CSS Slideshow mit Timeline

Javascript Variablen konsistent halten

Javascript ist dynamisch typisiert – ich kann eine Variable für Integer-Werte benutzten und dann für einen String. Für schnelles Javascript hält man die Variablen konsistent.

Arrays

Ein Array kann man wie folgt aufbauen:

let tage;
tage[0] = "Montag";
tage[1] = "Dienstag";
tage[2] = "Mittwoch";
...

Schneller geht es so:

let tage = ["Montag", 
    "Dienstag", 
    "Mittwoch", 
    ... 
    "Sonntag"
];

Schnelle Schleifen

Loops oder Schleifen sind das A & O jeder Anwendung und mit optimierten Bedingungen lassen sie sich schneller gestalten.

for (let i=0; i<document.getElementsByTagName('tr').length; i++ ) {
   document.getElementsByTagName('tr')[i].className   = 'newclass';
   document.getElementsByTagName('tr')[i].style.color = 'red';
   ...
}

Schon besser und besser lesbar

let rows = document.getElementsByTagName('tr');
for (let i=0; i<rows.length; i++ ) {
  rows[i].className   = 'newclass';
  rows[i].style.color = 'red';
  ...
}

Selektoren für Schleifen werden immer in einer Variablen gespeichert (let rows = document.getElementsByTagName('tr')) statt sie in jedem Durchlauf erneut aus dem DOM zu extrahieren.

Dennoch ist keine dieser beiden Varianten wirklich effizient. getElementsByTagName gibt kein statisches Array, sondern ein dynamisches Objekt zurück. Jedes Mal, wenn die Bedingung geprüft wird, muss der Browser auf das Objekt zugreifen und die Anzahl der referenzierten Objekte berechnen, um die Eigenschaft length zurückzugeben.

Ähnlich sieht es mit dem verwendeten Index aus. Das Objekt muss bei jedem Lauf durch die for-Schleife drei Mal berechnet werden. Dieser Code sind besser, wobei meistens die erste Variante die bessere ist:

const rows = document.getElementsByTagName('tr');
for (let i=0, row; row=rows[i]; i++ ) {
  row.className   = 'newclass';
  row.style.color = 'red';
  ...
}
const rows = document.getElementsByTagName('tr');
for (let i=rows.length-1; i>-1; i-- ) {
  let row = rows[i];
  row.className   = 'newclass';
  row.style.color = 'red';
  ...
}

Javascript Abkürzungen (&&)

Der Short Circuit-Operator (&&) hilft bei der Optimierung bedingter Anweisungen und erlaubt die Ausführung teurer Operationen, nachdem weniger teure Bedinungen bereits erfolgreich geprüft wurden: Die zweite Bedingung wird erst evaluiert, wenn die erste Bedingung erfüllt ist. Also setzt man die aufwendigere Prüfung nach hinten.

Der ||-Operator arbeitet ähnlich und evaluiert die zweite Bedingung nur wenn die erste Bedingung nicht erfüllt ist. Wenn zwei Bedingungen vorliegen, aber nur eine Bedingung erfüllt sein muss, damit das Script fortgeführt werden kann, wird die weniger aufwendige Bedingung nach vorn gesetzt, so dass die zweite Bedingung nur geprüft werden muss, wenn die erste Bedingung nicht zutrifft.

Zugriff auf Elemente

Das DOM stellt viele Methoden für den Zugriff auf Elemente zur Verfügung und man läßt sich schnell auf eine ausgiebige Benutzung von childNodes, siblings, parentNodes und tagNames ein. Diese Technik ist sowohl unzuverläßig als auch langsam, insbesondere, wenn Elemente in das Dokument eingefügt und entfernt werden. Außerdem sollte man immer in Betracht ziehen, dass Weißraum zwischen den Elementen zu einem childNode wird. getElementById ist komfortable Zugriff auf ein individuelles Element, aber querySelector und querySelectorAll sind das Äquivalent zu jQuery $(), bieten eine konsistente Schreibweise und erreichen alle Elemente über CSS-Selektoren.

Immer wieder wird ein Zugriff auf mehrere Elemente benötigt, z.B. auf alle h-Überschriften in einem Dokument. Auf die h-Elemente können wir mit getElementsByTagName('*') zugreifen, aber das würde auf viel zu viele Elemente zugrifen und die Schleifen deutlichlich langsamer machen. Der Weg sollte nur eingeschlagen werden, wenn die header in der korrekten Reihenfolge benötigt werden.

let headers = document.getElementsByTagName('*');
for (let i=0, oElement; oElement=headers[i]; i++ ) {
  if (oElement.tagName.match(/^h[1-6]$/i) {
    ...
  }
}

Diese Technik ist besser:

for (let i=1; i<7; i++ ) {
  let headers = document.getElementsByTagName('h'+i);
  for (let j=0, oElement; oElement=headers[j]; j++ ) {
    ...
  }
}

Den Wiederaufbau einer Seite minimieren

Jedes Mal, wenn ein Element in ein Dokument eingefügt wird, muss der Browser den Elementfluss der Seite neu aufbauen, Elemente neu positionieren und rendern. Je mehr eingefügt wird, desto mehr muss neu aufgebaut werden. Also reduziert man die Anzahl der Elemente, die hinzugefügt werden, damit der Browser nicht so häufig eine Neustrukturierung vornehmen muss.

Wird ein Element mit mehreren Kindern hinzugefügt, werden zuerst die Kinder in das Element eingefügt und dann erst das Element in das Dokument eingefügt, so dass der Browser mit einer einzigen Neustrukturierung auskommt. Wenn mehrere sibling-Elemente nicht als Kinder eines neuen Elements eingefügt werden, kann man ein document fragment benutzen, die Elemente dort unterbringen, und dann das document fragment in das Dokument einfügen. Die Elemente werden dann als siblings in einer einzigen Neustrukturierung untergebracht.

let foo = document.createDocumentFragment();
foo.appendChild(document.createElement('p'));
foo.firstChild.appendChild(document.createTextNode('Test'));
foo.lastChild.appendChild(document.createTextNode('Me'));
foo.firstChild.style.color = 'green';
document.body.appendChild(foo);

Das selbe gilt für den Text und Stile eines Elements: Zuerst wird werden Inhalte und Stile eingefügt und dann erst das Element in das Dokument gesetzt.

Mehrere Stile per Javascript hinzufügen

So sieht das Einfügen mehrerer Stile immer wieder aus:

elem.style.position = 'absolute';
elem.style.top = '0px';
elem.style.left = '0px';
... etc ...

Das ist megaout, da es den veralteten Ansatz des DHTMLs benutzt und richtig langsam ist. Wir könnten das DOM nehmen und reguläres CSS in einer einzigen Neustrukturierung einfügen:

oElement.setAttribute('style','position:absolute; top:0px; left:0px;... ...');

Effizienter ist es allerdings, gleich eine CSS-Klasse zu benutzen und die Klasse im CSS zu vereinbaren.

oElement.setAttribute('class','myClass');

classList.add, classList.remove und classList.toggle sind komfortable Alternativen für alle modernen Browser. Internet Explorer unterstützt classList ab Version 10, aber es gibt ein Polyfil für classList für IE9 bei github.

Anonyme Funktionen

Wir wollen die Hintergrundfarbe einer Tabellenreihe beim mouseover ändern und beim mouseout wieder zurücksetzen. Schreiben wir klassisch so:

if (document.getElementsByTagName) {
  const rows = document.getElementsByTagName('tr');
  for (let i=0, row; row=rows[i]; i++) {
    row.mouseover = over;
    row.mouseout  = out;
  }
}

function over() {
  this.setAttribute('style','background: silver');
}

function out() {
  this.setAttribute('style','background: blue');
}

Da die Funktionen over() und out() so einfach sind, können wir sie als anonyme Funktionen registrieren:

for (let i=0, row; row=rows[i]; i++) {
  row.mouseover = function (event) {
    this.setAttribute('style','background: silver');
  }
  row.mouseout  = function (event) {
    this.setAttribute('style','background: blue');
  }
}

Das funktioniert genauso gut, hält aber den Code besser beisammen (der Parameter event ist optional). Auch dieser Konstrukt ist OK:

obj.onclick = function (event) {
	functionOne();
	functionTwo();
}

Strings durchsuchen – String Matching

Es gibt zwei grundlegende Techniken, die einen String nach einer Zeichenkette durchsuchen und entweder die Position des Treffers oder -1 (nicht gefunden) zurück zu bekommen. Die erste benutzt 'indexOf', um die Position des Substrings im String herauszufinden. Die zweite Technik ist 'search' oder eine entsprechende Methode, um ein Suchmuster mit einem regulären Ausdruck zu finden. string.search() ist schneller als string.indexOf. Nur wenn das Suchmuster sehr einfach ist, sollte indexOf anstelle des regulären Ausdrucks verwendet werden. match() oder search() sollten ebenfalls vermieden werden, wenn der String sehr lang (10KB und mehr) ist.

Werden reguläre Ausdrücke mit vielen Wildcards benutzt, wird die Suche nach einer Zeichenkette ebenfalls deutlich langsamer. Das wiederholte Ersetzen von Substrings ist ebenfalls kostenintensiv.

Sinnvolle Wiederverwertung

Wenn das Suchmuster eines regulären Ausdrucks wiederholt verwendet wird, sollte es einmal erzeugt und als Variable gespeichert werden, damit der Browser die Suche optimieren kann. Im Beispiel werden die beiden regulären Ausdrücke getrennt behandelt und verschwenden Ressourcen:

if( a == 1 && oNode.nodeValue.match(/^\s*extra.*free/g) ) {
    //erzeugt die erste Kopie
} else if( a == 2 && oNode.nextSibling.nodeValue.match(/^\s*extra.*free/g) ) {
    //erzeugt die zweite Kopie
}

Die folgenden Anweisungen arbeiten identisch, aber effzienter, da der Browser nur eine Kopie des regulären Ausdrucks anlegen muss:

let oExpr = /^\s*extra.*free/g;
if( a == 1 && oNode.nodeValue.match(oExpr) ) {
   // Benutzt die vorhandene Variable
} else if( a == 2 && oNode.nextSibling.nodeValue.match(oExpr) ) {
   // Benutzt ebenfalls die vorhandene Variable
}

Grundsätzlich muss der gespeicherte Ausdruck nicht gelöscht oder auf null gesetzt werden werden, wenn die Suche beendet ist. Die Script-Engine führt die Garbage Collection durch und der Löschvorgang ist eine unnötige zusätzliche Operation.

Wenn sie inline in einem Loop definiert wurde, wird jede Instanz eines regulären Ausdrucks nur einmal erzeugt und automatisch für den nächsten Zugriff in der Schleife gecacht. Wenn wir aber mehrere Instanzen des selben Ausdrucks innerhalb der Schleife erzeugen, wird jede separat erzeugt und gecacht, wie wir oben sehen.

for( let i = 0, oNode; oNode = oElement.childNodes[i]; i++ ) {
  if( oNode.nodeValue.match(/^\s*extra.*free/g) ) {
     // erzeugt des Ausdruck
    // wird gecacht und beim nächsten Lauf durch die Schleife wiederverwertet
  }
}

Das triff nicht auf die new RegExp-Syntax zu, die immer eine neue Kopie des Ausdrucks erzeugt und generell langsamer ist als die Erzeugung eines statischen Ausdrucks. In Schleifen sollte sie also so weit wie möglich vermieden werden.

Inline-Skripte

Javascript Inline-Skripte wie <body onload="javascript: start()"> und <input onclick="" ... /> verlangsamen den Aufbau einer Seite, da der Browser annehmen muss, dass ein Inline-Skript die Struktur der Seite verändert. Macht heute aber wohl kaum noch jemand …

eval ist hinterhältig

Sowohl die Methode eval als auch verwandte Konstrukte wie new Function sind Verschwender.

Beim Laden einer Seite wird eine neue Instanz des Javascript-Interpreters gestartet und eine Skripting-Umgebung erzeugt (wird auch als „thread“ bezeichnet). Der Aufruf von eval startet einen weiteren Interpreter mit einer neuen Umgebung und importiert die Variablen der ersten Umgebung in die neue Umgebung. Wenn der eval-Aufruf abgewickelt ist, exportiert die Variablen zurück in ihre ursprüngliche Umgebung und sammelt den Müll. Obendrein kann der Code nicht für die Optimierung gecacht werden.

Mit useStrict ist eval() nicht mehr erlaubt.

Nur auf das horchen, was gebraucht wird

Jedes Mal, wenn ein neuer Event Handler registriert wird, beginnt die Scripting-Engine mit dem Abhorchen und Feuern auf das Ereignis. Jeder weitere Event Handler legt also eine zusätzliche Last auf die Engine.

Unnötigen Code vermeiden

Wenn ein Script nur unter bestimmten Bedingungen laufen soll, werden die Bedingungen so klar wie möglich formuliert und das Script sofort gestopt, wenn die Bedingungen nicht erfüllt werden. Wenn das Skript innerhalb einer Funktion liegt – was bei dem größten Teil des JavaScript-Codes der Fall sein wird –, kann die Funktion mit return sofort verlassen werden.

Um for- oder while-Anweisungen zu verlassen, wird break aufgerufen, continue überspringt umfangreiche Blöcke in for- und while-Anweisungen. Durchsucht ein Skript z.B. alle a-Tags eines Dokuments um ein href-Attribut mit einem Link auf eine CSS-Datei zu finden, kann die for-Anweisung sofort verlassen werden, wenn die CSS-Datei gefunden wurde. Wenn ein return nicht möglich ist, weil eine Funktion nach der for-Anweisung weiter ausgeführt werden soll, könnte eine Variable definiert werden und als zusätzliche Bedingung in der for-Anweisung untergebracht werden. Einfacher ist es allerdings, die for-Anweisung einfach abzubrechen:

document.addEventListener('load',function () {
  const elem = document.querySelectorAll('.link');

  for(let i=0, elem; elem = elem[i]; x++ ) {
    if( elem.getAttribute('href').match(/\/old.css$/) ) {
      elem.setAttribute('href','styles/new.css');
      break;
    }
  }
});

Timer kosten kostbare Zeit

setInterval und setTimeout werden für Animationseffekte benutzt. Jede dieser Methoden erzeugt einen zusätzlich Thread für jeden angelegten Timeout und wenn der Browser mehrere Timeouts simultan ausführt, geht die Performance den Berg runter. Mit ein oder zwei Timern ist das noch kein Problem, aber sobald es fünf oder zehn werden, wird der Geschwindigkeitsverlust sichtbar. Dazu kommt noch, dass Timer zur eval-Familie der Methoden gehören, so dass sie gleich mehrfach ineffizient sind.

requestAnimationFrame ist effizienter und wird per Vorgabe 60 mal pro Sekunde aufgerufen – mehr ist selten nötig.

let iterationCount = 0;
let repeater;

function runlock () {
   easing = easeInQuad(iterationCount, 0, width, 300);
   lok.setAttribute ('style','left: ' + easing + 'px');
   iterationCount++;

   if (iterationCount > 250) {
      cancelAnimationFrame(repeater);
   } else {
      repeater = requestAnimationFrame(runlock);
   }
}

runlock();

Noch effizienter sind Animation mit einer Mischung aus Javascript und CSS-Animation bzw. Transformationen.