Resumability: zamiast hydratować, wznów wykonanie
- data
- kategoria
- Frontend
- czytanie
- 3 min / 592 słów
Hydration zakłada, że browser dostaje HTML wyrenderowany na serwerze, a potem odtwarza client-side runtime potrzebny do dalszego działania aplikacji.
Nawet jeśli ograniczymy hydration do pojedynczych islands, nadal często wykonujemy podobny cykl:
server
|
v
render component
|
v
HTML
browser
|
v
load JS
|
v
recreate component state
|
v
attach behavior
Resumability próbuje zmienić samą naturę tego procesu.
Zamiast rekonstruować aplikację po stronie klienta, próbujemy wznowić wykonanie tam, gdzie serwer je zakończył.
Hydration odtwarza, resumability kontynuuje
Najprostsze porównanie:
hydration:
server executes
|
v
HTML
|
v
browser executes again
|
v
runtime reconstructed
Resumability:
server executes
|
v
HTML + serialized execution metadata
|
v
browser
|
v
continue when needed
To nie oznacza literalnego zatrzymania CPU na serwerze i przeniesienia stack frame'ów przez sieć.
Chodzi o zapisanie wystarczającej ilości informacji, żeby klient nie musiał eager odtwarzać całej struktury aplikacji.
Problem z klasyczną hydration
Załóżmy stronę zawierającą 200 interaktywnych komponentów.
Użytkownik podczas całej sesji kliknie może trzy.
Klasyczna hydration może jednak wykonać pracę potrzebną do aktywacji wszystkich 200.
200 components
|
v
download code
|
v
initialize runtime
|
v
reconstruct state
|
v
attach behavior
To klasyczne eager execution.
Płacimy koszt teraz, ponieważ zakładamy, że być może później będzie potrzebny.
Resumability próbuje odwrócić model:
component exists
|
v
no interaction
|
v
no execution
user clicks
|
v
load exact code needed
|
v
resume
Komercyjny przykład: duży portal e-commerce
Wyobraźmy sobie stronę produktu zawierającą:
gallery
wishlist
selector wariantu
tooltipy
rekomendacje
accordion FAQ
reviews
delivery calculator
newsletter
recently viewed
Większość użytkowników nie użyje wszystkich tych funkcji.
Jeżeli aktywujemy cały runtime podczas page load, klient płaci za potencjalną interaktywność.
Model resumable może próbować przesunąć koszt bliżej realnego momentu użycia.
page load
|
v
mostly HTML
user opens delivery calculator
|
v
load calculator logic
|
v
continue interaction
To jest bardziej agresywna forma lazy execution niż zwykły lazy loading komponentu.
Sam HTML nie wystarczy
Tu wracamy do fundamentalnego problemu.
Załóżmy:
function Product({ id }) {
const add = () => addToCart(id);
return <button onClick={add}>Add to cart</button>;
}
Server może wygenerować:
<button>Add to cart</button>
Ale żeby wznowić interakcję, browser musi wiedzieć:
this button
|
v
needs handler X
|
v
handler requires product id 42
|
v
handler implementation lives in chunk Y
To oznacza, że server-rendered output musi zawierać dodatkowe metadata opisujące powiązania pomiędzy DOM-em, stanem i kodem.
Resumability wymaga więc bardziej świadomego formatu transferu niż samo HTML.
Ciekawostka
Najtrudniejszym elementem nie jest serializacja danych.
To potrafimy robić od dawna:
JSON.stringify({
productId: 42,
quantity: 1
});
Problemem jest serializacja zależności wykonania.
Closure:
() => addToCart(product.id)
nie jest prostą wartością.
Zawiera odniesienie do kodu i lexical environment.
Framework resumable musi więc przekształcić program tak, żeby możliwe było późniejsze odnalezienie:
handler
state
code location
dependencies
bez wcześniejszego wykonania całego component tree.
Dlatego resumability jest w dużej mierze problemem kompilatora i runtime'u, a nie tylko techniką SSR.
Event może uruchomić ładowanie kodu
W klasycznej aplikacji:
load page
|
v
load component JS
|
v
attach click handler
|
v
wait for click
Resumable runtime może odwrócić kolejność:
load page
|
v
wait for click
|
v
identify handler
|
v
load handler code
|
v
execute
To bardzo ciekawa zmiana.
JavaScript przestaje być ładowany dlatego, że komponent istnieje.
Jest ładowany dlatego, że wykonanie stało się potrzebne.
Kto przechwytuje pierwszy event?
Oczywiście coś nadal musi działać po stronie klienta.
Jeżeli użytkownik kliknie:
<button>Add to cart</button>
browser musi wiedzieć, że kliknięcie ma znaczenie dla aplikacji.
Dlatego resumability zwykle potrzebuje małego runtime'u startowego.
Jego zadaniem może być:
event
|
v
inspect metadata
|
v
find required code
|
v
load chunk
|
v
restore required state
|
v
execute handler
Czyli JavaScript nie znika.
Zmienia się jego granularność i moment wykonania.
Koszt przenosi się gdzie indziej
Każda optymalizacja coś kosztuje.
Resumability może ograniczyć startup CPU i ilość eager JavaScriptu, ale wymaga:
serializowania dodatkowego stanu
metadata w HTML
transformacji kodu podczas builda
granularnych chunków
runtime'u rozwiązującego dependencies
Możemy więc zmniejszyć:
initial execution cost
kosztem większej złożoności:
compiler
+
delivery format
+
runtime
To bardzo typowy trade-off w architekturze.
Typowe rozwiązanie: Qwik
Najbardziej znanym współczesnym przykładem jest Qwik.
Jego architektura jest zaprojektowana wokół resumability zamiast klasycznej eager hydration.
Framework próbuje serializować informacje potrzebne do późniejszego wykonania i ładować kod możliwie granularnie dopiero wtedy, gdy jest potrzebny.
Istotniejsza od samej biblioteki jest jednak idea:
startup aplikacji nie musi oznaczać uruchomienia całej aplikacji.
Resumability nie jest automatycznie lepsze
Dla strony z dużą ilością contentu i relatywnie niewielką liczbą interakcji model jest bardzo atrakcyjny.
Ale wyobraźmy sobie aplikację podobną do Figmy.
Użytkownik natychmiast:
zaznacza elementy
przeciąga
zmienia właściwości
korzysta ze skrótów
otwiera panele
modyfikuje wspólny state
Duża część aplikacji szybko stanie się aktywna.
Koszt lazy resolution może wtedy mieć mniejsze znaczenie, a klasyczny długowieczny runtime może być prostszym modelem.
Znów nie ma jednej najlepszej architektury.
Kolejna granica: kod, który w ogóle nie trafia do klienta
Resumability zadaje pytanie:
czy możemy nie wykonywać client code, dopóki naprawdę go nie potrzebujemy?
Ale można pójść jeszcze dalej.
Co jeśli część komponentów nigdy nie powinna mieć client-side implementacji?
Jeżeli komponent:
pobiera dane z bazy
wykonuje server-only transformację
renderuje statyczną część UI
to być może nie ma żadnego powodu, żeby jego kod trafił do browsera.
Wtedy przestajemy optymalizować tylko moment wykonania.
Zaczynamy dzielić sam module graph na kod serwerowy i kliencki.
I właśnie tutaj pojawiają się Server Components.