Website performance optimaliseren deel 2: sneller laden

Marco den Teuling
0

Inhoudsopgave

    Inleiding

    Bezoekers van je website waarderen alle interactieve elementen, fraaie animaties, webfonts, video’s en high-res foto’s natuurlijk, maar een op die manier opgetuigde website laadt vaak wel traag. Het weer vlot trekken van een langzame website is als een meerkamp met uiteenlopende disciplines.

    In het eerste deel hebben we gekeken naar wat je kunt doen om de hoeveelheid data die je website omvat te reduceren. In dit tweede deel kijken we wat je kunt doen om te zorgen dat je website sneller laadt.

    Level 3: voor- en nalevering

    Als je de download eenmaal gereduceerd hebt, kun je nadenken over wanneer je bepaalde bronnen nodig hebt. Het standaardgedrag – een HTML-bestand haalt alle bijbehorende scripts, stijlen en afbeeldingen van internet als het wordt geladen – is meestal niet het snelst. Sommige dingen kunnen beter vooraf worden opgevraagd, andere later.

    Maar wat betekent ‘snelheid’ eigenlijk voor een webpagina? Je kunt de tijd meten die verstrijkt tussen de eerste request en het arriveren van het laatste bit, maar dat is niet noodzakelijk de relevante variabele. Gebruikers zijn meer geïnteresseerd in drie andere gebeurtenissen: dat er iets op het scherm verschijnt, dat ze al een soort lay-out in de browser-viewport zien, en dat ze met die view kunnen interageren.

    Die gebeurtenissen zijn de First Contentful Paint (FCP), de Largest Content Paint (LCP) of de First Meaningful Paint (FMP) – en de Time to Interactive (TTI). Als het dus vijf seconden duurt om een pagina te laden, moet de gebruiker tot dat moment niet naar een wit scherm hoeven te staren. In het ideale geval zien ze de relevante inhoud binnen een seconde, met weinig verandering daarna, en kunnen ze de pagina bedienen terwijl de browser nog bezig is met het naladen van afbeeldingen, video’s en interacties onder het weergavevenster.

    Meestal vormen afbeeldingen het grootste deel van de data, en daarom is lazy-loading ingeburgerd – de browser vraagt de afbeeldingen alleen op wanneer hij er tijd voor heeft of ze nodig heeft. Moderne browsers (met uitzondering van Safari) hebben daar geen JavaScript meer voor nodig: een loading=“ lazy” in de <img> is voldoende. Dat werkt ook voor iFrames.

    Het grootste probleem is de inhoud die de eerste rendering blokkeert: JavaScript-code die in de header is ingebed en de stylesheetbestanden. Als de browser dergelijke inhoud tegenkomt, stopt hij het laden van de pagina, downloadt het bestand en parst het of voert het uit, alvorens verder te gaan met het renderen.

    Er moeten zo weinig mogelijk scripts draaien voordat de pagina gerenderd wordt. Dus verplaats je <script>-elementen zo veel mogelijk naar het einde van de <body>. Je kunt hetzelfde effect bereiken door het <script>-element in de header te laten staan en het defer-attribuut mee te geven. De browser zal het downloaden wel eerder starten, wat meestal wenselijk is. Als de volgorde van de scripts er niet toe doet, kun je in plaats daarvan werken met het async-attribuut.

    Minder bekend is dat stylesheets ook niet in het <head>-deel hoeven te staan. Je kunt de CSS bijvoorbeeld onder het browservenster herladen of per component uitsplitsen. Als je stijlen voor media-query’s opvraagt met <link href=“[URL]” rel=“stylesheet” media= “[Media-Query]”>, downloadt de browser ze alleen als hij ze nodig heeft. De tool Critical haalt de stijlen die meteen nodig zijn uit de stylesheet en voegt ze inline in het HTML-document in.

    Dit ‘opsplitsen’ van de code is in strijd met de bovengenoemde eis van zo groot mogelijke datapakketten. Je kunt dat oplossen door te wegen en te meten, of door over te stappen op HTTP/2. De technische kant van het splitsen van code wordt afgehandeld door gangbare bundlers zoals webpack, Rollup en Parcel.js.

    HTTP/2 server-push is een leuke functie, maar de front-end-opties zijn flexibeler en sturen data die de browser allang in de cache heeft staan niet botweg over de lijn. Bij JavaScript gebruik je XMLHttpRequest of fetch() om bestanden te laden. Bij HTML gebruik je de tag <link href=“[URL]” rel=“[type]”>, die bijzonder geavanceerde mogelijkheden biedt.

    Zo bespaart het type dns-prefetch tijd bij de DNS-lookup, terwijl preconnect bovendien de TCP-verbinding en -versleuteling afhandelt. preload en prefetch laden beide een bestand, maar voor verschillende doeleinden: prefetch heeft een lage prioriteit en is geschikt voor pagina’s die nog moeten worden bezocht, terwijl preload – verplicht met een as-attribuut, zoals as=“script” – sneller laadt en bedoeld is voor de huidige pagina. prerender heeft het effect alsof de pagina op een achtergrond-tabblad geladen wordt. Maar zo krachtig als die hulpmiddelen zijn: net als bij de PWA-cache is het advies om spaarzaam om te gaan met de resources.

    Level 4: code finetunen

    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.

    website optimaliseren; css-transitie

    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.

    website optimaliseren; transform-attributen

    … 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.

     

    Conclusie

    De webapplicaties van tegenwoordig hebben de neiging te zwaar te zijn. Megabytes aan vaak ongebruikte code komen uit allerlei frameworks en bibliotheken en originele webtechnieken zoals knoppen, invoervelden en scrollen worden nagebouwd met JavaScript. Dat is op zich niet erg, zolang de pagina maar snel laadt en soepel loopt – niet alleen op een goed uitgeruste ontwikkelaarslaptop, maar ook op een drie jaar oude goedkope smartphone.

    Vaak zijn de voor de hand liggende maatregelen bijzonder effectief, maar als je er meer uit wilt halen, moet je dieper graven – en stuit je op steeds meer details bij het laden, compileren en renderen. JavaScript is zowel een vloek als een zegen. Het is vaak medeverantwoordelijk voor prestatieproblemen. Functies zoals lazy-loading en service-workers kunnen echter ook voor soepeler surfen zorgen.

    (Deze tekst is verschenen in c’t 4/2021, p.120, met medewerking van Herbert Braun en Daniel Dupré)

     

    Wil je op de hoogte blijven van het laatste IT-nieuws en de nieuwste online-artikelen? Meld je dan hier aan voor onze nieuwsbrief:

     

    Meer handige workshops lees je in c't magazine okt/2021

    Meer over

    Websites

    Deel dit artikel

    Lees ook

    Met OpenSource Syncthing backup maken op vele platforms

    De opensource software Syncthing synchroniseert de inhoud van mappen tussen apparaten en kan ook uitstekend worden gebruikt voor het maken van back-up...

    Tips hoe je het uitvallen van internet kan opvangen

    Om tijdens het uitvallen van internet snel weer online te kunnen zijn, moet je wel een paar voorbereidingen treffen. Enkele tips hoe je dit kunt opvan...

    0 Praat mee
    avatar
      Abonneer  
    Laat het mij weten wanneer er