Konrad Kowalski (rootsher)Principal Platform & Reliability Architect001101000011000111110100001010111000001011010110

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:

text
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:

text
hydration:

server executes
  |
  v
HTML
  |
  v
browser executes again
  |
  v
runtime reconstructed

Resumability:

text
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.

text
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:

text
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ą:

text
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.

text
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:

jsx
function Product({ id }) {
  const add = () => addToCart(id);

  return <button onClick={add}>Add to cart</button>;
}

Server może wygenerować:

html
<button>Add to cart</button>

Ale żeby wznowić interakcję, browser musi wiedzieć:

text
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:

js
JSON.stringify({
  productId: 42,
  quantity: 1
});

Problemem jest serializacja zależności wykonania.

Closure:

js
() => 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:

text
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:

text
load page
  |
  v
load component JS
  |
  v
attach click handler
  |
  v
wait for click

Resumable runtime może odwrócić kolejność:

text
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:

html
<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ć:

text
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:

text
serializowania dodatkowego stanu
metadata w HTML
transformacji kodu podczas builda
granularnych chunków
runtime'u rozwiązującego dependencies

Możemy więc zmniejszyć:

text
initial execution cost

kosztem większej złożoności:

text
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:

text
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:

text
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.