Konrad Kowalski (rootsher)Principal Platform & Reliability Architect010100010010111111100010100100000110110000010111

Selective i progressive hydration: nie wszystko musi dostać CPU od razu

data
kategoria
Frontend
czytanie
3 min / 592 słów

Hydration rozwiązuje problem połączenia server-rendered HTML z działającą aplikacją po stronie klienta.

Problem w tym, że klasyczna hydration często traktuje całe drzewo jako jeden duży startup task.

Jeżeli strona ma dziesiątki interaktywnych regionów, browser może wykonać mnóstwo pracy zanim użytkownik w ogóle spróbuje z większości z nich skorzystać.

Naturalny kolejny krok brzmi:

nie hydratować wszystkiego naraz.

Hydration jako kolejka pracy

Załóżmy stronę:

text
header
hero
search
product filters
recommendations
reviews
newsletter
footer

Nie wszystkie elementy mają taki sam priorytet.

Search może być potrzebny natychmiast.

Reviews znajdują się trzy viewporty niżej.

Newsletter może nigdy nie zostać użyty.

Klasyczna hydration może jednak potraktować je podobnie:

text
load JS
  |
  v
recreate whole tree
  |
  v
attach behavior everywhere

Selective hydration próbuje zmienić ten model.

Najpierw to, co istotne

Zamiast jednego dużego zadania możemy myśleć o wielu mniejszych:

text
hydrate search
hydrate navigation
hydrate cart
hydrate reviews
hydrate newsletter

I nadać im priorytety.

Przykładowo:

text
visible + interactive
  |
  v
high priority

below the fold
  |
  v
low priority

never interacted with
  |
  v
maybe never needed

To zmienia hydration z operacji binarnej w problem schedulingu.

Selective i progressive to nie zawsze to samo

Terminologia różni się między frameworkami, ale użyteczny podział jest taki:

Progressive hydration oznacza stopniowe aktywowanie kolejnych części UI.

text
region A
  |
  v
region B
  |
  v
region C

Selective hydration oznacza możliwość wyboru, który region ma zostać aktywowany wcześniej, niezależnie od kolejności drzewa.

text
region A waiting
region B clicked by user
  |
  v
hydrate B first

To subtelna, ale ważna różnica.

W pierwszym modelu robimy pracę partiami.

W drugim scheduler może reagować na realny intent użytkownika.

Komercyjny przykład: portal contentowy

Wyobraźmy sobie homepage dużego portalu.

Na pierwszym ekranie znajdują się:

text
navigation
search
główny artykuł

Niżej:

text
komentarze
wyniki sportowe
rekomendacje
newsletter
interaktywny widget

Jeżeli użytkownik tylko czyta artykuł, natychmiastowa hydration wszystkich tych elementów jest czystym kosztem.

Lepsza strategia może wyglądać tak:

text
navigation -> immediately
search     -> immediately
comments   -> when visible
widget     -> when visible
newsletter -> on interaction

Framework może implementować to automatycznie albo pozwolić developerowi definiować granice.

Visibility to tylko jeden sygnał

Najprostszy trigger:

text
component enters viewport
  |
  v
hydrate

Ale można wykorzystywać różne sygnały:

text
element staje się widoczny
użytkownik najeżdża kursorem
element dostaje focus
użytkownik zaczyna interakcję
browser ma idle time
zakończyło się ważniejsze zadanie

To daje model:

text
priority = f(
  visibility,
  user intent,
  interaction,
  available CPU
)

I to jest znacznie bliższe rzeczywistemu problemowi performance niż proste:

text
hydrate everything ASAP

Ciekawostka

Hydration konkuruje o main thread dokładnie tak samo jak każdy inny JavaScript.

Jeżeli browser ma do wykonania:

text
120 ms hydration task

a użytkownik kliknie po 20 ms, handler kliknięcia nie może po prostu „przerwać” wykonywanego JavaScriptu.

JavaScript na main threadzie działa kooperacyjnie.

Dopóki aktualny task nie odda kontroli:

text
hydration hydration hydration hydration
  |
  v
task ends
  |
  v
click handler

input czeka.

Dlatego dzielenie hydration na mniejsze jednostki może poprawić responsiveness nawet wtedy, gdy łączna ilość pracy pozostaje identyczna.

Mniej blokowania nie oznacza mniej pracy

To ważne rozróżnienie.

Załóżmy:

text
full hydration = 200 ms CPU

Możemy podzielić ją na:

text
20 ms
20 ms
20 ms
...

UI może wtedy reagować pomiędzy taskami.

Ale nadal wykonaliśmy 200 ms pracy.

Progressive hydration poprawia głównie:

kiedy praca blokuje użytkownika.

Nie musi zmniejszać całkowitego kosztu.

Żeby naprawdę wykonać mniej pracy, trzeba pójść krok dalej.

Hydrate on interaction

Załóżmy dropdown, którego większość użytkowników nigdy nie otworzy.

Zamiast:

text
page load
  |
  v
download dropdown JS
  |
  v
hydrate dropdown

możemy zrobić:

text
page load
  |
  v
static HTML

user interacts
  |
  v
load required JS
  |
  v
activate component

Tutaj korzyść jest większa.

Jeśli użytkownik nigdy nie kliknie, koszt może nigdy nie zostać poniesiony.

To jest ważna zmiana z:

text
do work later

na:

text
maybe never do the work

Ale interakcję trzeba jakoś przechwycić

Tu pojawia się ciekawy problem.

Jeżeli komponent nie jest jeszcze aktywny, to kto obsłuży pierwsze kliknięcie?

Potrzebujemy lekkiej warstwy, która potrafi:

text
capture interaction
  |
  v
identify required component
  |
  v
load code
  |
  v
activate component
  |
  v
replay / continue interaction

Czyli opóźnianie hydration nie usuwa infrastruktury.

Przenosi część odpowiedzialności do runtime'u odpowiedzialnego za lazy activation.

Granularność ma znaczenie

Możemy hydratować:

text
whole application

albo:

text
page sections

albo:

text
individual components

Im mniejsza granica, tym większa kontrola.

Ale też więcej:

text
punktów ładowania kodu
zależności
scheduling decisions
runtime bookkeeping

Nie ma sensu rozbijać aplikacji na tysiące granic tylko dlatego, że technicznie możemy.

Granice powinny odpowiadać realnej interaktywności.

Typowe rozwiązania

Współczesne frameworki realizują te idee na różne sposoby.

React rozwija mechanizmy selective hydration w połączeniu z concurrent rendering i Suspense.

Astro pozwala określać sposób aktywowania client components za pomocą strategii typu:

text
load immediately
load when visible
load when browser is idle

Nie jest jednak istotne, która biblioteka ma konkretną składnię.

Mechanizm pozostaje ten sam:

client CPU staje się zasobem, który trzeba przydzielać według priorytetów.

A co jeśli większość strony nigdy nie potrzebuje runtime'u?

Selective hydration nadal zakłada, że mamy server-rendered drzewo aplikacji i wybieramy moment aktywacji jego fragmentów.

Ale można odwrócić założenie.

Zamiast:

text
whole page is an application
some parts activate later

możemy powiedzieć:

text
whole page is static HTML
only some parts are applications

To brzmi podobnie, ale prowadzi do innej architektury.

Jednostką aplikacji przestaje być cała strona.

Stają się nią małe, niezależne wyspy interaktywności.

I właśnie z tego wyrasta Islands Architecture.