Dokumentacja pokazuje wszystkim to samo. Opis funkcji walidującej nie różni się między dwoma czytelnikami, zmienia się dopiero wtedy, gdy ktoś zmerguje pull requesta. Mimo to domyślny sposób postawienia takiego serwisu, w praktycznie każdym frameworku, polega na renderowaniu go od nowa przy każdym wejściu.
Rozłożyliśmy to na czynniki pierwsze na dwóch działających serwisach: valibot.dev i formisch.dev, czyli dokumentacjach bibliotek TypeScript rozwijanych przez Open Circle. Razem to ponad 1100 podstron i obie renderowały się przy wejściu. Poniżej to, co naprawdę trzeba było zmienić, łącznie z fragmentem, który wygląda na drobiazg, a nim nie jest.
Wąskie gardło nie było w przeglądarce
Warto to powiedzieć dokładnie, bo pierwszy podejrzany okazał się niewinny.
Oba serwisy stoją na Qwiku, który nie hydratuje, tylko wznawia pracę. Nie ma bootowania frameworka po stronie klienta ani ponownego przechodzenia drzewa komponentów po to, żeby podpiąć zdarzenia. Przeglądarka miała już właściwie nic do roboty.
Koszt siedział piętro wyżej. Każde wejście uruchamiało funkcję na edge, a ta funkcja istniała z jednego powodu: ustawień czytelnika. Motyw, kolumna z rozdziałami i wybór frameworka leżały w ciasteczkach httpOnly, odczytywanych po stronie serwera przez routeLoader$. W momencie, w którym HTML zależy od ciasteczka, przestaje być wspólny. Nie da się go oddać do CDN i pozwolić mu odpowiedzieć tymi samymi bajtami kolejnemu tysiącowi osób.
Ciasteczko nie było więc szczegółem implementacji. Było tym, co trzymało statyczny serwis w trybie dynamicznym.
Wszystko do kompilacji, nawigacja zostaje
Zamiana adaptera Vercel Edge na adapter SSG z Qwik Routera zmienia to, co wychodzi z builda. Zamiast bundla serwerowego każda trasa daje dwa pliki: HTML i leżący obok niego q-data.json.
Ten podział ma znaczenie. HTML dostaje osoba, która wchodzi na zimno, prosto z cache. q-data.json pobiera nawigacja po stronie klienta, kiedy czytelnik klika dalej, więc poruszanie się po serwisie nadal przypomina aplikację, a nie serię przeładowań. Dla czytelnika nie zmienia się nic. Zmienia się tylko to, że żadne wejście niczego nie budzi.
Najtrudniejsze: ustawienia bez miknięcia
Tutaj zwykła migracja zamienia się w prawdziwy problem.
Kiedy przenosisz ustawienie z ciasteczka do przeglądarki, jedno rozwiązanie samo się narzuca i jest złe. Czytasz localStorage w useVisibleTask$, albo w odpowiedniku tego haka w twoim frameworku, i stosujesz wartość. Ten kod wykonuje się po pierwszym narysowaniu strony. Czytelnik widzi więc najpierw wartość domyślną, a swoją dopiero klatkę później.
Przy motywie to miknięcie jasnego tła u kogoś, kto świadomie wybrał ciemne. Drobiazg, po którym strona sprawia wrażenie zepsutej.
Przy przełączniku rozdziałów jest gorzej, bo to ustawienie nie dotyczy koloru. Rusza max-width, dokłada albo zabiera boczną kolumnę i zmienia marginesy treści. Zastosowane po narysowaniu strony jest skokiem layoutu na treści, na którą czytelnik właśnie patrzy.
Rozwiązaniem nie jest przyspieszenie pracy wykonywanej po narysowaniu. Jest nim podjęcie decyzji, zanim cokolwiek zostanie narysowane:
Krótki blokujący skrypt w
<head>sięga dolocalStoragei dokłada klasę do elementu<html>. Blokujący jest tu istotą rzeczy. Musi wykonać się, zanim przeglądarka cokolwiek narysuje, a to jedyne miejsce w dokumencie, które to gwarantuje.Arkusz stylów opiera się na tej klasie i wyłącznie na niej. Nigdy na sygnale dostępnym po wznowieniu, bo to wpuszcza ten sam problem kolejności innymi drzwiami.
W Tailwindzie v4 stan widoczny jest wariantem bazowym, a własny wariant
no-chaptersgo nadpisuje. Ten sam model codark. Odwrotnie, czyli z ukryciem jako stanem bazowym, oznacza, że na klasę czeka przypadek najczęstszy.Sekcja
asidez rozdziałami renderuje się zawsze, a chowa ją wariant CSS. Element, który jest w drzewie i zostaje ukryty, nie kosztuje nic. Element renderowany warunkowo przebudowuje układ w momencie pojawienia się.
Sygnały zostały w komponencie. Po prostu nie decydują już o układzie. Odpowiadają wyłącznie za opis przycisku, czyli za pracę po narysowaniu strony, której nikt nie zobaczy.
Reguła ogólna, która z tego wychodzi: wszystko, co zmienia geometrię, musi zostać rozstrzygnięte przed pierwszym narysowaniem, inaczej będzie widoczne jako skok. Wszystko, co zmienia tylko podpis, może poczekać.
Obrazy OG należą do kompilacji
Wcześniej podgląd do social mediów rysowała trasa /og-image, uruchamiana przy każdym zapytaniu przez @vercel/og. To funkcja na ścieżce żądania obsługująca roboty, czyli coś lekko absurdalnego do utrzymania w momencie, w którym usunęło się już funkcję obsługującą ludzi.
Teraz jeden PNG na trasę powstaje podczas kompilacji i przechodzi przez sharp z kwantyzacją palety. Karty składają się z kilku płaskich kolorów, więc kwantyzacja jest praktycznie bezstratna i mocno zbija rozmiar pliku. Znacznik w <head> prowadzi wprost do /og/<slug>.png. Robot pytający o podgląd dostaje plik z CDN.
Nagłówki cache dopasowane do tego, czym plik naprawdę jest
Wynik kompilacji to nie jedna kategoria i przypisanie mu jednej polityki cache kończy się albo zostawieniem wydajności na stole, albo zepsutym serwisem po wdrożeniu.
Pliki z hashem w nazwie, czyli /build, /assets i /fonts, dostają rok i immutable. Nazwa zmienia się razem z zawartością, więc nieaktualna kopia po prostu nie istnieje.
q-data.json świadomie nie dostaje takiego traktowania i to jest ten przypadek, na którym najłatwiej się przejechać. Adres jest stały, ale zawartość zmienia się przy każdym wdrożeniu, bo odwołuje się do hashy symboli. Po oznaczeniu go jako immutable wracający czytelnik przechodzi dalej po stronie klienta, dostaje dane z poprzedniego wdrożenia i prosi o symbole, których już nie ma.
Dlatego dostaje krótkie max-age dla przeglądarki, długie s-maxage, żeby CDN nadal przejmował ruch, i tydzień stale-while-revalidate. Przejścia zostają natychmiastowe, a nikt nie utknie z plikiem wskazującym na build, którego już nie ma.
Jeden patch, inaczej po cichu tracisz podstrony
SSG w Qwik Routerze dopisywał ukośnik do wszystkiego, co uznał za trasę, również do ścieżek zakończonych rozszerzeniem pliku. Efektem nie był błąd. Efektem były podstrony, których zabrakło w wyniku kompilacji, i przestające działać adresy z kropką, takie jak .md czy .png.
Ten rodzaj awarii wart jest osobnej uwagi, bo jest groźny. Build, który się wywala, zostaje naprawiony tego samego dnia. Build, który po cichu produkuje mniej podstron niż wczoraj, zostaje zauważony wtedy, gdy ktoś zgłosi martwy link.
Patch ogranicza dopisywanie ukośnika do ścieżek bez znanego rozszerzenia i brakujące trasy wróciły. Wyłapało to porównanie listy tras między starym a nowym buildem, a nie zaufanie do tego, że kompilacja się udała.
Co z tego zostaje
Z kompilacji wychodzi HTML, JSON, fonty i obrazy. Nie ma tam żadnego runtime'u przywiązanego do konkretnego adaptera, więc ten sam katalog postawi u siebie dowolny CDN, a wybór hostingu pozostaje otwarty, zamiast zostać podjęty raz i odziedziczony na zawsze. Dziś oba serwisy stoją na Cloudflare, a przy trafieniu w cache pierwszy bajt wraca w około 80 ms z Europy.
Najważniejsze nie jest tu jednak podmienienie adaptera, bo to zmiana konfiguracji. Najważniejsze jest to, że ciasteczko było architekturą. Cały mechanizm renderowania przy wejściu istniał po to, żeby obsłużyć trzy ustawienia czytelnika, a przeniesienie tych trzech ustawień do klasy na <html> jest tym, co pozwoliło wrzucić cały serwis do cache.
Przykład z życia, razem z tym, co to oznaczało dla zespołu utrzymującego biblioteki: