Konrad Kowalski (rootsher)Principal Platform & Reliability Architect000101001011101001011100001000101001100000101101

Islands architecture: interaktywne wyspy w statycznym dokumencie

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

Selective hydration zadaje pytanie:

które części aplikacji aktywować wcześniej, a które później?

Islands Architecture idzie krok dalej i zmienia punkt wyjścia.

Nie zakłada już, że cała strona jest aplikacją.

Zakłada raczej:

strona jest dokumentem, a tylko wybrane fragmenty są aplikacjami.

Static by default

Wyobraźmy sobie portal contentowy:

text
article
author bio
table of contents
comments
newsletter
stock ticker
related content

W klasycznym SPA wszystko może należeć do jednego client-side runtime'u.

W modelu islands:

text
STATIC DOCUMENT
├── article
├── author bio
├── table of contents
├── [comments island]
├── [newsletter island]
├── [stock ticker island]
└── related content

Większość strony pozostaje zwykłym HTML-em.

JavaScript trafia tylko tam, gdzie jest realnie potrzebny.

Wyspa jest granicą interaktywności

To najważniejszy mental model.

Nie chodzi tylko o code splitting.

Możemy mieć dwa komponenty ładowane w osobnych chunkach, ale nadal należące do jednego globalnego runtime'u aplikacji.

Island oznacza mocniejszą granicę:

text
static HTML
│
├── interactive runtime A
│
└── interactive runtime B

Każdy interaktywny fragment może zostać aktywowany niezależnie.

Reszta dokumentu nie wymaga hydration.

Komercyjny przykład: portal finansowy

Załóżmy stronę artykułu o wynikach spółki.

Większość contentu:

text
tytuł
tekst
grafiki
tabela danych
bio autora

jest statyczna.

Interaktywne są tylko:

text
live stock ticker
wykres
komentarze
newsletter

W klasycznej aplikacji komponentowej cały route może zostać objęty hydration.

W islands:

text
article      -> static
author       -> static
chart        -> island
ticker       -> island
comments     -> island
newsletter   -> island

Użytkownik dostaje mniej JavaScriptu i mniej pracy startupowej.

Dla content-heavy produktów to często bardzo dobry trade-off.

Islands to nie po prostu lazy loading

Lazy loading mówi:

pobierz kod później.

Islands mówi:

ten fragment ma własną granicę client runtime'u.

To różnica między:

text
one application
  |
  v
lazy component

a:

text
document
  |
  v
independent interactive regions

Lazy loading jest więc mechanizmem delivery.

Islands Architecture jest decyzją o strukturze aplikacji.

Ciekawostka

Granica island nie musi pokrywać się z granicą komponentu.

Możemy mieć:

text
<ProductCard>
  <Image />
  <Title />
  <Price />
  <FavoriteButton />
</ProductCard>

ale tylko:

text
<FavoriteButton />

wymaga JavaScriptu.

Z perspektywy organizacji kodu ProductCard może być jednym komponentem.

Z perspektywy runtime'u tylko mały fragment musi być island.

To pokazuje ważne rozróżnienie:

text
component boundary
!=
bundle boundary
!=
hydration boundary

Framework może je czasem łączyć dla wygody, ale architektonicznie są to trzy różne decyzje.

Każda wyspa może mieć inny moment aktywacji

Nie wszystkie islands muszą startować natychmiast.

Możemy mieć:

text
search
-> activate on load

chart
-> activate when visible

newsletter
-> activate on interaction

comments
-> activate when user scrolls near section

To łączy islands z wcześniejszym problemem schedulingu.

Różnica polega na tym, że teraz część strony nie ma w ogóle nic do hydratowania.

Mniej JavaScriptu to nie jedyna korzyść

Najbardziej oczywista zaleta brzmi:

text
less client JS

Ale równie ważna jest izolacja.

Jeżeli niezależne regiony mają własne granice, łatwiej ograniczyć:

text
zakres rerenderów
ilość współdzielonego state
startup dependencies
blast radius błędu

To może uprościć system.

Ale tylko do pewnego momentu.

Shared state robi się trudniejszy

Załóżmy dwie wyspy:

text
[cart counter]

...

[add to cart]

Obie muszą znać stan koszyka.

Jeśli należą do jednej aplikacji:

text
global store
  |
  v
component A
component B

problem jest prosty.

Przy niezależnych islands trzeba zdecydować, jak synchronizować stan:

text
island A
  ^
  v
shared channel?
  ^
  v
island B

Możliwości jest wiele:

text
browser events
shared store
URL
server state
custom runtime
BroadcastChannel w bardziej specyficznych przypadkach

Ale każda metoda ponownie zwiększa coupling.

Im więcej shared state między wyspami, tym bardziej system zaczyna przypominać jedną aplikację, którą wcześniej próbowaliśmy rozdzielić.

Dużo islands może oznaczać dużo runtime'ów

Kolejny problem to dependencies.

Jeżeli kilka islands używa tego samego frameworka, chcemy uniknąć sytuacji:

text
island A -> framework runtime
island B -> framework runtime
island C -> framework runtime

gdzie ten sam kod jest wielokrotnie pobierany lub inicjalizowany.

Nowoczesne tooling potrafi współdzielić dependency, ale architektura nadal wymaga sensownej granularności.

Sto małych islands nie musi być lepsze niż pięć większych.

Typowe rozwiązania

Najbardziej bezpośrednim współczesnym przykładem jest Astro, które domyślnie renderuje komponenty do HTML-u i wysyła client-side JavaScript tylko dla elementów jawnie oznaczonych jako interaktywne.

Możemy koncepcyjnie powiedzieć:

text
server component
-> HTML only

client island
-> HTML + JS runtime

Podobne idee istniały jednak wcześniej i nie są własnością konkretnego frameworka.

Astro po prostu uczyniło ten model centralnym elementem swojej architektury.

Islands mają świetny sweet spot

Ten model szczególnie dobrze pasuje do aplikacji, które są głównie dokumentami:

text
media
blogi
dokumentacja
marketing
e-commerce content pages
portale informacyjne

Gorzej pasuje do aplikacji, gdzie prawie wszystko współdzieli intensywny state:

text
Figma
complex editor
trading terminal
IDE

Tam próba podzielenia systemu na niezależne islands może stworzyć więcej komunikacji niż korzyści.

Nadal jednak wykonujemy hydration

To kluczowe.

Island ogranicza zakres hydration:

text
whole page
  |
  v
NO

selected region
  |
  v
YES

Ale wewnątrz wyspy nadal często dzieje się klasyczny proces:

text
server HTML
  |
  v
download JS
  |
  v
reconstruct component
  |
  v
hydrate

Zmniejszyliśmy problem.

Nie usunęliśmy go.

A skoro serwer już wykonał komponent i zna jego stan, pojawia się bardziej radykalne pytanie:

czy browser naprawdę musi rekonstruować ten komponent od początku?

Może dałoby się wysłać wystarczająco dużo informacji, żeby klient po prostu kontynuował wykonanie dokładnie tam, gdzie serwer je zostawił.

To prowadzi do Resumability.