Jak w PROXX wykorzystaliśmy podział kodu, wbudowanie kodu i renderowanie po stronie serwera.
Na konferencji Google I/O 2019 – z Mariko, Jake i ja wysłaliśmy PROXX – nowoczesnego klonu Sapera do użytku w internecie. PROXX to przede wszystkim ułatwienia dostępu (można to zagrać za pomocą czytnika ekranu!) i możliwość działania zarówno na telefonach z podstawową przeglądarką, jak i na zaawansowanych komputerach stacjonarnych. Telefony z internetem podlegają ograniczeniom na wiele sposobów:
- Słabe procesory
- Słabe lub niedostępne GPU
- Małe ekrany bez obsługi dotykowej
- Bardzo ograniczona ilość pamięci
Działają one jednak w nowoczesnej przeglądarce i są bardzo przystępne cenowo. Z tego powodu telefony z internetem zyskują popularność na rynkach wschodzących. Ich pułap cenowy pozwala zupełnie nowym odbiorcom, którzy wcześniej nie mogli sobie pozwolić na takie zakupy, dostęp do internetu i korzystanie z nowoczesnych rozwiązań internetowych. Według przewidywań w 2019 roku w samych Indiach zostanie sprzedawanych około 400 milionów telefonów z internetem, więc użytkownicy telefonów z internetem mogą stanowić znaczną część Twoich odbiorców. Poza tym szybkość połączenia na poziomie 2G jest normą na rynkach wschodzących. Jak udało nam się sprawić, że urządzenie PROXX działa dobrze w warunkach telefonów z internetem?
Wydajność jest ważna, bo obejmuje zarówno wydajność wczytywania, jak i wydajność działania. Wykazano, że dobra skuteczność wiąże się z zwiększonym utrzymaniem użytkowników, większą liczbą konwersji i – co najważniejsze – większą integracją społeczną. Jeremy Wagner ma znacznie więcej danych na temat tego, dlaczego skuteczność jest ważna.
To jest pierwsza część dwuczęściowej serii. Część 1 skupia się na wydajności wczytywania, a część 2 – na wydajności działania w czasie działania.
Rozpoznawanie aktualnego stanu
Kluczowe znaczenie ma sprawdzanie wydajności wczytywania na prawdziwym urządzeniu. Jeśli nie masz pod ręką prawdziwego urządzenia, zalecamy skorzystanie z narzędzia WebPageTest, szczególnie w „prostej” konfiguracji. WPT uruchamia baterię testów wczytywania na prawdziwym urządzeniu z emulowanym połączeniem 3G.
3G to szybkość, którą dobrze jest zmierzyć. Chociaż być może jesteś przyzwyczajony do sieci 4G, LTE, a wkrótce nawet 5G, rzeczywistość w internecie mobilnym wygląda zupełnie inaczej. Możesz jechać pociągiem, jechać na konferencję, być na koncercie lub lecieć samolotem. W ten sposób będziesz najprawdopodobniej bliżej 3G, a czasem jeszcze gorszych.
W tym artykule skupimy się na sieci 2G, ponieważ w swojej grupie docelowej PROXX kieruje swoją ofertę na telefony z internetem oraz rynki wschodzące. Po przeprowadzeniu testu WebPageTest otrzymasz kaskadę (podobną do tego w Narzędziach deweloperskich) oraz pasek zdjęć na górze. Na pasku miniatur widać to, co widzi użytkownik podczas wczytywania aplikacji. W przypadku sieci 2G wczytywanie niezoptymalizowanej wersji PROXX jest bardzo złe:
Po wczytaniu przez 3G użytkownik przez 4 sekundy widzi białą pustkę. W przypadku sieci 2G użytkownik przez 8 sekund nie widzi absolutnie nic. Jeśli czytasz, dlaczego skuteczność ma znaczenie, wiesz, że z powodu niecierpliwości tracimy znaczną część potencjalnych użytkowników. Aby coś pojawiło się na ekranie, użytkownik musi pobrać cały kod JavaScript (62 KB). Główną zaletą tego scenariusza jest to, że druga rzecz, która pojawia się na ekranie, jest także interaktywna. A może jednak?
Po pobraniu ok. 62 KB kodu JavaScriptu w formacie gzip i wygenerowaniu DOM użytkownik zobaczy naszą aplikację. Jest ona technicznie interaktywna. Spojrzenie jednak ukazuje inną rzeczywistość. Czcionki internetowe nadal ładują się w tle i do czasu, aż będą gotowe, użytkownik nie będzie mógł zobaczyć tekstu. Chociaż ten stan kwalifikuje się jako pierwsze wyrenderowanie znaczącej wartości (FMP), z pewnością nie jest prawidłowo interaktywny, ponieważ użytkownik nie jest w stanie określić, czego dotyczą dane wejściowe. Aplikacja będzie gotowa do użycia przez kolejną sekundę w sieci 3G i 3 sekundy w sieci 2G. W sumie proces interakcji z aplikacją zajmuje 6 sekund w sieci 3G i 11 sekund w sieci 2G.
Analiza kaskadowa
Skoro już wiemy, co widzi użytkownik, musimy ustalić przyczynę. Możemy wtedy przyjrzeć się kaskadzie i przeanalizować, dlaczego zasoby wczytują się zbyt późno. W śladzie 2G firmy PROXX wyraźnie widać 2 niepokojące sygnały:
- Jest wiele cienkich wielokolorowych linii.
- Pliki JavaScript tworzą łańcuch. Na przykład drugi zasób rozpoczyna się dopiero po zakończeniu pierwszego, a trzeci dopiero po zakończeniu działania drugiego.
Zmniejszam liczbę połączeń
Każdy cienki wiersz (dns
, connect
, ssl
) oznacza utworzenie nowego połączenia HTTP. Konfiguracja nowego połączenia jest kosztowna, ponieważ trwa około 1 s w sieci 3G i ok.2,5 s w sieci 2G. W naszym wodospadzie widzimy nowe połączenie:
- Prośba 1.
index.html
- Prośba 5. Style czcionek z witryny
fonts.googleapis.com
- Prośba 8: Google Analytics
- Żądanie 9: plik czcionki od sprzedawcy
fonts.gstatic.com
- Żądanie 14: manifest aplikacji internetowej
Nie można uniknąć nowego połączenia dla index.html
. Aby pobrać zawartość pliku, przeglądarka musi nawiązać połączenie z naszym serwerem. Nowego połączenia z Google Analytics można uniknąć, wbudowając w nie coś takiego jak Minimalne Analytics. Google Analytics nie blokuje jednak renderowania ani interakcji aplikacji, więc szybkość jej wczytywania nie jest dla nas ważna. Najlepiej, aby dane Google Analytics były ładowane w czasie bezczynności, gdy wszystkie inne dane zostały już wczytane. Dzięki temu nie obciążają one przepustowości ani mocy obliczeniowej podczas początkowego obciążenia. Nowe połączenie z plikiem manifestu aplikacji internetowej jest określone przez specyfikację pobierania, ponieważ plik ten musi być wczytywany przez połączenie bez danych uwierzytelniających. Również plik manifestu aplikacji internetowej nie blokuje renderowania ani interakcji aplikacji, więc nie musimy się tym zajmować.
Problem w tym, że te dwie czcionki i ich style blokują renderowanie i interaktywność. W kodzie CSS dostarczanej przez fonts.googleapis.com
znajdują się 2 reguły @font-face
, po jednej na każdą czcionkę. Style czcionek są w rzeczywistości tak małe, że zdecydowaliśmy się umieścić je w kodzie HTML, usuwając jedno zbędne połączenie. Aby uniknąć kosztów związanych z konfiguracją połączenia z plikami czcionek, możemy skopiować je na nasz serwer.
Równoległe wczytywanie
Patrząc na kaskadę, widzimy, że po zakończeniu wczytywania pierwszego pliku JavaScript nowe pliki zaczynają się od razu ładować. Jest to typowe w przypadku zależności modułów. Nasz moduł główny ma prawdopodobnie importy statyczne, więc kod JavaScript nie może działać, dopóki te operacje importu nie zostaną wczytane. Należy pamiętać, że tego rodzaju zależności są znane w czasie tworzenia. Możemy użyć tagów <link rel="preload">
, aby mieć pewność, że wszystkie zależności zaczną się wczytywać po otrzymaniu kodu HTML.
Wyniki
Sprawdźmy, co udało się osiągnąć przez te zmiany. Ważne jest, aby nie zmieniać żadnych innych zmiennych w konfiguracji testu, które mogłyby zniekształcić wyniki, dlatego do dalszej części tego artykułu użyjemy prostej konfiguracji WebPageTest i wyświetlimy pasek miniatur:
Dzięki tym zmianom skrócił się czas przetwarzania tekstu na mowę z 11 do 8,5, co zajęło nam około 2,5 sekundy czasu konfiguracji połączenia. Świetna robota.
Renderowanie wstępne
Ograniczyliśmy TTI, ale nie wpłynął on tak naprawdę na długotrwały biały ekran, z którego użytkownik musi korzystać w ciągu 8,5 sekundy. Zapewne największe ulepszenia w FMP można uzyskać, wysyłając znaczniki ze stylem w index.html
. Popularne techniki pozwalające na osiągnięcie tego celu to renderowanie wstępne i renderowanie po stronie serwera, które są ściśle powiązane i omówione w artykule Renderowanie w internecie. Obie metody uruchamiają aplikację internetową w węźle i serializują powstały DOM do kodu HTML. Renderowanie po stronie serwera robi to zgodnie z żądaniem po stronie serwera, a renderowanie wstępne odbywa się w czasie kompilacji i zapisuje dane wyjściowe jako nowy element index.html
. PROXX to aplikacja JAMStack, która nie ma serwera po stronie serwera, dlatego zdecydowaliśmy się wdrożyć renderowanie wstępne.
Renderowanie wstępne można wdrożyć na wiele sposobów. W PROXX zdecydowaliśmy się na użycie usługi Puppeteer, która uruchamia Chrome bez interfejsu użytkownika i umożliwia zdalne sterowanie tą instancją za pomocą interfejsu Node API. Używamy go do wstrzyknięcia naszych znaczników oraz kodu JavaScript, a następnie odczytuje DOM jako ciąg HTML. Używamy modułów CSS, więc bezpłatnie otrzymujemy wbudowane style CSS.
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.setContent(rawIndexHTML);
await page.evaluate(codeToRun);
const renderedHTML = await page.content();
browser.close();
await writeFile("index.html", renderedHTML);
Po wprowadzeniu tych aktualizacji możemy spodziewać się poprawy FMP. Musimy wczytywać i wykonywać taką samą ilość kodu JavaScript jak wcześniej, więc nie należy spodziewać się dużych zmian w algorytmie TTI. Jeśli coś się zmieniło, index.html
powiększył się i może spowodować nieco osłabienie mechanizmu TTI. Jest tylko jeden sposób, aby to sprawdzić: uruchomienie narzędzia WebPageTest.
Pierwsze wyrenderowanie elementu znaczącego przesuwa się z 8,5 sekundy do 4,9 sekundy, co jest ogromnym usprawnieniem. Proces TTI nadal trwa około 8,5 sekundy, więc ta zmiana w dużym stopniu nie ma na niego wpływu. W tym przypadku wprowadziliśmy zmianę percepcyjną. Niektórzy mogą nawet nazywać to odsłonięciem. Renderując pośredni wygląd gry, zmieniamy postrzeganą wydajność wczytywania na lepsze.
Wbudowane
Kolejnym wskaźnikiem, który podają nam zarówno Narzędzia deweloperskie, jak i WebPageTest, jest czas do pierwszego bajtu (TTFB). Jest to czas od wysłania pierwszego bajtu żądania do pierwszego bajtu otrzymanej odpowiedzi. Ten czas jest często nazywany czasem RTT, chociaż technicznie istnieje różnica między tymi dwoma liczbami: RTT nie uwzględnia czasu przetwarzania żądania po stronie serwera. DevTools i WebPageTest wizualizują TTFB z jasnym kolorem w bloku żądań/odpowiedzi.
W naszej kaskadzie widzimy, że wszystkie żądania spędzają większość czasu, oczekując na pierwszy bajt odpowiedzi.
Ten problem pierwotnie zawdzięczamy Push HTTP/2. Deweloper aplikacji wie, że potrzebne są określone zasoby i może skłonić go do działania. Zanim klient zauważy, że potrzebuje dodatkowych zasobów, znajduje się już w pamięci podręcznej przeglądarki. Przekazywanie HTTP/2 jest zbyt trudne do prawidłowego wykonania i nie należy tego robić. Ten problematyczny obszar zostanie powtórzony podczas standaryzacji protokołu HTTP/3. Obecnie najprostszym rozwiązaniem jest wbudowanie wszystkich zasobów krytycznych kosztem wydajności buforowania.
Kluczowy kod CSS jest już wbudowany dzięki modułom CSS i mechanizmowi wstępnego renderowania opartego na technologii Puppeteer. W przypadku JavaScriptu musimy wstawić wbudowane moduły kluczowe i ich zależności. To zadanie ma różne poziomy trudności w zależności od używanego pakietu SDK.
Skróciło to o 1 sekundę naszą technologię TTI. Dotarliśmy do punktu, w którym nasz plik index.html
zawiera wszystko, co jest potrzebne do początkowego renderowania i zwiększania interakcji. Kod HTML może się wyrenderować podczas pobierania, co spowoduje utworzenie naszej platformy FMP. Po zakończeniu analizy i wykonania kodu HTML aplikacja jest interaktywna.
Agresywny podział kodu
Tak. index.html
zawiera wszystko, co jest potrzebne do interakcji. Jednak po bliższym przyjrzeniu się okazało, że zawiera ona też wszystko inne. index.html
ma około 43 KB. Spójrzmy na to, z czym użytkownik może wejść w interakcję na początku: mamy formularz do konfigurowania gry z kilkoma komponentami, przyciskiem uruchamiania i prawdopodobnym kodem, który utrzymuje się i wczytuje ustawienia użytkownika. To wszystko. 43 KB to dość dużo.
Aby dowiedzieć się, skąd pochodzi rozmiar pakietu, możemy użyć eksploratora map źródłowych lub podobnego narzędzia do przedstawienia zawartości pakietu. Zgodnie z przewidywaniami nasz pakiet obejmuje logika gry, silnik renderowania, ekran wygranej, ekran utraconego ekranu i kilka narzędzi. Na stronie docelowej wystarczy tylko niewielka ich część. Przeniesienie wszystkiego, co nie jest ściśle wymagane do interaktywności, do modułu leniwego ładowania znacznie ogranicza przetwarzanie tekstu na mowę.
Musimy tylko podzielić kod. Ten podział kodu służy do podziału pakietu monolitycznego na mniejsze części, które mogą być ładowane leniwie na żądanie. Popularne usługi tworzące pakiety, takie jak Webpack, Rollup i Parcel, obsługują dzielenie kodu z wykorzystaniem dynamicznego podziału na kody import()
. Narzędzie do tworzenia pakietów przeanalizuje kod i wbudowane wszystkie moduły importowane statycznie. Wszystko, co zaimportujesz dynamicznie, zostanie umieszczone w osobnym pliku i pobrane z sieci dopiero po wykonaniu wywołania import()
. Nawiązywanie połączenia z siecią wiąże się z pewnymi kosztami i powinien korzystać z tej usługi tylko wtedy, gdy masz na to czas. Polega ona na statycznym importowaniu modułów, które są bardzo potrzebne podczas ładowania, a dynamicznym ładowaniu całej reszty. Nie czekaj jednak do ostatniej chwili, aby zacząć korzystać z leniwego ładowania modułów, których zamierzasz użyć. Film Idle Until Urgent autorstwa Phila Waltona stanowi świetny wzór na znalezienie równowagi między leniwym ładowaniem a niecierpliwością.
W usłudze PROXX utworzyliśmy plik lazy.js
, który statycznie importuje wszystko, czego nie potrzebujemy. W pliku głównym możemy następnie dynamicznie importować lazy.js
. Jednak niektóre z naszych komponentów Preact w wersji lazy.js
były dość skomplikowane, ponieważ preact nie obsługuje od razu leniwie ładowanych komponentów. Z tego powodu napisaliśmy krótki kod komponentu deferred
, który umożliwia renderowanie obiektu zastępczego do momentu wczytania danego komponentu.
export default function deferred(componentPromise) {
return class Deferred extends Component {
constructor(props) {
super(props);
this.state = {
LoadedComponent: undefined
};
componentPromise.then(component => {
this.setState({ LoadedComponent: component });
});
}
render({ loaded, loading }, { LoadedComponent }) {
if (LoadedComponent) {
return loaded(LoadedComponent);
}
return loading();
}
};
}
Po wykonaniu tych czynności możemy użyć obietnicy komponentu w funkcjach render()
. Na przykład komponent <Nebula>
, który renderuje animowany obraz tła, podczas ładowania zostanie zastąpiony pustym polem <div>
. Gdy komponent zostanie załadowany i gotowy do użycia, <div>
zostanie zastąpiony rzeczywistym komponentem.
const NebulaDeferred = deferred(
import("/components/nebula").then(m => m.default)
);
return (
// ...
<NebulaDeferred
loading={() => <div />}
loaded={Nebula => <Nebula />}
/>
);
Po wykonaniu tych wszystkich czynności zmniejszyliśmy rozmiar pliku index.html
do zaledwie 20 KB, czyli mniej niż połowy oryginalnego rozmiaru. Jaki ma to wpływ na FMP i TCI? WebPageTest powie!
Różnice między FMP a TI wynosi tylko 100 ms, ponieważ chodzi tylko o analizę i wykonanie wbudowanego JavaScriptu. Po zaledwie 5, 4 s w sieci 2G aplikacja jest całkowicie interaktywna. Pozostałe mniej ważne moduły są wczytywane w tle.
Więcej sojuszów
Jeśli spojrzysz na naszą listę modułów krytycznych powyżej, zauważysz, że silnik renderowania nie jest częścią tych modułów. Oczywiście gra nie może się uruchomić, dopóki nie będziemy mieć mechanizmu renderowania, który umożliwi jej renderowanie. Mogliśmy wyłączyć przycisk „Start”, dopóki silnik renderowania nie będzie gotowy do uruchomienia gry, ale z naszego doświadczenia wynika, że konfiguracja ustawień gry jest zwykle na tyle długa, że nie jest to konieczne. W większości przypadków mechanizm renderowania i pozostałe moduły są wczytywane, gdy użytkownik naciśnie „Start”. W rzadkich przypadkach, gdy użytkownik działa szybciej niż połączenie sieciowe, wyświetlamy prosty ekran wczytywania, który oczekuje na zakończenie działania pozostałych modułów.
Podsumowanie
Pomiary są ważne. Aby nie tracić czasu na problemy, które nie są realne, zalecamy, aby zawsze przeprowadzać pomiary przed wdrożeniem optymalizacji. Pomiarów należy także wykonywać na prawdziwych urządzeniach z połączeniem 3G lub za pomocą narzędzia WebPageTest, jeśli nie masz pod ręką prawdziwego urządzenia.
Pasek zdjęć może dać Ci wyobrażenie o tym, jakie wrażenia użytkownik odczuwa wczytanie aplikacji. Kaskada informuje, które zasoby odpowiadają za potencjalnie długie czasy wczytywania. Oto lista kontrolna rzeczy, które możesz zrobić, aby poprawić wydajność wczytywania:
- Dostarcz jak najwięcej zasobów w ramach jednego połączenia.
- Wstępne wczytywanie, a nawet zasobów wbudowanych, które są wymagane podczas pierwszego renderowania i interaktywności.
- Renderuj wstępnie aplikację, aby poprawić postrzeganą szybkość wczytywania.
- Użyj agresywnego dzielenia kodu, aby zmniejszyć ilość kodu wymaganego do interakcji.
Wkrótce udostępnimy część 2, w której omawiamy optymalizację wydajności działania w przypadku urządzeń z bardzo ograniczonymi ograniczeniami.