Konrad Kowalski (rootsher)Principal Platform & Reliability Architect011100010111011101000010001101010100101101011100

Server Components: nie cały kod UI musi trafić do browsera

data
kategoria
Frontend
także w
Software Architecture
czytanie
3 min / 646 słów

Przez większość tej historii pytaliśmy:

kiedy renderować?

Potem:

gdzie renderować?

Hydration, islands i resumability dokładają kolejne pytanie:

kiedy uruchomić kod po stronie klienta?

Server Components zmieniają poziom abstrakcji.

Pytanie brzmi:

czy ten kod w ogóle powinien trafić do browsera?

Component tree nie musi oznaczać jednego runtime'u

Klasyczny model komponentowy zakłada zwykle, że komponent jest częścią aplikacji, którą ostatecznie potrafi wykonać klient.

Server Components pozwalają przeciąć drzewo:

text
App
├── ProductPage          -> server
│   ├── ProductDetails   -> server
│   ├── Recommendations  -> server
│   └── AddToCart        -> client
└── Navigation           -> client

Część komponentów istnieje tylko po stronie serwera.

Nie trzeba wysyłać ich implementacji do browsera.

To istotna różnica względem zwykłego SSR.

SSR i Server Components rozwiązują inne problemy

SSR odpowiada na pytanie:

gdzie powstaje HTML dla pierwszego renderu?

Server Components odpowiadają:

w którym runtime może wykonywać się dany komponent?

Możemy mieć komponent wyrenderowany na serwerze podczas SSR, którego kod później i tak trafia do browsera na potrzeby hydration.

W Server Components część kodu może być całkowicie server-only.

text
SSR:

component code
  |
  v
server render
  |
  v
HTML
  |
  v
same component code ships to client


Server Component:

component code
  |
  v
server execution
  |
  v
serialized result
  |
  v
no client implementation required

To dlatego utożsamianie RSC z SSR jest błędem.

Dlaczego w ogóle wysyłamy tyle kodu

Załóżmy komponent, którego jedynym zadaniem jest pobranie produktu i przygotowanie danych:

jsx
async function ProductDetails({ id }) {
  const product = await db.products.find(id);

  return (
    <section>
      <h1>{product.name}</h1>
      <p>{product.description}</p>
    </section>
  );
}

Jeżeli ten komponent nie potrzebuje:

text
lokalnego state
event handlerów
browser APIs
efektów

to jego implementacja jest potrzebna głównie po to, żeby stworzyć wynik renderowania.

Po co więc wysyłać ją użytkownikowi?

Server Components pozwalają zatrzymać tę część grafu po stronie serwera.

Komercyjny przykład: product page

Załóżmy dużą stronę produktu.

Mamy:

text
ProductPage
├── ProductDescription
├── TechnicalSpecs
├── RelatedProducts
├── ReviewsSummary
├── VariantSelector
└── AddToCart

Pierwsze cztery fragmenty mogą nie potrzebować client runtime'u.

VariantSelector i AddToCart są interaktywne.

Możemy więc podzielić system:

text
SERVER
├── ProductDescription
├── TechnicalSpecs
├── RelatedProducts
└── ReviewsSummary

CLIENT
├── VariantSelector
└── AddToCart

Dzięki temu browser dostaje kod odpowiedzialny za interakcję, a nie cały kod potrzebny do skonstruowania strony.

Granica server/client jest granicą dependency graphu

To najważniejszy techniczny detal.

Wyobraźmy sobie:

text
ProductPage
  |
  v
MarkdownRenderer
  |
  v
syntax-highlighting-library
  |
  v
large parser

Jeżeli cały ten graph należy do client bundle, browser może dostać dużo kodu tylko po to, żeby wyrenderować treść raz.

Jeśli ProductPage jest server-only:

text
server graph
├── MarkdownRenderer
├── parser
└── database client

te dependencies nie muszą przekraczać granicy sieciowej.

Client dostaje tylko wynik.

