Nowoczesny rendering to kompozycja
- data
- kategoria
- Frontend
- także w
- Software Architecture · System Design
- czytanie
- 3 min / 562 słów
Przeszliśmy długą drogę od prostego modelu:
request
|
v
server
|
v
HTML
|
v
browser
Potem pojawiły się AJAX, SPA, CSR, powrót SSR, SSG, deferred generation, revalidation, streaming, hydration, islands, resumability i Server Components.
Łatwo z tego zrobić zoo akronimów.
Lepszy mental model jest prostszy:
współczesna architektura renderowania to zestaw niezależnych decyzji o compute, cache, sieci i client runtime.
Nie wybierasz już jednego trybu dla aplikacji
Dawniej pytanie brzmiało:
SSR czy CSR?
Później:
SSR czy SSG?
Dzisiaj sensowniejsze pytania to:
kiedy renderować?
gdzie renderować?
co cache'ować?
co streamować?
co wysłać do klienta?
co hydratować?
To kilka różnych osi.
Jedna aplikacja może używać wielu odpowiedzi jednocześnie.
Weźmy realny marketplace
Załóżmy platformę podobną do dużego sklepu lub marketplace'u.
Mamy:
Homepage
Search
Category
Product page
Cart
Checkout
Account
Admin
Próba ustawienia jednego:
renderingMode = "SSR"
dla całej aplikacji jest architektonicznie mało interesująca.
Każdy fragment ma inne właściwości.
Homepage
Homepage może zawierać głównie content marketingowy:
hero
categories
campaign banners
popular products
footer
Duża część zmienia się stosunkowo rzadko.
Dobry model:
prerender
+
revalidation
+
CDN
Nie ma powodu renderować tego od zera dla każdego użytkownika.
Ale mały fragment:
Hello, Anna
Your cart: 3
jest personalizowany.
Nie powinien niszczyć cache'owalności całego dokumentu.
To miejsce na dynamiczny fragment albo client-side activation.
Search
Search jest inny.
Query:
/search?q=mechanical+keyboard
ma potencjalnie ogromną liczbę wariantów.
Nie będziemy generować ich podczas builda.
Search może więc korzystać z:
request-time data
+
server render
+
client-side interactions
Pierwszy wynik może powstać na serwerze, a późniejsze:
filters
sorting
pagination
mogą działać po stronie klienta.
To nie jest ani „SSR application”, ani „CSR application”.
To kompozycja obu modeli.
Product detail page
Tutaj robi się jeszcze ciekawiej.
Product Page
├── description
├── technical specs
├── price
├── availability
├── recommendations
├── reviews
├── wishlist
└── add to cart
Każdy fragment ma inne właściwości.
Możemy mieć:
description
-> prerender + long cache
technical specs
-> prerender
price
-> short revalidation / dynamic
availability
-> dynamic
recommendations
-> streamed
wishlist
-> client
add to cart
-> client
Do tego część komponentów może być server-only i nigdy nie trafić do browsera.
Jedna strona wykorzystuje więc kilka generacji architektury renderowania jednocześnie.
Cart i checkout
Koszyk jest jeszcze inną klasą problemu.
Tutaj mamy:
silną personalizację
dużo interakcji
optimistic updates
częste mutation
bardzo świeże dane
Nie ma większego sensu próbować traktować całego cart UI jak statycznego contentu.
Client-side runtime ma tutaj dużą wartość.
Checkout może jednak nadal dostać początkowy HTML z serwera.
Czyli:
server initial render
+
long-lived client state
To nadal sensowny model.
Ciekawostka
Najważniejsze granice współczesnej aplikacji często nie pokrywają się z route'ami.
Możemy mieć:
/products/42
ale wewnątrz niego różne boundary:
cache boundary
streaming boundary
server/client boundary
hydration boundary
error boundary
To pięć różnych granic na tej samej stronie.
Framework może przedstawiać je wszystkie przez podobną strukturę komponentów, ale z punktu widzenia runtime'u rozwiązują inne problemy.
Dlatego component tree coraz częściej staje się jednocześnie opisem architektury wykonania.
Cztery zasoby
Większość decyzji można sprowadzić do czterech kosztów.
Compute
Gdzie wykonujemy pracę?
build machine
origin
edge
browser
Network
Co musi przekroczyć sieć?
HTML
JSON
JavaScript
component payload
images
Storage / cache
Który wynik możemy policzyć raz i później reuse'ować?
static artifact
CDN cache
fragment cache
data cache
Client CPU
Ile pracy zostawiamy urządzeniu użytkownika?
parse JS
execute JS
hydrate
render
handle interactions
Nowe techniki renderowania zwykle nie eliminują kosztu.
Przesuwają go pomiędzy tymi czterema miejscami.
SSR nie jest darmowy
Przesuwamy compute:
browser -> server
ale zwiększamy:
server CPU
request latency
infrastructure complexity
SSG nie jest darmowe
Przesuwamy compute:
request-time -> build-time
ale zwiększamy:
build cost
storage
invalidation complexity
Hydration nie jest darmowa
Kupujemy:
early HTML
+
client application
kosztem:
server render
+
client reconstruction
Islands nie są darmowe
Redukujemy:
global client runtime
ale zwiększamy:
number of boundaries
state coordination
runtime fragmentation
Każda architektura jest wymianą kosztów.
Framework powinien być konsekwencją decyzji
To odwraca popularny sposób projektowania.
Nie:
używamy frameworka X
|
v
więc będziemy używać feature Y
Tylko:
ten fragment:
- zmienia się rzadko
- ma duży ruch
- nie jest personalizowany
|
v
powinien być prerenderowany i cache'owany
A dopiero potem pytamy:
jak implementujemy to w naszym stacku?
Next.js, Nuxt, Astro, SvelteKit czy inne meta-frameworki są narzędziami do implementacji tych decyzji.
Nie powinny być ich źródłem.
Najważniejsze pytanie: gdzie postawić granice
Senior frontend engineer coraz rzadziej optymalizuje pojedyncze:
render()
Coraz częściej projektuje granice:
static | dynamic
server | client
cached | fresh
eager | lazy
blocking | streamed
Dobrze postawiona granica potrafi usunąć całe klasy kosztów.
Źle postawiona może sprawić, że:
personalizacja wyłączy cache całej strony
jeden client component wciągnie duży dependency graph do browsera
jeden wolny fetch zablokuje cały SSR
jedna duża hydration boundary zajmie main thread
Nie wróciliśmy do server-rendered webu
Na pierwszy rzut oka historia zatoczyła koło.
Zaczęliśmy od:
server -> HTML
i znowu dużo mówimy o serwerze.
Ale współczesny model jest inny.
Dzisiaj możemy decydować osobno:
ten fragment powstaje podczas builda
ten podczas requestu
ten na edge
ten streamujemy
ten zostaje server-only
ten aktywujemy w browserze
ten uruchamiamy dopiero po kliknięciu
Nie wróciliśmy więc do starego SSR.
Doszliśmy do architektury, w której renderowanie przestało być pojedynczym etapem.
Stało się problemem rozmieszczenia pracy w czasie, przestrzeni i pomiędzy runtime'ami.
I to jest chyba najlepszy sposób patrzenia na współczesny frontend:
nie jako wybór kolejnego skrótu,
ale jako projektowanie przepływu compute, danych i interaktywności przez cały system.