Strona główna / Artykuły

Myśl w kategoriach nieważności: praktyczny model tego, jak przeglądarki renderują strony

Naucz się rozumieć proces renderowania w przeglądarce, zadając pytanie o to, co unieważnia każdą zmianę – od problemów z układem elementów i kosztu rysowania po proces kompozycji, hydratacji i strumieniowania.

Wydawca: REACTAPP.TOP11 min czytania2533 słów

Źródła: The Web Page Rendering Process In Browsers: A Deep Dive

Prawie każdy deweloper front-endu potrafi opisać proces renderowania: HTML przekształca się w DOM, CSS w CSSOM, a następnie następują kroki układania elementów, malowania i łączenia ich. Ten ciąg stanowi dobry wstęp, ale jest praktycznie bezużyteczny, gdy strona napotyka problemy, ponieważ opisuje tylko jedną rundę przetwarzania, podczas gdy przeglądarki cały czas wykonują częściowe, powtarzane operacje. Ten przewodnik zastępuje zapamiętaną listę funkcjonalnym modelem opartym na jednym pytaniu, dzięki czemu możesz wyjaśnić problemy z układaniem elementów, wolne procesy malowania, tanie animacje oraz opóźnienia w hydratacji strony, używając tych samych rozumowań i potwierdzić swoje przypuszczenia w DevTools.

Renderowanie jest procesem ciągłym, a nie jednorazowym zdarzeniem

Strona nigdy nie jest renderowana raz i pozostawiana w spokoju. Skrypty modyfikują DOM, zmiany klas wpływają na style, obrazy i czcionki webowe przychodzą późno, użytkownicy przesuwają kursor i przewijają stronę, animacje działają w każdym kadru, a frameworki ponownie renderują komponenty po aktualizacji stanu. Każde z tych zdarzeń zmusza silnik renderowania do odpowiedzi na to samo pytanie:

Co przestało być ważne i ile pracy trzeba odrobić?

Mając to pytanie na uwadze jest ważniejsze niż znawstwo nazw elementów. To właśnie dlatego dwa aplikacje wyglądające identycznie mogą działać zupełnie inaczej na tym samym telefonie: jedna ciągle wywołuje kosztowne ponowne obliczenia, a druga w większości korzysta z pracy, którą przeglądarka może ponownie wykorzystać.

Pierwszy krok: przeglądarka buduje wewnętrzne modele

Gdy przychodzi HTML, parser przekształca go w DOM. DOM nie jest obrazem strony – to drzewo elementów, atrybutów i węzłów tekstowych, wraz z relacjami rodzica i rodzeństwa pomiędzy nimi. Weźmy mały fragment kodu:

<main>
  <h1>Browser Rendering</h1>
  <p>Understanding what happens after HTML arrives.</p>
</main>

Parser przekształca go w drzewo, które silnik może analizować i aktualizować:

Document
└── main
    ├── h1
    │   └── "Browser Rendering"
    └── p
        └── "Understanding what happens after HTML arrives."

Ścieżki stylów przechodzą przez własną obróbkę. Przeglądarka je analizuje, stosuje zasady kaskadowania i dziedziczenia, określa, które reguły pasują do których elementów, oraz oblicza ostateczną wartość każdej właściwości dla każdego elementu. W tym momencie nic jeszcze nie zostało narysowane – silnik gromadzi informacje o stanie strony i potrzebuje zarówno struktury, jak i obliczonych stylów, zanim będzie mógł określić geometrię elementów. Schemat oparty na zależnościach wygląda w ten sposób:

HTML ──→ DOM ───────────┐
                        ├──→ Style + Layout ──→ Paint ──→ Composite
CSS  ──→ Styles ────────┘

Traktuj tę ilustrację jako mapę zależności, a nie opis implementacji. Prawdziwe silniki zawierają znacznie więcej wewnętrznych etapów, pamięci cache, struktur do śledzenia nieważności danych, wielu wątków oraz skrótów specyficznych dla danej platformy. To, co dobrze pokazuje diagram, to to, jakie wyjścia zależą od których wejść. Jeśli chcesz dokładniej przyjrzeć się temu, jak wartości stylów są rozstrzygane przed układem elementów, nasz artykuł na temat miejsca, które zajmuje CSS w procesie przetwarzania w przeglądarce dostarcza bardziej szczegółowych informacji.

