Od HTML do piksela: jak przeglądarka renderuje stronę

Każdy element DOM przechodzi przez pięć etapów, zanim go zobaczysz, niezależnie od frameworka. Niektóre style odpalają tylko ostatni etap, inne uruchamiają wszystkie pięć. Stąd różnica w kosztach animacji - od jednego etapu composite po pełny reflow.
Dwie animacje, które wyglądają identycznie:
@keyframes slide-a {
to {
transform: translateX(100px);
}
}
@keyframes slide-b {
to {
left: 100px;
}
}Box A leci 60 fps na każdym laptopie. Box B na tym samym sprzęcie potrafi się ściąć i zawiesić scroll. To samo przesunięcie, inny styl, inny koszt. Skąd ta różnica?
Pod spodem każdego elementu DOM jest pipeline z pięcioma etapami: parse → style → layout → paint → composite. Każdy z nich można odpalić osobno albo razem, zależnie od tego, co dokładnie zmieniasz. To jest warstwa pod każdym frameworkiem - niezależnie od tego, czy piszesz w React, Vue, Astro czy vanilla JS, przeglądarka robi to samo. Wybór frameworka decyduje kiedy odpalasz pipeline, ale nie jak on działa.
Po przejściu przez tych pięć etapów wiadomo, czemu transform jest darmowy, a width drogi - i co zrobić, gdy twoje 60 fps spada do 15.
#Parsing: HTML i CSS lecą równolegle
Pierwszy etap zaczyna się w chwili, gdy serwer zaczyna wysyłać odpowiedź. Przeglądarka odbiera dane jako strumień - HTML wpada kawałek po kawałku, CSS dochodzi z osobnych żądań. Parser HTML i parser CSS działają jednocześnie, każdy na swoim strumieniu, każdy buduje swoje drzewo.
Parser HTML rozkłada przychodzący tekst na tokeny - najmniejsze rozpoznawalne kawałki dokumentu: znacznik otwierający (<div>), zamykający (</div>), atrybut (class="foo"), tekst między tagami. Z tych tokenów konstruuje DOM (Document Object Model) - drzewo elementów, którymi później manipulujesz z poziomu JS (document.querySelector, element.appendChild i tak dalej). Co ważne, parser pracuje inkrementalnie - nie czeka, aż cały HTML dotrze z serwera, tylko buduje drzewo na bieżąco. Jak tylko zobaczy <body>, zaczyna konstruować jego dzieci. Może renderować już częściowe drzewo, zanim cały HTML dotrze. To dlatego widzisz, jak strona buduje się od góry na wolnym łączu.
Parser CSS robi analogiczną rzecz z arkuszami stylów: każdy plik CSS, każdy tag <style> i każdy atrybut style jest rozkładany na tokeny (te same zasady co przy HTML, tylko inne reguły - selektory, właściwości, wartości) i składany w CSSOM (CSS Object Model). Jest jednak kluczowa różnica: CSSOM jest render-blocking. Przeglądarka nie wyrenderuje niczego, dopóki nie skończy parsować wszystkich arkuszy, bo każdy nowy selektor może nadpisać style, które już zna. Wyobraź sobie sytuację: parser zaczął rysować nagłówek na zielono, a w ostatnim CSS na końcu pliku jest h1 { color: red }. Bez kompletnego CSSOM nie wiadomo, która reguła wygra w kaskadzie.
Stąd cała praktyka critical CSS - style potrzebne do narysowania pierwszego ekranu (typografia, layout hero section, kolory tła) wstawiasz wprost do <head> w tagu <style>, resztę ładujesz osobno. Przeglądarka nie musi czekać na zewnętrzny style.css, żeby pokazać hero section.
Specyfikacja opisuje to dokładnie. HTML Living Standard - sekcja 13.2 Parsing rozpisuje, w jakim “trybie” parser znajduje się w każdej chwili: czyta zwykły tekst, czeka na nazwę tagu po napotkaniu <, czyta atrybut, sprawdza, czy <! zaczyna komentarz, czy <!DOCTYPE>. Każdy kolejny znak przełącza parser do innego trybu, a kiedy ma już rozpoznany kawałek dokumentu (tag, atrybut, fragment tekstu), oddaje go jako gotowy token konstruktorowi DOM. Analogiczny model dla CSS opisuje CSS Syntax Module L3.
#Style: kaskada, computed values, render tree
Mając DOM i CSSOM, przeglądarka musi je połączyć i odpowiedzieć na pytanie: “który element drzewa DOM jakie style ostatecznie dostaje - z jakimi konkretnymi wartościami (color, font-size, margin, padding i tak dalej)?”. Wynik tej operacji to render tree (w niektórych silnikach nazywany layout tree albo frame tree).
Render tree to nie jest po prostu DOM plus style. To filtrowane drzewo widocznych węzłów. Nie ma tu <head>, <script> ani niczego z display: none. Jest za to wszystko z visibility: hidden - bo to nadal zajmuje miejsce w layoucie, tylko jest niewidoczne.
Dla każdego widocznego elementu przeglądarka oblicza computed value każdej właściwości CSS - czyli finalną wartość, z jaką ma być wyświetlony. To proces trzystopniowy:
1. Selector matching - dopasowywanie reguł do elementów. CSS pisze się “od strony reguł”: mówisz “elementy z klasą .btn mają niebieskie tło”. Ale przeglądarka patrzy odwrotnie - bierze konkretny element drzewa DOM i pyta: “które z setek selektorów we wszystkich arkuszach CSS do mnie pasują?”. To właśnie jest selector matching. Weźmy konkretny przykład:
.modal .btn {
background: blue;
}(.btn to typowa konwencja nazwy klasy dla buttonów, .modal dla okna modalnego - chodzi o “każdy button znajdujący się wewnątrz modala”). Pytanie: do których elementów na stronie ta reguła się stosuje?
Silnik najczęściej idzie od prawej do lewej. Najpierw zbiera wszystkie elementy z klasą .btn (jest ich zwykle niewiele, łatwo je wyszukać po mapie klas), potem dla każdego z nich sprawdza, czy gdzieś wyżej w drzewie DOM ma przodka z klasą .modal. Gdyby silnik szedł odwrotnie (od .modal w dół), musiałby przejrzeć całe poddrzewo każdego modala w poszukiwaniu buttonów - znacznie więcej roboty.
2. Kaskada - rozstrzyganie konfliktów. Dla tej samej właściwości na tym samym elemencie może istnieć kilka konkurujących wartości (np. color):
- domyślny styl przeglądarki (
a { color: blue }w user agent stylesheet, w skrócie UA) - styl użytkownika (custom CSS dodany np. przez extension w przeglądarce)
- twój CSS - tzw. author stylesheet (
.link { color: green }) - inline
<a style="color: red">w atrybucie
Tylko jedna wartość trafi do computed value. Reguły CSS Cascade and Inheritance Level 5 ustalają zwycięzcę po trzech kryteriach, w tej kolejności:
- Origin (skąd reguła pochodzi) - inline style wygrywa z autorem (twój CSS), autor wygrywa ze stylem użytkownika, ten wygrywa z domyślnym stylem przeglądarki.
- Specificity (jak precyzyjny selektor) -
#hero .title(ID + klasa) wygrywa z.title(sama klasa), a ten wygrywa zp(sam tag). Im więcej precyzji w selektorze, tym wyższa specificity. - Order (kolejność) - jeśli wszystko inne równe, wygrywa reguła zapisana niżej w pliku CSS.
3. Inheritance - dziedziczenie po rodzicu dla niektórych właściwości (color, font, line-height), jeśli wartość nie została zdefiniowana wprost na elemencie. Inne (margin, padding, width) nie dziedziczą i zaczynają od initial value (wartość domyślna ze specyfikacji dla danej właściwości).
Wynik: każdy element render tree dostaje finalną wartość każdej właściwości. Po tym kroku przeglądarka wie, że “h1 ma color rgb(220, 38, 38), font-size 32px, margin-top 24px”. Ale jeszcze nie wie gdzie ten h1 ma być na ekranie ani jak duży dokładnie będzie. To zostawione na następny etap.
#Layout: gdzie i jak duży
Etap layoutu (czasem zwany reflow) odpowiada na proste pytanie zadawane dla każdego elementu render tree: “gdzie się znajdujesz i jakie masz wymiary?”. Wynik to geometria każdego boxa - pozycja (x, y) i wymiary (width, height) wyrażone w pikselach.
Pozornie banalne, w rzeczywistości najtrudniejsze. Layout jest viewport-relative (zależny od rozmiaru okna - inny dla mobile, inny dla 4K) i flow-aware (każdy element musi wiedzieć, gdzie są jego sąsiedzi, rodzeństwo, rodzic, dziecko). Zmiana jednego elementu często oznacza przeliczyć cały subtree, czasem całą stronę.
Wyobraź sobie listę kart w gridzie. Powiększasz pierwszą kartę o 20 pikseli wysokości. Przeglądarka musi:
- Przeliczyć tę kartę (jej wymiary się zmieniły)
- Przesunąć następną kartę w dół (jest dzieckiem tego samego flex containera)
- Sprawdzić, czy reszta gridu się mieści (może trzeba dołożyć rząd)
- Zaktualizować scroll height całego dokumentu (treść jest teraz wyższa)
- Sprawdzić, czy pozycja sticky elementów nie wymaga update
Jeden style.height = '120px' w JS to potencjalnie pięć przeliczeń. Dlatego layout jest najczęściej najdroższym etapem pipeline - i dlatego optymalizacja często sprowadza się do “rób mniej layoutu”.
CSS Display Module Level 3 definiuje formalnie box model (block / inline / flex / grid layout), CSS Box Sizing Module Level 4 opisuje, jak width, height, min-content, max-content, fit-content są obliczane. Te dwa dokumenty są punktem startowym, jeśli chcesz wejść głębiej w mechanikę.
#Paint: od geometrii do obrazu
Po layoucie przeglądarka wie co ma być na ekranie i gdzie. Etap paintu (zwany repaint, gdy odnosi się do ponownego rysowania) wypełnia te informacje pikselami.
Przeglądarka nie rysuje od razu na ekranie - najpierw tworzy paint records, listę instrukcji w stylu “narysuj prostokąt o tych wymiarach, kolorze X, z radius Y; potem narysuj tekst…”. Te instrukcje są wykonywane sekwencyjnie, w kolejności stacking order - z-index, position, parent-child, flow-relative.
Paint dzieli się też logicznie na paint layers - warstwy. Każda warstwa to osobny bitmap pikseli (jak warstwa w Photoshopie), który można malować i przesuwać niezależnie. Większość strony siedzi razem na jednej, głównej warstwie. Niektóre elementy - z transform, opacity, will-change, position: fixed (i kilkoma innymi flagami) - zostają wypromowane do osobnej warstwy (Chrome, a dokładnie jego silnik renderujący Blink - fork WebKit z 2013 roku, nazywa ten proces “layer promotion”). Po co ten podział? Bo każdą warstwę można obrabiać niezależnie od pozostałych - i to jest pomost do następnego etapu.
Repaint może być różny zakresowo. Czasem to cały viewport (zmiana background dwóch rodziców), czasem jeden prostokąt 50×50 (zmiana color na hover). Przeglądarka próbuje malować tylko region, który się zmienił, ale nie zawsze może. Jeśli zmienisz box-shadow na elemencie pokrywającym pół ekranu, paint będzie miał pół ekranu roboty.
#Composite: GPU składa obraz
Ostatni etap to compositing. Compositor składa wszystkie paint layers w jeden końcowy obraz, który trafia na ekran. To zadanie wykonuje GPU (procesor graficzny, wyspecjalizowany w równoległym przetwarzaniu pikseli), nie CPU (główny procesor komputera, dobry do zadań sekwencyjnych) - i tu leży klucz do płynnych animacji.
Jeśli element jest w osobnej warstwie (przez transform, opacity, will-change itp.), compositor może go przesunąć, obrócić, wyskalować lub zmienić przezroczystość bez wracania do layout i paint. Po prostu rysuje tę samą warstwę w innym miejscu, pod innym kątem, z inną przezroczystością. GPU robi to znacznie szybciej niż CPU, bo ma do tego dedykowane jednostki.
To wyjaśnia obserwację z początku posta: transform: translateX(100px) jest darmowy, left: 100px jest drogi. transform działa tylko na poziomie compositora (GPU). left zmienia pozycję w layoucie, co odpala wszystkie etapy poniżej - layout, paint, composite. Trzy etapy vs jeden.
Stąd cała praktyka “animuj tylko transform i opacity”. To nie jest mit - to bezpośrednia konsekwencja architektury pipeline. Każda inna właściwość (top, left, width, height, margin, padding, color, background) wymaga przynajmniej repaint, a najczęściej także reflow.
will-change to kontrakt z przeglądarką - mówisz jej wprost “ten element niedługo będzie animowany, daj mu osobną warstwę z góry, żebyś nie musiała robić layer promotion w trakcie animacji”. Używane mądrze daje płynność; nadużywane (will-change: * na wszystkim) marnuje pamięć GPU i może zaszkodzić. MDN ostrzega wprost: “will-change is intended to be used as something of a last resort, in order to try to deal with existing performance problems”. Definicję mechaniki znajdziesz w CSS Will-Change Module Level 1.
#Co to znaczy w praktyce: reflow vs repaint vs composite
Każda właściwość CSS, którą zmieniasz w runtime, odpala konkretny podzbiór etapów pipeline. Kliknij dowolną właściwość, żeby zobaczyć, które etapy się aktywują:
Compositor przesuwa, obraca lub skaluje istniejącą warstwę na GPU - bez wracania do layoutu i paintu.
Trzy kategorie pod kątem kosztu czasowego (im więcej etapów pipeline’u się odpala, tym dłużej trwa zmiana):
Tylko composite (najszybsze, mieszczą się w 16 ms na klatkę): transform, opacity. Tylko te dwa - reszta zawsze dotyka przynajmniej paint. Możesz animować przy 60 fps.
Paint + composite (średnie): color, background, outline, visibility, filter. Przeglądarka musi przerysować piksele danego elementu, ale layout się nie zmienia. Wciąż akceptowalne dla animacji, jeśli zmieniany region jest mały - z zastrzeżeniem, że filter: blur() z dużym promieniem czy drop-shadow na pół ekranu potrafią być drogie nawet bez reflow.
Layout + paint + composite (najwolniejsze): width, height, top, left, margin, padding, font-size, display, box-shadow. Każda z tych zmian odpala reflow - a reflow potrafi propagować się na pół drzewa. box-shadow ląduje tutaj, bo klasycznie spread i offset zmieniają bounding box (prostokątne pole, które zajmuje element) - choć modern Chrome potrafi to czasem zoptymalizować jako sam paint.
Pełna lista, która właściwość co odpala, jest rozpisana na csstriggers.com. Jeden z tych dokumentów, które warto mieć w zakładkach.
#Layout thrashing
Klasyczna pułapka. Wyobraź sobie kod:
for (const el of elements) {
el.style.width = el.offsetWidth + 10 + "px";
}Wygląda niewinnie, jest absurdalnie wolny. Dlaczego?
W każdej iteracji pętli robisz dwie rzeczy:
- read (odczyt) -
el.offsetWidthpyta przeglądarkę “ile pikseli szerokości ma teraz ten element?”. Żeby na to odpowiedzieć, przeglądarka musi mieć aktualne wartości layoutu. - write (zapis) -
el.style.width = ...ustawia nową szerokość. Po tym layout jest już nieaktualny - przeglądarka oznacza go jako wymagający przeliczenia.
Problem polega na tym, że sekwencja read-write-read-write zmusza przeglądarkę do natychmiastowego przeliczenia layoutu po każdym write, bo następny read potrzebuje świeżej wartości. To się nazywa forced synchronous layout (czasem layout thrashing) i potrafi zamienić pętlę 100-elementową w 100 osobnych reflow.
Fix: rozdzielić reads od writes. Najpierw wszystkie reads, potem wszystkie writes:
const widths = elements.map((el) => el.offsetWidth);
elements.forEach((el, i) => {
el.style.width = widths[i] + 10 + "px";
});Pojedynczy reflow jest tani. Drogie jest n reflowów tam, gdzie wystarczyłby jeden.
#Pipeline to nie pięć kroków, to pięć sygnałów
Każda właściwość CSS, którą zmieniasz, wybiera, ile etapów odpalasz - a wybór “tylko composite” zamiast “wszystkie trzy” to często różnica między animacją odświeżaną 60 razy na sekundę a taką, która rusza tylko raz na pół sekundy.
Wiedza o kaskadzie, render tree, reflow, repaint i layer promotion nie jest po to, żeby się nią popisywać. Jest po to, żeby kiedy patrzysz na ścinającą się animację, mieć w głowie mapę: “to leci przez layout, mogę ją zmusić, żeby leciała tylko przez composite, zamieniając left na transform”. Albo: “ta pętla robi forced layout dla każdej iteracji, mogę ją rozbić na dwa przebiegi”. Pipeline nie zmienia się ze zmianą frameworka ani wersji Chrome’a, więc raz przyswojony zostaje na długo.
Komentarze
Wczytywanie komentarzy…