GhostPizza to pizzeria w Obornikach, dla której utrzymuję stronę ghost-pizza.pl z menu, galerią i panelem do edycji treści. Poprzednia wersja działała na Laravelu z frontendem w React i była wolna na telefonach: w Lighthouse na mobile strony dostawały 60–70 punktów za wydajność, a galeria potrafiła mieć największe malowanie treści (LCP) po około 15 sekundach. 18 października przełączyłem ją na nową wersję, która na wszystkich trzech publicznych stronach ma 100 punktów za wydajność, a LCP galerii spadło do około 1,2 sekundy. Sprawdź sam w PageSpeed Insights.
Założenie: zero JavaScriptu tam, gdzie nie jest potrzebny
Zacząłem od przejrzenia, co właściwie robił JavaScript na publicznych stronach. Okazało się, że prawie nic: każdy useState i useEffect służył do pobrania danych, a jedyna logika układu to sprawdzenie szerokości okna, żeby wybrać zdjęcie w nagłówku na telefon. Treści nie było w HTML-u z serwera, co szkodziło i szybkości, i wyszukiwarkom.
Nowa wersja renderuje strony główną, menu i galerię na serwerze przez hono/jsx, z komponentami przeniesionymi jeden do jednego. Wszystkie dane strony ładują się na serwerze w jednym przebiegu. JavaScript zastąpił zwykły HTML: <picture> z atrybutem media wybiera zdjęcie na telefon, kilka ikon to wbudowane SVG, a mapa Google ładuje się leniwie. Strony nie wysyłają żadnego własnego skryptu, a test Lighthouse w repozytorium pilnuje, żeby tak zostało.
Panel administracyjny to osobna aplikacja SvelteKit. Tam JavaScript ma sens, bo to narzędzie do pracy, a nie strona dla klientów. Konta przeszły ze starej wersji razem z hasłami, bo nowa weryfikuje stare skróty bcrypt, więc nikt nie musiał niczego resetować.
Wyrenderowane strony w pamięci
Treść zmienia się rzadko, raz na kilka miesięcy, ale właściciel może ją w każdej chwili poprawić sam w panelu. Dlatego każda wyrenderowana strona trafia do pamięci procesu, od razu w dwóch wersjach: zwykłej i skompresowanej gzipem. Trafienie w cache to jedno wyszukanie w mapie, bez renderowania i bez kompresji przy każdym żądaniu. Każdy zapis w panelu czyści cały cache.
Jest w tym subtelność. Render, który zaczął się tuż przed zapisem w panelu, mógłby skończyć się już po nim i włożyć do cache starą treść. Dlatego cache ma licznik wersji: render zapamiętuje wersję z chwili startu i nie zapisuje wyniku, jeśli w międzyczasie ktoś wyczyścił cache.
Cały mechanizm to kilkanaście linii:
export function createHtmlCache(): HtmlCache {
const pages = new Map<string, CachedBody>();
let version = 0;
return {
get: (key) => pages.get(key),
// render zaczęty przed zapisem w panelu nie odkłada starej treści
set: (key, body, renderedAt) => {
if (renderedAt === version) pages.set(key, body);
},
invalidateAll: () => {
pages.clear();
version += 1;
},
get version() {
return version;
},
};
}
To rozwiązanie działa, bo aplikacja ma jedną replikę. Przy kilku replikach każda trzymałaby własny cache i potrzebne byłoby wspólne unieważnianie. Zapisałem to w dokumencie decyzji, żeby nikt nie skalował jej w ciemno.
Czego się nauczyłem
- Zanim przepiszesz, sprawdź, co kod naprawdę robi. Cały JavaScript publicznych stron dało się zastąpić HTML-em, bo jego jedynym zadaniem było pobieranie danych, które serwer mógł wstawić od razu.
- Zapisuj ograniczenia razem z decyzją. Cache w pamięci jest prosty i szybki, ale działa tylko przy jednej replice. Bez tej notatki pierwsza próba skalowania skończyłaby się nieaktualnymi stronami.