Dev / CodingAI-generated

Hoe een webpagina van HTML naar pixels gaat

Na de DNS-opvraag en de handshakes uit Wat er gebeurt vóór de eerste byte komt er eindelijk HTML binnen. Dan is de browser nog lang niet klaar. Die HTML is platte tekst, en tussen die tekst en een pagina met lettertypes en kolommen zit een hele keten van stappen. Eén verkeerd geplaatst script kan die keten stilleggen.

Ik heb een echte pagina van wallieweb.nl ontleed. Je ziet hoe de HTML-parser er een boom van maakt en welke bestanden het tekenen kunnen ophouden. De regels komen uit de HTML-specificatie van de WHATWG, de stappen na het parsen uit de documentatie van Chromium.

Van bytes naar tokens

Vanaf een Nederlandse verbinding vroeg ik drie keer het DNS-artikel op met curl. De eerste byte kwam na 165 tot 201 milliseconden binnen, de hele pagina na 195 tot 233 ms. Het ging om zo'n 43 kB HTML, gecomprimeerd ruim 12 kB.

De specificatie beschrijft het parsen in twee fasen. Eerst decodeert de browser de bytes naar tekens. De tokenizer hakt die tekens in tokens: een starttag <p>, een stukje tekst, een eindtag </p>. Daarna verwerkt de tree construction die tokens één voor één tot een Document: de DOM-boom waar scripts en CSS later mee werken.

De parser repareert je fouten

HTML is vergevingsgezind, en dat is precies vastgelegd. Voor elke fout schrijft de specificatie voor wat de parser moet doen. Daardoor bouwt elke browser van dezelfde rommelige HTML dezelfde boom. Ik liet parse5, een JavaScript-parser die de specificatie volgt, deze regel verwerken:

<title>Test</title><p>Een <b>vet<p>woord</b> hier

De boom die eruit kwam:

html
  head
    title
      "Test"
  body
    p
      "Een "
      b
        "vet"
    p
      b
        "woord"
      " hier"

Er staan een html, een head en een body in die ik nooit heb geschreven. De tweede <p> sluit de eerste af, want een nieuwe alinea mag niet in een open alinea staan. Het opvallendste: de <b> wordt in de tweede alinea opnieuw geopend. De parser houdt een lijst bij van open opmaak-elementen zoals b, i en em, en zet die voort als ze onderweg zijn afgebroken. Bij een tabel zonder <tbody> voegt de parser er ook gewoon een toe.

De wallieweb-pagina leverde in parse5 ruim 200 elementen en zo'n 140 tekstknopen op. De volledige tekst van het artikel staat al in de HTML: met menu en voettekst erbij bijna 1000 woorden.

CSS en een rem op het tekenen

In de head van de pagina staat één stylesheet. Die is 73.719 bytes, of 12.359 bytes gecomprimeerd. De browser maakt er een CSS-stylesheetobject van, het CSSOM, waarmee later per element de stijl wordt berekend.

Zou de browser tekenen voordat de CSS er is, dan zag je eerst kale tekst die daarna verspringt. Het render-blocking-mechanisme uit de specificatie houdt het tekenen daarom tegen. Zolang de parser nog in de head zit, of zolang een stylesheet die de parser daar tegenkwam nog niet binnen is, slaat de browser het tekenen van de pagina over. Er is wel een tijdslimiet, maar hoe lang die is, mag elke browser zelf bepalen. Een stylesheet die pas in de body wordt aangemaakt, houdt het tekenen op deze manier niet op.

In de response-headers stuurt de server een Link-header mee met rel=preload voor twee lettertypes, het logo en dezelfde stylesheet. Volgens de specificatie is dat hetzelfde als die regels vooraan in de head zetten. De browser verwerkt die header nog voordat de parser de eerste tag leest.

Waarom één script alles ophoudt

Een klassiek extern script zonder extra attributen houdt alles op. Het script wordt opgehaald en uitgevoerd, en het parsen ligt stil tot beide klaar zijn. Een script kan de pagina namelijk aanpassen terwijl die nog wordt gelezen, bijvoorbeeld met document.write(). De parser kan dus niet zomaar doorgaan.

Het kan nog trager. Staat er een stylesheet die nog aan het laden is, dan wacht het script daar eerst op. Een script kan opvragen hoe een element is opgemaakt, en door te wachten ziet het de opmaak inclusief die stylesheet. De parser ligt dan stil tot de stylesheet en het script allebei binnen zijn en het script heeft gedraaid.

