Techniki przyspieszające ładowanie aplikacji internetowych, nawet na telefonach z podstawową przeglądarką.

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?

Rozgrywka PROXX.

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:

Film z paska zdjęć pokazuje, co widzi użytkownik, gdy PROXX wczytuje się na prawdziwym, tańszym urządzeniu przez emulowane połączenie 2G.

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?

[Pierwsze wyrenderowanie znaczącej wartości][FMP] w niezoptymalizowanej wersji PROXX jest _technicznie_ [interactive][TTI], ale bezużyteczne dla użytkownika.

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:

  1. Jest wiele cienkich wielokolorowych linii.
  2. 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.
Kaskada informuje o tym, które zasoby wczytują się, kiedy i jak długo to trwa.

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:

Korzystamy z paska zdjęć z aplikacji WebPageTest, aby sprawdzić, co udało nam się osiągnąć.

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.

Ten pasek pokazuje wyraźnie poprawę wskaźnika FMP. Zwykle nie dotyczy to TTI.

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.

Sekcja w jasnym kolorze żądania oznacza, że żądanie oczekuje na otrzymanie pierwszego bajtu 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.

Dzięki wbudowanemu skryptowi JavaScript zmniejszyliśmy czas TTI z 8,5 s do 7,2 s.

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.

Strona docelowa PROXX. Użyto tylko kluczowych komponentów.

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ę.

Analiza zawartości pliku „index.html” narzędzia PROXX wykazuje wiele zbędnych zasobów. Kluczowe zasoby są wyróżnione.

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!

Na pasku zdjęć potwierdza się: nasza technologia TTI liczy teraz 5,4 s. Znaczący wzrost w porównaniu z pierwotną wersją 11.

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.