Koszt wysyłania aplikacji do browsera
- data
- kategoria
- Frontend
- czytanie
- 3 min / 639 słów
SPA i CSR rozwiązały problem interaktywności.
Browser przestał być tylko rendererem dokumentów i stał się runtime'em aplikacji. To umożliwiło płynne przejścia, lokalny stan, optimistic updates i UI, które mogło działać przez wiele godzin bez pełnego reloadu.
Ale pojawił się nowy koszt.
Nie chodziło już tylko o to, ile danych trzeba pobrać.
Chodziło o to, ile pracy trzeba wykonać zanim aplikacja stanie się użyteczna.
HTML przestał być końcowym produktem
W klasycznym webie odpowiedź serwera była niemal gotowym interfejsem.
request
|
v
HTML
|
v
parse
|
v
paint
W dużej aplikacji CSR ścieżka wygląda inaczej:
request
|
v
HTML shell
|
v
JavaScript
|
v
parse
|
v
compile
|
v
execute
|
v
initialize runtime
|
v
fetch data
|
v
render
|
v
layout
|
v
paint
To istotna różnica.
HTML może dotrzeć po 100 ms, a użytkownik nadal nie zobaczy właściwego contentu przez kolejną sekundę.
TTFB przestaje więc opisywać realne doświadczenie użytkownika.
Bundle size to tylko część problemu
Przez długi czas frontend performance był mocno utożsamiany z rozmiarem bundle'a.
main.js - 700 KB
To oczywiście ma znaczenie, ale transfer size jest tylko pierwszym kosztem.
JavaScript trzeba jeszcze:
download
|
v
decompress
|
v
parse
|
v
compile
|
v
execute
A wykonanie kodu nie dzieje się w próżni.
W typowej aplikacji runtime musi:
zarejestrować moduły
zainicjalizować framework
utworzyć struktury komponentów
uruchomić efekty
zbudować początkowy state
wykonać render
podpiąć event listenery
Na desktopie z mocnym CPU może być to praktycznie niezauważalne.
Na słabszym telefonie dokładnie ten sam JavaScript może stać się głównym bottleneckiem.
Main thread jest współdzielonym zasobem
Najważniejszy problem nie brzmi:
JavaScript jest wolny.
Problem brzmi:
JavaScript konkuruje o ten sam main thread z pracą potrzebną do wyświetlenia i obsługi UI.
W uproszczeniu:
main thread
JS
style
layout
event handlers
more JS
paint preparation
more JS
Jeżeli podczas startupu aplikacja wykonuje 300 ms synchronicznej pracy, browser nie może w tym samym czasie obsłużyć kliknięcia.
Użytkownik może już widzieć button, ale kliknięcie zostanie przetworzone dopiero wtedy, gdy main thread odzyska możliwość wykonania kolejnego taska.
To jedna z ważniejszych różnic między:
content visible
a
application responsive.
Ciekawostka
JavaScript engine nie wykonuje source code bezpośrednio linia po linii.
Współczesne silniki najpierw parsują kod, tworzą jego wewnętrzną reprezentację, generują bytecode, a często wykonywane fragmenty mogą później trafić do optymalizującego JIT-a.
Dlatego koszt JavaScriptu zależy nie tylko od liczby bajtów.
Dwa bundle o podobnym rozmiarze mogą mieć różny koszt parsowania, kompilacji i wykonania.
To jeden z powodów, dla których metryka:
KB transferred
nie jest pełnym opisem kosztu aplikacji.
Data fetching może zacząć się za późno
CSR tworzy jeszcze jeden problem: dependency discovery.
Załóżmy, że browser pobiera:
<div id="app"></div>
<script src="/app.js"></script>
Dopiero po wykonaniu JavaScriptu aplikacja odkrywa, że potrzebuje produktu:
const product = await fetch("/api/products/42");
Sieciowo wygląda to tak:
HTML
|
v
discover app.js
|
v
request app.js
|
v
download
|
v
execute
|
v
discover /api/products/42
|
v
request data
To klasyczny waterfall.
Browser nie mógł wcześniej zacząć pobierać danych, bo nie wiedział, że będą potrzebne.
Im więcej zależności odkrywanych jest dopiero podczas wykonywania aplikacji, tym bardziej critical path staje się sekwencyjny.
Code splitting pomaga, ale zmienia problem
Naturalną odpowiedzią na duże bundle było dzielenie aplikacji.
Zamiast:
app.js
mamy:
runtime.js
vendor.js
dashboard.js
charts.js
editor.js
To pozwala nie pobierać kodu, którego użytkownik aktualnie nie potrzebuje.
Ale tworzy nowe pytanie:
kiedy browser dowie się, którego chunku potrzebuje?
Jeżeli dopiero po:
main bundle
|
v
router
|
v
route match
|
v
dynamic import
to znowu tworzymy waterfall.
Code splitting nie usuwa więc problemu dependency graphu.
Zmienia tylko jego kształt.
Komercyjny przykład: sklep internetowy
Wyobraźmy sobie stronę produktu zbudowaną jako czysty CSR.
Browser pobiera shell aplikacji.
Następnie:
load framework
|
v
load router
|
v
recognize /products/42
|
v
load product chunk
|
v
execute component
|
v
request product API
|
v
receive data
|
v
render product
Tymczasem użytkownik chce zobaczyć przede wszystkim:
Mechanical Keyboard
129 zł
In stock
To są dane, które serwer często zna już podczas pierwszego requestu.
Wysyłanie pustego shellu tylko po to, żeby browser później odpytał serwer o informacje potrzebne do wygenerowania pierwszego ekranu, zaczyna wyglądać jak niepotrzebny round trip.
W aplikacji typu dashboard można ten koszt zaakceptować.
W e-commerce, newsach czy publicznym katalogu produktów zaczyna być trudniejszy do obrony.
Problem nie był tylko wydajnościowy
CSR komplikował też inne właściwości webu.
Jeżeli content powstaje dopiero po wykonaniu JavaScriptu, trudniejsze stają się:
crawlability
link previews
generowanie metadata
dostęp do treści przy błędzie JS
szybkie wyświetlenie contentu na słabych urządzeniach
Współczesne crawlery potrafią wykonywać JavaScript znacznie lepiej niż kiedyś, ale architektonicznie nadal istnieje różnica między:
HTTP response contains content
a:
HTTP response contains program that may produce content
Wahadło zaczyna wracać
W połowie lat 2010 coraz więcej zespołów miało już doświadczenie z dużymi SPA.
Korzyści były oczywiste.
Ale oczywiste stały się też koszty:
more JS
-> more startup work
client-only data fetching
-> waterfalls
large runtime
-> more main-thread contention
empty shell
-> later content
Rozwiązaniem nie było porzucenie SPA.
Chodziło o zmianę miejsca, w którym wykonujemy pierwszy render.
Co jeśli serwer uruchomi ten sam kod komponentów wcześniej i wyśle browserowi gotowy HTML?
Użytkownik dostanie treść od razu, a JavaScript może później przejąć aplikację.
W ten sposób frontend wraca do idei znanej od początku webu, ale w zupełnie nowej formie.
Wraca Server-Side Rendering.