Browsers mogen dat wachten deels opvangen met een speculatieve parser. Terwijl de echte parser op een script wacht, leest die vooruit in de HTML en haalt alvast bestanden op die verderop staan. Aan de boom zelf verandert de speculatieve parser niets.

Met twee attributen schakel je het blokkeren uit:

AttribuutOphalenUitvoeren
geenparsen stoptmeteen, parsen wacht
asynctijdens het parsenzodra het binnen is
defertijdens het parsenals het parsen klaar is

Voor JavaScript-modules (type="module") is uitstellen de standaard; daar heeft defer geen effect.

De wallieweb-pagina doet het netjes. Ik telde negen externe scripts met async, samen 514.422 bytes, of 155.141 bytes gecomprimeerd. Het script voor de bezoekersstatistieken heeft defer. Er staat ook een script met nomodule tussen, bedoeld voor oude browsers. Een browser die modules begrijpt, haalt dat volgens de specificatie niet eens op. Wat overblijft zijn een paar korte inline scripts zonder src. Die voert de browser uit zodra de parser ze tegenkomt en de stylesheet binnen is.

Stijl en layout

Zodra niets het tekenen meer tegenhoudt, kan het renderen beginnen, ook als de parser nog niet aan het eind van de HTML is. Chromium beschrijft daarvoor een pijplijn van stappen. Na animate, dat lopende animaties bijwerkt, komt style: de CSS wordt op de DOM toegepast en elk element krijgt een berekende stijl.

Niet elk element doet daarna nog mee. Volgens de CSS-specificatie levert een element met display: none geen enkele box op, en zijn kinderen ook niet. Het staat wel in de DOM, maar voor de rest van de pijplijn bestaat het niet.

Dan volgt layout: de grootte en positie van elk element op het scherm. De breedte van je venster telt mee, dus een smaller venster geeft een andere layout. De uitkomst noemt Chromium de fragment tree.

Paint is nog geen pixels

De stap paint klinkt als het moment waarop de pixels verschijnen, maar dat is het niet. Paint maakt een display list: een lijst tekeninstructies die beschrijft wat waar moet komen. Daarna kopieert commit die lijst naar de compositor thread. Layerize deelt hem op in lagen die los van elkaar kunnen worden gerasterd en geanimeerd. Bij raster worden de instructies omgezet in tegels voor de GPU. Activate en aggregate maken daar één frame van en bij draw rekent de GPU de uiteindelijke pixels uit.

Volgens Chromium voert de main thread de scripts uit en parset hij HTML en CSS. Ook layout en paint draaien daar. Scrollen en bepaalde animaties kunnen layout, pre-paint en paint overslaan en dan helemaal op de compositor thread draaien. Daardoor kunnen ze doorlopen terwijl de main thread met een renderupdate bezig is.

Zelf proberen

De tijden van je eigen verbinding:

curl -4 -so /dev/null -w 'eerste byte %{time_starttransfer}  totaal %{time_total}  grootte %{size_download}\n' https://wallieweb.nl/blog/hoe-dns-een-naam-vindt
eerste byte 0.165434  totaal 0.195311  grootte 42902

Welke scripts een pagina laadt en met welke attributen:

curl -s https://wallieweb.nl/blog/hoe-dns-een-naam-vindt | grep -o '<script[^>]*src="[^"]*"[^>]*>'
<script src="/_next/static/chunks/0eo1cde-rr3bs.js" async="">
<script src="/_next/static/chunks/3g4px251sg85e.js" async="">
...
<script defer="" src="https://stats.wallieweb.nl/script.js" ...>
<script src="/_next/static/chunks/0cz1d0mv5g_q7.js" noModule="">

Rommelige HTML laten repareren kan met parse5 (npm install parse5). In een browser zie je in de ontwikkelaarstools onder Elements dezelfde gerepareerde boom. Een tabel zonder tbody heeft er daar ook een.

Tot slot

Het eerste wat me verraste was de <b> die in de volgende alinea gewoon weer opduikt. Ik dacht dat de browser bij fouten wat gokte. In werkelijkheid staat in de specificatie precies welke boom eruit moet komen. Het tweede was dat paint geen pixels maakt maar een lijst instructies, en dat het echte tekenen pas zes stappen later op de GPU gebeurt. En mijn eigen site bleek geen enkel extern script te hebben dat het parsen ophoudt. Dat is niet mijn verdienste: Next.js zet zelf async bij de scripts die het genereert.