Het laden is de grootste bottleneck op internet, dus dat deel is bijzonder belangrijk voor het optimaliseren van de prestaties. Maar na het aanvankelijke laden van de HTML en de rendering-blokkerende resources, moet de browser binnen een fractie van een seconde een stel tekstbestanden omzetten naar pixels op het scherm. Dat zware werk wordt het Critical Rendering Path genoemd.
Daar zitten verschillende taken achter. De browser converteert HTML en CSS in boomstructuren (DOM en CSSOM) en voegt beide samen in de rendering-boom. Daar gaat het alle DOM-nodes af en berekent de lay-out voor elke node, dat wil zeggen de grootte en positie van de inhoudskaders. In de paint- of rasterfase vult de browser die vakjes met pixels en bepaalt uiteindelijk de lay-out in de compositing-fase.
Een voor de hand liggende prestatie-optimalisatie is dus om de werklast beheersbaar te houden door het aantal DOM-nodes te beperken. Lighthouse klaagt al bij 1500 elementen.
De hoeveelheid CSS is minder een probleem (afgezien van het laden), omdat met dat eenvoudige formaat zeer effectief kan worden omgegaan. Ook samengestelde CSS-selectors (zoals nav li:- first-child a) veranderen daar niets aan: hoewel de mythe blijft bestaan dat die de prestaties beïnvloeden, liggen de effecten dicht bij de meetbaarheidsgrens.
Reflows in grote documenten hebben daarentegen een merkbaar remmend effect: zo noem je het wanneer elementen die reeds gerenderd zijn, opnieuw de lay-out-, paint- en compositing-fases moeten doorlopen.
In de praktijk gebeurt dat vaak als gevolg van achteraf gedownloade content inhoud, bijvoorbeeld afbeeldingen zonder vooraf bekende afmetingen of webfonts die na de eerste rendering beschikbaar zijn. Het resulterende wijzigen van de grootte kan een cascade van reflows veroorzaken. Ook CSS-animaties en -overgangen, evenals JavaScript-acties, kunnen dat veroorzaken.
Een CSS-transitie die omringende tekst opzij schuift, zet de browser flink aan het werk …
Afhankelijk van het type wijziging en de intelligentie van de browser hoeft dat niet altijd een volledige reflow te zijn. Om bijvoorbeeld een element te animeren en te verplaatsen, zijn de CSS-eigenschappen top, left, width en height handig. Waar mogelijk moet je echter de transform-eigenschap gebruiken met de functies translate() en scale(). De meeste browsers slaan de lay-out- en paint-fase dan over en gaan direct naar de compositing-fase. De grafische processor manipuleert de pixels van het reeds gerenderde element binnen milliseconden.
Zelfs op mobiele toestellen lopen dergelijke animaties meestal vloeiend. Als je niet op je ogen vertrouwt, kun je de framerate meten met de ontwikkelaarshulpmiddelen. Bij Chrome kun je dat doen via het Run-commando (Ctrl+Shift+P) met ‘Show frame per second (FPS) meter’). Maximaal haalbaar is 60 fps.
… terwijl een soortgelijke transitie met transform-attributen geen moeite kost.
JavaScript-code draait in een enkele thread. Daarom kan een langdurige actie de hele browser tot stilstand brengen. De oplossing voor dat probleem zijn asynchrone functies, die in JavaScript mogelijk zijn in de vorm van callbacks, promises en async/await-functies.
Bij passieve event-handlers, die belangrijk zijn voor scrol- en touch-events, gaat het ook over het vermijden van onnodige wachttijden. Omdat het scrollen in een aparte thread gebeurt, kan dat zelfs tijdens complexe berekeningen soepel verlopen – als de browser niet eerst zou hoeven controleren of de code het scrollen niet stopt met preventDefault(). Met de {passive: true}-optie in addEventListener() belooft de ontwikkelaar dat niet te doen.
WebWorkers kunnen uitgebreide berekeningen overhevelen naar afzonderlijke threads. Dat is goed te combineren met WebAssembly, een subset van JavaScript die is toegespitst op performance en is geconverteerd (getranspileerd) vanuit talen als C++. WebGL tenslotte stuurt grafische uitvoer rechtstreeks naar de gpu.
Je hebt die opgevoerde prestaties echter zelden nodig, behalve voor game-ontwikkeling, en het is niet erg nuttig voor typische taken op een webpagina. Scripts op een webpagina zijn meestal bezig met DOM-manipulaties die niet mogelijk zijn met WebWorkers, WebAssembly en WebGL.
Er kunnen verrassende prestatieverschillen zijn bij DOM-toegangen. De waarschijnlijk meest gebruikelijke manier om naar het document te schrijven is om HTML-broncode in te voegen via de innerHTML-eigenschap:
someData.forEach(data => {
document.querySelector(‘.my-list’).
innerHTML += `<li>${data}</li>`;
});
Omslachtiger zijn de ouderwetse DOM-methoden zoals document.createElement() en append-Child():
const list = document.
querySelector(‘.my-list’);
for (let i = 0;
i < someData.length; i++) {
const li = document.
createElement(‘li’);
li.textNode = someData[i];
list.appendChild(li);
}
De code slaat het lijstelement op in de cache en voegt nieuwe elementen pas toe aan het DOM als de attributen en inhoud compleet zijn. De klassieke for-lus is net iets sneller dan array-methoden zoals forEach(). Maar terwijl dat net als caching slechts minimale verbeteringen oplevert, versnellen de DOM-methoden het script enorm – tot wel duizend keer voor grote lijsten.
Maar hoe relevant is dat in de praktijk? ‘Overhaaste optimalisatie is de wortel van alle kwaad’, schreef programmeergoeroe Donald Knuth. In feite zal bijna geen enkel programmeerproject lijden onder het verschil in performance tussen for en forEach(), terwijl het goed kunnen onderhouden en de leesbaarheid van code wel van vitaal belang zijn. Als je vijf lijst-items invoegt, maakt het niet uit welke variant je kiest. Anderzijds stapelen vele kleine prestatieverminderingen zich op, en kan het letten op efficiënte code bepalen of een toepassing bruikbaar is of niet.
Dat effect kan goed worden bestudeerd met behulp van bekende algoritmen, zoals voor het berekenen van Fibonacci-getallen – een reeks getallen gevormd door de som van de vorige twee (0, 1, 1, 2, 3, 5, 8, …). Zo kan men de eerste n Fibonacci getallen als volgt berekenen:
const fib = n => n < 2?
n : fib(n – 1) + fib(n – 2);
Het algoritme roept zichzelf recursief aan om terug te gaan naar de eerste getallen in de reeks, die het dan optelt. Eenvoudig, elegant – en uiterst inefficiënt. Ergens rond n = 60 houdt de browser het voor gezien. De vereiste rekentijd neemt exponentieel toe met elke iteratie, terwijl slimmere algoritmen het resultaat in fracties van seconden afleveren.
Recursie en geneste lussen kunnen de krachtigste processors op de knieën krijgen. Als je vaak aan complexe scripts werkt, moet je weten wat de Big O-notatie is, die je blik op dergelijke performance-gevallen scherp houdt.