Układ to obliczanie zależności

Gdy dostępne są DOM oraz obliczone style, przeglądarka wyznacza geometrię elementów. Proces układu musi rozstrzygnąć takie kwestie jak:

  • Jaka jest szerokość i wysokość każdego elementu?
  • Gdzie znajduje się każdy element?
  • Gdzie tekst się zawija wewnątrz kontenera?
  • Gdzie kończą się dzieci elementu?
  • Jak rozmiar elementu wpływa na sąsiednie elementy?
  • Ostatnie pytanie wyjaśnia, dlaczego układowanie treści jest kosztowne. Załóżmy, że akapit staje się wyższy ze względu na zwiększenie rozmiaru czcionki. Wszystko, co znajduje się po nim w sekwencji, może musieć przesunąć się niżej, a wysokość kontenera może ulec zmianie, co może wpłynąć na jego własne elementy. Zmiana, która wydaje się lokalna, może mieć wpływ na dużą część dokumentu. Dlatego pomocne jest postrzeganie układania treści jako rozwiązywania sieci zależności, a nie jak kładzenia prostokątów na płótnie.

    Mechanizmy układania starają się ograniczyć ponowne obliczenia do dotkniętej poddrzewa, ale to, ile pracy faktycznie zostanie wykonywane, zależy od rodzaju zmiany oraz od sposobu, w jaki elementy ze sobą współpracują. Na przykład treść w kontenerze o stałym rozmiarze zazwyczaj powoduje mniejsze skutki niż treść w kontenerze o automatycznym rozmiarze.

    Anulowanie ma większe znaczenie niż cały proces przetwarzania

    Załóżmy, że skrypt wykona polecenie card.style.width = „500px”. Przeglądarka wie teraz, że jej poprzednie dane geometryczne dotyczące tej karty, a być może także elementów wokół niej, mogą być przestarzałe. Przed następnym kadrem musi określić, które etapy należy ponownie wykonać. W uproszczonej formie:

    Something changes
          │
          ▼
    What does it invalidate?
          │
          ├── Style
          ├── Layout
          ├── Paint
          └── Composite
    

    Kluczowym spostrzeżeniem jest to, że nie wszystkie zmiany przechodzą przez cały proces przetwarzania:

    • Nowa szerokość zmienia dane geometryczne, więc musi zostać ponownie wyliczona struktura układu, a obszar dotknięty zmianą jest zazwyczaj ponownie narysowany.
    • Nowa kolor tła zmienia wygląd bez przesuwania żadnych elementów, więc potrzebne jest ponowne narysowanie obrazu, ale zwykle nie konieczne jest ponowne wyliczanie struktury układu.
    • Nową wartość transform można często zastosować przy znacznie mniejszym nakładzie pracy, ale tylko wtedy, gdy decyzje przeglądarki dotyczące warstw oraz otaczające warunki pozwalają na przetworzenie tej aktualizacji na etapie kompozycji.

    Dlatego ogólne zalecenia typu „unikaj ponownego malowania” lub „używaj tylko GPU” w praktyce nie działają. Korzystne pytanie diagnostyczne brzmi zawsze: jaką czynność unieważniła ta konkretna zmiana? Nasz artykuł na temat ile kosztuje przeglądarce każda zmiana w CSS przedstawia powszechne właściwości według etapów, które one zwykle uruchamiają.

    Jak JavaScript zmusza do wczesnego wykonywania operacji renderowania

    Powszechnym sposobem na przypadkowe wywołanie kosztownego renderowania jest mieszanie się operacji zapisu i odczytu układu. Poniższy pętla ustawia szerokość każdej karty i natychmiast pyta o jej rzeczywistą szerokość:

    for (const card of cards) {
      card.style.width = "500px";
      const width = card.getBoundingClientRect().width;
      console.log(width);
    }
    

    Sam funkcja getBoundingClientRect() nie ma żadnych problemów. Jest to normalna, przydatna API. Koszt wynika z kolejności wykonywanych operacji. Przypisanie stylu pozostawia prace związane z układem do późniejszego wykonania, a w następnej linii żąda się wartości, która może być poprawna dopiero po zakończeniu tych prac. Przeglądarka nie ma wyboru i musi wykonać operacje związane ze stylem oraz układem synchronicznie, tuż przed zwróceniem liczby. W pętli pojawia się tzw. layout thrashing: zapis, odczyt, zapis, odczyt, przy czym za każdym razem wymusza się aktualizację układu.

    Przebudowa kodu tak, aby wszystkie odczyty odbywały się najpierw, a wszystkie zapisy później, eliminuje tę przymusową synchronizację:

    const widths = cards.map(card =>
      card.getBoundingClientRect().width
    );
    
    for (const card of cards) {
      card.style.width = "500px";
    }
    

    Teraz pomiary są przeprowadzane na podstawie pojedynczej, już ważnej struktury rozkładu, a operacje zapisu są pozostawiane przeglądarce do opracowania razem podczas następnego renderowania. Należy zauważyć, że obie wersje nie są ściśle równoważne: druga z nich rejestruje szerokości przed zmianą, więc używaj tej kolejności tylko wtedy, gdy faktycznie tego potrzebujesz. Jeśli operacja odczytu rzeczywiście zależy od wcześniejszego zapisu, standardowymi technikami są grupowanie operacji zapisu i planowanie operacji odczytu na następny kadrowanie, na przykład za pomocą requestAnimationFrame.

    Główna lekcja nie brzmi „nigdy nie mierzyć układu”. JavaScript i proces renderowania korzystają z tej samej wątki oraz tych samych danych, więc kod może tworzyć zależności, które zmuszają przeglądarkę do natychmiastowego wykonania czynności, które w przeciwnym razie zostałyby zaplanowane i zrealizowane efektywnie w grupach. Istnieją również inne subtelne czynniki powodujące problemy z układem, które omawiamy w artykule live DOM collections and layout thrashing.

    Proces malowania to odrębny koszt od układu

    Gdy już znana jest geometria, przeglądarka musi nadal określić wygląd każdego obszaru: tła, obramowania, znaki tekstowe, obrazy, cienie, gradienty, efekty wycięcia i inne elementy. Skutecznym sposobem na oddzielenie tych dwóch etapów jest:

    • Układ określa, gdzie znajdują się elementy i jaki mają rozmiar.
    • Proces malowania decyduje o ich wyglądzie.

    Zmiana wartości background-color zazwyczaj nie wymaga nowej geometrii dla całego dokumentu, podczas gdy zmiana rozmiaru lub pozycji może to konieczne uczynić. Nie należy jednak zakładać, że proces rysowania jest bezkosztowy – rysowanie dużych obszarów, intensywne efekty takie jak duże rozmyte cienie, częste zmiany treści oraz zbyt szerokie aktualizacje mogą sprawić, że czas rysowania będzie na tyle długi, iż opóźni to wyświetlanie klatki.

    Rasterizacja i kompozycja tworzą ostateczną klatkę

    Proces rysowania generuje instrukcje rysunkowe, a niekoniecznie ostateczne piksele. Te instrukcje są następnie rasterizowane na dane pikselowe, często dzielone na płytki, i przetwarzane za pomocą stosu graficznego platformy. Następnie kompozycja łączy powstałe powierzchnie, czyli warstwy, w klatkę, która pojawia się na ekranie.

    To jest etap, w którym transform i opacity zyskują swoją sławę. Gdy warunki są odpowiednie, animowanie tych właściwości pozwala elementowi się poruszać, zmieniać rozmiar lub wygasać bez konieczności ponownego obliczania układu dokumentu lub przerysowywania zawartości elementu przez przeglądarkę; po prostu ponownie łączy istniejące warstwy.

    Popularne uproszczenie „transform działa na GPU, więc zawsze jest szybki” ukrywa jednak rzeczywiste koszty:

    • Każda dodatkowa warstwa zużywa pamięć, co ma znaczenie w urządzeniach mobilnych o niskich parametrach.
    • Zawartość musi być mimo wszystko w pewnym momencie rasterowana, a proces ten może być kosztowny.
    • To, czy element otrzymuje własną warstwę, zależy od heurystyk przeglądarki i kontekstu renderowania, więc ten sam CSS może zachowywać się inaczej w różnych sytuacjach.

    Zasada trwałości jest prostsza od jakiejkolwiek listy właściwości: zmiana, która unieważnia mniej elementów, sprawia, że silnik musi przeliczyć mniejszą ilość danych.

    Budżet zasobów jest wspólny

    Ekran o częstotliwości odświeżania 60Hz odświeża się co około 16,67 milisekundy, ponieważ 1000 / 60 daje w przybliżeniu tę wartość. Przy częstotliwości 120Hz ten interwał skraca się do około 8,33 milisekundy.

    Interwał ten nie stanowi części czasu CPU przeznaczonego wyłącznie na twój kod. W tym czasie przeglądarka musi obsłużyć wykonywanie skryptów, obliczanie stylów, układ elementów, rysowanie, rasterizację, kompozycję obrazu, obsługę danych wejściowych, powroty z sieci oraz własne zadania konserwacyjne. Dlatego jeden długi zadań w JavaScript może opóźnić renderowanie, nawet jeśli sama praca renderowania byłaby prosta.

    To także powód, dla którego takie metryki jak FCP (First Contentful Paint) i INP (Interaction to Next Paint) istnieją oddzielnie. Szybkie wyświetlanie treści oraz szybka reakcja na dane wejściowe to powiązane cele, ale zawodzą z różnych przyczyn i wymagają oddzielnej pomiaru.

    Miejsce budowy interfejsu wpływa na koszt renderowania

    Zanim przeglądarka cokolwiek wyrenderuje, decyzja architektoniczna już wpłynęła na ilość pracy, z którą będzie musiała się zmierzyć: gdzie jest tworzony początkowy interfejs?

    Renderowanie po stronie klienta

    W przypadku CSR serwer wysyła minimalną strukturę HTML oraz kod aplikacji, a urządzenie wykonywa większość pracy:

    Server
       │
       ├── HTML shell
       └── JavaScript + data
                │
                ▼
             Browser
                │
                ├── Parse JS
                ├── Execute JS
                ├── Fetch data
                ├── Build UI
                └── Render
    

    Renderowanie po stronie serwera

    W przypadku SSR pobieranie danych, logika aplikacji oraz generowanie HTML odbywają się na serwerze przed wysłaniem odpowiedzi:

    Request
       │
       ▼
    Server
       │
       ├── Fetch data
       ├── Execute application logic
       └── Generate HTML
                │
                ▼
             Browser
                │
                ▼
          Parse + Render
    

    SSR pozwala szybciej dostarczyć znaczący kod do przeglądarki, podczas gdy CSR przenosi obliczenia i renderowanie na sprzęt użytkownika. Żaden z tych podejść nie jest ogólnie lepszy. Odpowiedni wybór zależy od stopnia złożoności aplikacji, danych, których potrzebuje, efektywności buforowania odpowiedzi, urządzeń używanych przez użytkowników oraz od ilości JavaScriptu, który musi zostać uruchomiony w przeglądarce.

    Hydryacja: wyglądanie na gotowe to nie jest być gotowym

    Wiele nowoczesnych frameworków łączy te dwa podejścia poprzez hydryację. Serwer wysyła HTML, który można natychmiast wyświetlić, a JavaScript po stronie klienta uruchamia aplikację i dodaje konieczne obsługi zdarzeń do interakcji:

    Server HTML
         │
         ▼
    Browser renders content
         │
         ▼
    JavaScript loads
         │
         ▼
    Application initializes
         │
         ▼
    Hydration
         │
         ▼
    Interactive UI
    

    Skutek tego jest łatwy do przeoczenia: strona może wydawać się ukończona na długo przed tym, jak będzie mogła zareagować na kliknięcie. Proces hydratacji obejmuje pobieranie, analizowanie, kompilowanie i wykonywanie dużej ilości kodu JavaScript, inicjalizację komponentów oraz konfigurację słuchaczów, wszystko to na głównym wątku. Dlatego duża aplikacja może sprawić, że treść pojawi się szybciej, ale zaraz potem powstanie nagły wzrost obciążenia głównego wątku, właśnie wtedy, gdy użytkownicy zaczynają klikać.

    To napięcie tłumaczy obecny kierunek rozwoju strategii renderowania: wysyłanie mniejszej ilości kodu JavaScript na stronie klienta, dzielenie plików na mniejsze części oraz aktywowanie tylko tych elementów strony, które faktycznie wymagają interaktywności, zamiast całego drzewa naraz.

    Zmiany w strumieniowaniu w zależności od momentu rozpoczęcia pracy

    Klasyczne SSR tworzy pełną odpowiedź przed jej wysłaniem. Strumieniowanie natomiast wysyła HTML w fragmentach, w miarę jak staje się dostępny:

    Server
      │
      ├── HTML shell ──────────→ Browser
      ├── Content ─────────────→ Browser
      ├── More content ────────→ Browser
      └── Remaining response ──→ Browser
    

    Przeglądarka może analizować i renderować pierwsze fragmenty treści, podczas gdy serwer nadal generuje resztę. Strumieniowanie nie sprawia, że renderowanie jest ogólnie tańsze. Zmienia to jedynie moment, w którym można rozpocząć użyteczną pracę, co może skrócić czas od nawigacji do pokazania treści. Progresywne ładowanie stosuje tę samą zasadę po stronie klienta, rozkładając proces inicjalizacji w czasie zamiast uruchamiać całe oprogramowanie jednocześnie.

    Natychmiast obserwuj proces, zamiast go zapamiętywać

    Naj szybszym sposobem na rozwinięcie intuicji jest bezpośrednia obserwacja przeglądarki. Otwórz panel Performance w Chrome DevTools, nagraj interakcję i sprawdź, co działa się podczas zmian w interfejsie. Następnie wywołaj celowe zmuszone ułożenie elementów, wpisując szerokość i natychmiast odczytując informacje geometryczne:

    element.style.width = "600px";
    element.getBoundingClientRect();
    

    Nagraj ponownie, tym razem z aktualizacją, która zmienia tylko transformację:

    element.style.transform = "translateX(100px)";
    

    Celem tego ćwiczenia nie jest określenie, która właściwość jest dobra, a która zła. Dla każdego nagrania przetwarzaj pytania w kolejności:

    • Czy styl został ponownie obliczony?
    • Czy wykonało się układanie, i czy zostało ono wymuszone przez skrypt?
    • Czy jakaś obszar została ponownie narysowana?
    • Czy nastąpiła rasteryzacja?
    • Czy aktualizację można było w pełni obsłużyć za pomocą komponentu kompozycji?

    Gdy już będziesz czytać te ślady w ten sposób, panel Wydajności przestanie być narzędziem do debugowania w ostateczności i stanie się sposobem na zobaczenie, jak silnik reaguje na każdą linię twojego kodu.

    Poza sześcioetapowym diagramem

    Klasyczny proces nadal warto znać:

    HTML → DOM → CSSOM → Layout → Paint → Composite
    

    Prawdziwy system jest bardziej złożony. Silnik cały czas utrzymuje w synchronizacji kilka powiązanych reprezentacji – od drzewa elementów i obliczonych stylów, przez geometrię elementów i instrukcje rysowania, aż po płytki rasterowe i warstwy skomponowane. Zmiana rzadko zmusza do ponownego utworzenia ich wszystkich. Silnik określa, co nadal jest ważne, oznacza to, co stało się nieaktualne, i ponownie wykorzystuje wszystko, co to możliwe.

    Główne wnioski

    • Zamień pytanie „Jaką optymalizację powinienem zastosować?” na „Co się zmieniło, co to unieważniło i dlaczego przeglądarka musiała to ponownie wykonać?”
    • Rozmieszczenie elementów to problem zależności; zmiany wydające się lokalne mogą mieć wpływ na cały dokument.
    • Mieszane operacje odczytu i zapisu zmuszają do synchronicznego rozplanowywania; oddziel je lub odłóż odczyty na następny kadrowy okres.
  • Malowanie i kompozycja mają swoje własne koszty; transform i opacity są tanie tylko wtedy, gdy warstwy i określone warunki współpracują ze sobą.
  • Budżet zasobów ramki jest dzielony z skryptami i obsługą danych wejściowych, więc długotrwałe zadania wpływają negatywnie nawet wtedy, gdy renderowanie jest tanie.
  • CSR, SSR, hydratacja i streaming przede wszystkim przenoszą pracę w czasie i przestrzeni; oceniaj je na podstawie tego, ile obciążenia dla głównej wątku trafia na urządzenie użytkownika i kiedy to następuje.