Dlatego Server Components można traktować jako mechanizm kontroli dystrybucji kodu, a nie tylko renderowania.

Ciekawostka

Granica Server Component -> Client Component wymaga serializacji.

Server Component nie może po prostu przekazać dowolnej wartości do klienta.

Jeżeli spróbujemy przekazać coś w rodzaju:

js
{
  connection: databaseConnection,
  handler: () => doSomething(),
  stream: internalServerObject
}

client runtime nie ma jak odtworzyć tych obiektów.

Granica sieciowa wymusza kontrakt danych.

W praktyce oznacza to, że architektura komponentów zaczyna mieć podobne ograniczenia jak projektowanie API:

text
server value
  |
  v
serializable representation
  |
  v
network boundary
  |
  v
client value

To bardzo ciekawa zmiana, bo boundary w drzewie UI staje się jednocześnie boundary systemu rozproszonego.

To może usunąć client-side waterfall

Server Components mogą też zmienić sposób pobierania danych.

W klasycznym CSR:

text
browser
  |
  v
component A loads
  |
  v
fetch A
  |
  v
component B discovered
  |
  v
fetch B

Powstaje waterfall.

Jeżeli server component zna cały potrzebny graph wcześniej:

text
server
├── fetch A
├── fetch B
└── render result

część pracy można wykonać bliżej danych i równolegle.

To nie oznacza automatycznie braku waterfalli: źle zaprojektowany server component nadal może robić sekwencyjne fetche.

Ale zmienia się miejsce, w którym można dependency graph zoptymalizować.

Server Components nie eliminują client components

Jeżeli komponent potrzebuje:

js
useState()

albo:

js
window.addEventListener(...)

albo event handlera:

jsx
<button onClick={...}>

to nadal potrzebujemy kodu po stronie klienta.

Dlatego realna aplikacja wygląda raczej tak:

text
server-heavy tree
  |
  v
client boundaries
  |
  v
interactive subtrees

Nie:

text
everything on server

Najważniejszą decyzją staje się więc miejsce postawienia granicy.

Zbyt wysoka granica = za dużo JavaScriptu

Jeżeli oznaczymy duży fragment jako client-side:

text
CLIENT
└── ProductPage
    ├── Description
    ├── Specs
    ├── Reviews
    ├── Gallery
    └── AddToCart

cały dependency graph poniżej może zacząć należeć do klienta.

Jeżeli granicę przesuniemy niżej:

text
SERVER
└── ProductPage
    ├── Description
    ├── Specs
    ├── Reviews
    ├── Gallery
    └── CLIENT AddToCart

browser dostaje znacznie mniej kodu.

To dlatego "use client" w React Server Components jest architektonicznie ważniejsze, niż wygląda.

Nie opisuje tylko komponentu.

Może wpływać na cały graf zależności poniżej boundary.

Typowe rozwiązanie: React Server Components

Najbardziej znanym przykładem są React Server Components, wykorzystywane między innymi przez współczesne frameworki Reactowe.

Implementacja wykorzystuje specjalny format transportowy pozwalający przesyłać wynik server component tree oraz referencje do client components.

Browser nie dostaje po prostu gotowego HTML-a.

Dostaje reprezentację, którą client runtime może wykorzystać do złożenia finalnego drzewa aplikacji.

To jeden z powodów, dla których Server Components są czymś innym niż klasyczny template rendering.

Granice zaczynają się nakładać

W tym miejscu nowoczesna architektura renderowania robi się ciekawa.

Jedna strona może jednocześnie wykorzystywać:

text
static generation
+
revalidation
+
server components
+
streaming
+
client components
+
selective hydration
+
edge cache

Te mechanizmy nie są konkurencją.

Opisują różne osie systemu.

I właśnie dlatego pytanie:

używamy SSR czy SSG?

coraz częściej jest zbyt uproszczone.

Współczesne aplikacje nie wybierają jednej strategii.

Komponują wiele strategii jednocześnie.

To będzie tematem finału tej serii.