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

Jak w PROXX wykorzystaliśmy podział kodu, wstawianie kodu i renderowanie po stronie serwera.

Podczas konferencji Google I/O 2019 Mariko, Jake i ja wysłaliśmy PROXX – nowoczesny klon sapera do internetu. Tym, co wyróżnia aplikację PROXX, są ułatwienia dostępu (można w nie grać za pomocą czytnika ekranu) i możliwość działania zarówno na telefonach z podstawową przeglądarką, jak i komputerach zaawansowanych. Telefony z podstawową przeglądarką są objęte wieloma ograniczeniami:

  • Słabe procesory
  • Słabe lub nieistniejące GPU
  • Małe ekrany bez dotykowego wprowadzania danych
  • Bardzo ograniczona ilość pamięci

Obsługują one jednak nowoczesną przeglądarkę i są bardzo przystępne cenowo. Z tego powodu telefony z podstawową przeglądarką wracają na rynki wschodzące. Dzięki jej pułapowi cenowemu zupełnie nowemu odbiorcy, którego wcześniej nie było stać na zakup, mogą zajrzeć do internetu i korzystać z nowoczesnego internetu. Przewiduje się, że w samych Indiach w 2019 roku zostanie sprzedanych około 400 milionów telefonów z podstawową przeglądarką, więc użytkownicy takich telefonów mogą stanowić znaczną część Twoich odbiorców. Poza tym prędkość połączeń zbliżonych do 2G to norma na rynkach wschodzących. Jak udało się nam zapewnić prawidłowe działanie PROXX w warunkach telefonów z podstawową przeglądarką?

Gra PROXX.

Wydajność jest ważna i obejmuje zarówno wydajność wczytywania, jak i wydajność środowiska wykonawczego. Wykazano, że dobra skuteczność przekłada się na wyższy wskaźnik utrzymania użytkowników, większą liczbę konwersji i – co najważniejsze – większą integrację społeczną. Jeremy Wagner ma o wiele więcej danych i statystyk na temat tego, dlaczego skuteczność jest ważna.

To jest pierwsza część dwuczęściowej serii. Część 1 dotyczy wydajności wczytywania, a 2. – środowiska wykonawczego.

Wykorzystanie stanu quo

Testowanie wydajności wczytywania na prawdziwym urządzeniu jest bardzo ważne. Jeśli nie masz pod ręką fizycznego urządzenia, polecam narzędzie WebPageTest, a zwłaszcza narzędzie „proste” konfiguracji. WPT przeprowadza testy wczytywania na prawdziwym urządzeniu z emulowanym połączeniem 3G.

Połączenie 3G to dobry wynik. Chociaż może się wydawać, że do 4G, LTE, a wkrótce nawet 5G, rzeczywistość w internecie mobilnym wygląda zupełnie inaczej. Załóżmy, że jesteś w pociągu, na konferencji, na koncercie lub w samolocie. To, czego będziesz się tam spodziewać, najprawdopodobniej będzie bliżej sieci 3G, a czasem nawet jeszcze gorzej.

W tym artykule skupimy się na sieciach 2G, ponieważ firma PROXX kieruje swoją ofertę na telefony z internetem i rynki wschodzące na swoich docelowych odbiorcach. Po przeprowadzeniu testu WebPageTest otrzymasz kaskadę (podobną do tego w Narzędziach deweloperskich) oraz pasek zdjęć u góry. Pasek miniatur pokazuje to, co widzi użytkownik podczas ładowania aplikacji. W sieci 2G wczytywanie niezoptymalizowanej wersji PROXX jest dość słabe:

Pasek zdjęć pokazuje, co widzi użytkownik, gdy PROXX ładuje się na prawdziwym urządzeniu niskiej klasy przez emulację połączenia 2G.

Po połączeniu się z siecią 3G użytkownik widzi 4 sekundy białego ekranu. W przypadku sieci 2G użytkownik nie widzi absolutnie nic przez ponad 8 sekund. Jeśli zapoznasz się z artykułem Dlaczego wydajność ma znaczenie, wiesz, że traciliśmy teraz sporą część potencjalnych użytkowników z powodu niecierpliwości. Aby zobaczyć coś na ekranie, użytkownik musi pobrać cały 62 KB JavaScript. Najważniejszym rozwiązaniem w tym scenariuszu jest to, że druga rzecz, która pojawia się na ekranie, jest także interaktywna. A może jednak?

[Pierwsze znaczące wyrenderowanie][FMP] w niezoptymalizowanej wersji PROXX jest _technicznie_ [interaktywna][TTI], ale bezużyteczne dla użytkownika.

Po pobraniu 62 KB pliku gzip'd JS i wygenerowaniu modelu DOM użytkownik może zobaczyć naszą aplikację. Aplikacja jest technicznie interaktywna. Patrząc jednak na grafikę, pokazujemy inną rzeczywistość. Czcionki internetowe są nadal wczytywane w tle i dopóki nie będą gotowe, użytkownik nie będzie mógł zobaczyć tekstu. Choć ten stan kwalifikuje się jako pierwsze wyrenderowanie elementu znaczącego (FMP), z pewnością nie kwalifikuje się on jako prawidłowo interaktywny, ponieważ użytkownik nie może określić, czego dotyczą dane wejściowe. Zanim aplikacja będzie gotowa do użytku, potrzeba kolejnej sekundy w przypadku sieci 3G i 3 sekund w przypadku sieci 2G. Działanie aplikacji w przypadku sieci 3G i 2G zajmuje 6 sekund, a w przypadku sieci 2G – 11 sekund.

Analiza kaskadowa

Skoro wiemy już, co widzi użytkownik, musimy ustalić, dlaczego. W tym celu możemy przyjrzeć się kaskadzie i przeanalizować, dlaczego zasoby wczytują się zbyt późno. W danych śledzenia 2G dla PROXX widzimy 2 główne sygnały ostrzegawcze:

  1. Widać kilka wielokolorowych cienkich linii.
  2. Pliki JavaScript tworzą łańcuch. Na przykład drugi zasób rozpoczyna się dopiero po zakończeniu przetwarzania pierwszego zasobu, a trzeci dopiero po zakończeniu przetwarzania.
.
. Kaskada zapewnia wgląd w to, które zasoby ładują się w określonym czasie i jak długo.

Zmniejszam liczbę połączeń

Każda cienka linia (dns, connect, ssl) oznacza utworzenie nowego połączenia HTTP. Skonfigurowanie nowego połączenia jest kosztowne, bo w przypadku sieci 3G zajmuje to około 1 s, a w przypadku 2G około 2,5 s. W kaskadzie widzimy nowe połączenie –

  • Prośba nr 1. index.html
  • Prośba nr 5. Style czcionek z: fonts.googleapis.com
  • Prośba nr 8: Google Analytics
  • Prośba nr 9: plik czcionek od: fonts.gstatic.com
  • Żądanie 14. Manifest aplikacji internetowej

Nie da się uniknąć nowego połączenia z usługą index.html. Przeglądarka musi nawiązać połączenie z naszym serwerem, aby pobrać treści. Nowego połączenia z Google Analytics można uniknąć, wstawiając kod w postaci elementu Minimal Analytics, ale Google Analytics nie blokuje renderowania aplikacji ani nie staje się jej interaktywna, więc nie ma znaczenia, jak szybko się ona wczytuje. W idealnym przypadku dane Google Analytics powinny być ładowane w czasie bezczynności, gdy zostały już wczytane wszystkie inne dane. Dzięki temu nie będzie zwiększać przepustowości ani mocy obliczeniowej podczas początkowego wczytywania. Nowe połączenie z plikiem manifestu aplikacji internetowej jest określone przez specyfikację pobierania, ponieważ plik manifestu musi być wczytywany przez połączenie bez uwierzytelniania. Również w tym przypadku plik manifestu aplikacji internetowej nie blokuje renderowania aplikacji ani jej działania interaktywnego, więc nie musimy się tym aż przejmować.

Dwie czcionki i ich style stanowią jednak problem, bo blokują renderowanie i interaktywność. W kodzie CSS dostarczanym przez fonts.googleapis.com widać tylko 2 reguły @font-face, po jednej na każdą czcionkę. Style czcionek są w rzeczywistości tak małe, że postanowiliśmy włączyć je w kodzie HTML i usunąć jedno zbędne połączenie. Aby uniknąć kosztów konfiguracji połączenia dla plików czcionek, możemy skopiować je na nasz własny serwer.

Równoległe wczytywanie

W kaskadzie widać, ż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 prawdopodobnie zawiera importy statyczne, więc JavaScript nie może zostać uruchomiony, dopóki te operacje nie zostaną załadowane. Ważne jest, aby uświadomić sobie, że tego rodzaju zależności są znane już w chwili kompilacji. Możemy zastosować tagi <link rel="preload">, aby mieć pewność, że wszystkie zależności zaczną się wczytywać w momencie otrzymania kodu HTML.

Wyniki

Przyjrzyjmy się, co udało nam się osiągnąć. Nie należy zmieniać w konfiguracji testu żadnych innych zmiennych, które mogłyby zniekształcić wyniki. Z tego względu w dalszej części tego artykułu użyjemy prostej konfiguracji WebPageTest. Zobaczmy też serię zdjęć:

Używamy paska zdjęć WebPageTest, aby sprawdzić, co udało się osiągnąć.

Dzięki tym zmianom czas konfiguracji połączenia skrócił się z 11 do 8,5, czyli mniej więcej 2,5 sekundy czasu konfiguracji połączenia, który chcieliśmy usunąć. Brawo!

Renderowanie wstępne

Choć właśnie zmniejszyliśmy TTI, tak naprawdę nie ma to wpływu na wiecznie biały ekran, z jakim użytkownik musi wytrzymać 8,5 sekundy. Prawdopodobnie największe ulepszenia w FMP można wprowadzić przez wysłanie znaczników ze stylem w index.html. Powszechnymi metodami osiągnięcia tego celu są renderowanie wstępne i renderowanie po stronie serwera. Te techniki są ściśle ze sobą powiązane i zostały opisane w artykule Renderowanie w internecie. Obie techniki uruchamiają aplikację internetową w Node.js i serializują powstały DOM do HTML. Renderowanie po stronie serwera robi to na żądanie po stronie serwera, a renderowanie wstępne robi to na etapie kompilacji i zapisuje dane wyjściowe jako nowe index.html. PROXX to aplikacja JAMStack, która nie działa po stronie serwera, dlatego zdecydowaliśmy się wdrożyć renderowanie wstępne.

Mechanizm wstępnego renderowania można wdrożyć na wiele sposobów. W PROXX wybraliśmy Puppeteer, który uruchamia Chrome bez żadnego interfejsu i umożliwia zdalne sterowanie instancją za pomocą Node API. Używamy tego do wstrzykiwania naszych znaczników i kodu JavaScript, a następnie odczytuje DOM jako ciąg HTML. Korzystamy z modułów CSS, dzięki czemu możemy bezpłatnie wstawiać potrzebne 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);

Dzięki temu możemy oczekiwać lepszej jakości w przypadku naszej platformy FMP. Nadal musimy wczytywać i wykonywać taką samą ilość kodu JavaScript jak wcześniej, więc nie spodziewaj się, że TTI zmieni się znacząco. W razie potrzeby index.html się powiększył i może nieco odsunąć się w kwestii TTI. Jest tylko jeden sposób, aby się przekonać: uruchomienie WebPageTest.

Pasek zdjęć wyraźnie pokazuje poprawę wskaźnika FMP. Zmiana nie wpływa głównie na TTI.

Czas na pierwsze wyrenderowanie elementu znaczącego skrócił się z 8,5 sekundy do 4,9 sekundy,to ogromny wzrost. Funkcja TTI nadal odbywa się po około 8,5 sekundy, więc ta zmiana nie ma na nią żadnego wpływu. W tym przypadku nastąpiła percepcyjna zmiana. Niektórzy mogą nawet nazywać to sztuczkiem. Renderując pośredni obraz gry, zmieniamy postrzeganą szybkość wczytywania na lepsze.

Wbudowana

Kolejnym wskaźnikiem używanym przez Narzędzia deweloperskie i WebPageTest jest czas do pierwszego bajtu (TTFB). Jest to czas, który upływa od momentu wysłania pierwszego bajtu żądania do pierwszego bajtu otrzymanej odpowiedzi. Czas ten jest również często nazywany czasem czasu przetwarzania w obie strony (RTT), chociaż technicznie istnieje między nimi różnica: RTT nie uwzględnia czasu przetwarzania żądania po stronie serwera. DevTools i WebPageTest wizualizują TTFB w kolorze jasnym w obrębie bloku żądań/odpowiedzi.

Sekcja świetlna żądania oznacza, że żądanie oczekuje na otrzymanie pierwszego bajtu odpowiedzi.

W naszej kaskadzie widać, że na wszystkie żądania poświęcają większość czasu oczekiwania na dostarczenie pierwszego bajtu odpowiedzi.

Ten problem został pierwotnie wymyślony dla metody HTTP/2 Push. Deweloper aplikacji wie, że potrzebne są określone zasoby, i może je przekierować. Gdy klient zda sobie sprawę, że musi pobrać dodatkowe zasoby, są one już w pamięci podręcznej przeglądarki. Okazało się, że metoda push HTTP/2 jest zbyt trudna do prawidłowego działania i dlatego nie zalecamy jej używania. Wrócimy do tego tematu podczas standaryzacji protokołu HTTP/3. Na razie najprostszym rozwiązaniem jest wbudowanie wszystkich kluczowych zasobów kosztem wydajności buforowania.

Nasze kluczowe elementy CSS są już wbudowane dzięki modułom CSS i mechanizmowi wstępnego renderowania opartego na platformie Puppeteer. W języku JavaScript musimy wbudować w elementy kluczowe moduły i ich zależności. To zadanie o różnym stopniu trudności w zależności od używanego pakietu SDK.

.
Wbudowany skrypt JavaScript ograniczyliśmy czas zamiany tekstu na TTI z 8,5 s do 7,2 s.

To skróciło czas o 1 sekundę w TTI. Dotarliśmy do punktu, w którym element index.html zawiera wszystko, co jest potrzebne do początkowego renderowania i zwiększenia interakcji. Kod HTML może zostać wyrenderowany w trakcie pobierania, co spowoduje utworzenie naszej platformy FMP. W momencie zakończenia analizy i uruchomienia kodu HTML aplikacja staje się interaktywna.

Agresywne dzielenie kodu

Tak. index.html zawiera wszystko, co jest potrzebne do interakcji. Jednak po dokładnym zbadaniu sprawy okazało się, że zawiera on też wszystkie inne elementy. Plik index.html ma około 43 KB. Spójrzmy na to w odniesieniu do tego, z czym użytkownik może wchodzić w interakcję na początku: mamy formularz do konfigurowania gry, który składa się z kilku komponentów, przycisku Start oraz prawdopodobnie części kodu, aby utrwalać i wczytywać ustawienia użytkownika. To już prawie wszystko. 43 KB to dużo.

Strona docelowa PROXX. Wykorzystano tu tylko kluczowe komponenty.

Aby dowiedzieć się, skąd pochodzi rozmiar pakietu, możemy użyć eksploratora mapy źródłowej lub podobnego narzędzia, które pozwala sprawdzić, co składa się w pakiecie. Zgodnie z przewidywaniami nasz pakiet zawiera logikę gry, mechanizm renderowania, ekran wygranej, ekran utraty i kilka narzędzi. Do utworzenia strony docelowej potrzebny jest tylko niewielki podzbiór tych modułów. Przeniesienie do leniwie ładowanego modułu wszystkie elementy, które nie są niezbędne do interaktywności, znacznie ogranicza mechanizm TTI.

Analiza zawartości pliku „index.html” protokołu PROXX pokazuje wiele niepotrzebnych zasobów. Krytyczne zasoby są wyróżnione.

Musimy tylko podzielić kod. Podział kodu powoduje rozłożenie pakietu monolitycznego na mniejsze części, które można leniwie ładować na żądanie. Popularne usługi tworzące pakiet, takie jak Webpack, Rollup i Parcel, obsługują podział kodu za pomocą dynamicznego import(). Kreator pakietów przeanalizuje kod i w tekście wszystkie zaimportowane statycznie moduły. Wszystko, co zaimportujesz dynamicznie, zostanie umieszczone w osobnym pliku i zostanie pobrane z sieci dopiero po wykonaniu wywołania import(). Oczywiście połączenie z siecią wiąże się z pewnymi kosztami i należy to robić tylko wtedy, gdy masz na to czas. Najlepiej jest statycznie importować moduły, które są krytycznie potrzebne podczas wczytywania, a pozostałe ładować dynamicznie. Nie należy jednak czekać do ostatniej chwili z leniwym ładowaniem modułów, które na pewno będą potrzebne. Phil Walton stworzył film Idle Until Urgent, który stanowi doskonały środek stanowiący środek między leniwym ładowaniem a pełnym zaangażowaniem.

W PROXX utworzyliśmy plik lazy.js, który statycznie importuje wszystko, czego nie jest potrzebne. Z głównego pliku możemy wtedy dynamicznie zaimportować plik lazy.js. Jednak niektóre z komponentów Preact trafiły do lazy.js, co okazało się niewystarczająco skomplikowanym rozwiązaniem, ponieważ Preact nie obsługuje komponentów wczytywanych z opcją leniwego ładowania. Z tego względu napisaliśmy małe opakowanie komponentu deferred, które pozwala wyświetlać obiekt zastępczy do momentu załadowania rzeczywistego 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();
    }
  };
}

Dzięki temu możemy użyć elementu Promise komponentu w funkcjach render(). Na przykład komponent <Nebula>, który renderuje animowany obraz tła, zostanie zastąpiony podczas wczytywania pustym elementem <div>. Gdy komponent zostanie wczytany i będzie gotowy do użycia, element <div> zostanie zastąpiony rzeczywistym komponentem.

const NebulaDeferred = deferred(
  import("/components/nebula").then(m => m.default)
);

return (
  // ...
  <NebulaDeferred
    loading={() => <div />}
    loaded={Nebula => <Nebula />}
  />
);

Dzięki tym zmianom zmniejszyliśmy rozmiar pliku index.html do zaledwie 20 KB, czyli do mniej niż połowy pierwotnego rozmiaru. Jaki ma to wpływ na FMP i TTI? WebPageTest powie Ci o tym!

Klatka filmowa potwierdza, że funkcja TTI ma teraz 5,4 s. Znaczna poprawa w porównaniu z pierwotną 11s.

Między FMP a TTI dzieli nas tylko 100 ms, ponieważ jest to jedynie przeanalizowanie i wykonanie wbudowanego JavaScriptu. Po 5, 4 s w sieci 2G aplikacja jest całkowicie interaktywna. Pozostałe, mniej ważne moduły są ładowane w tle.

Więcej funkcji Sleight of Hand

Jeśli spojrzysz na powyższą listę modułów krytycznych, zauważysz, że silnik renderowania nie jest jednym z najważniejszych. Oczywiście gra nie może się uruchomić, dopóki nie zostanie zrenderowany nasz silnik renderowania. Możemy wyłączyć przycisk „Start” , aż nasz mechanizm renderowania będzie gotowy do uruchomienia gry. Z naszych doświadczeń wynika, że skonfigurowanie ustawień gry zajmuje zwykle wystarczająco dużo czasu, że nie jest to konieczne. W większości przypadków silnik renderowania i pozostałe moduły są wczytywane przed naciśnięciem przez użytkownika przycisku „Rozpocznij”. W rzadkich przypadkach, gdy użytkownik jest szybszy 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 występują w rzeczywistości, zalecamy, aby przed wdrożeniem optymalizacji zawsze zacząć prowadzić pomiary. Pomiary należy wykonywać na prawdziwych urządzeniach z połączeniem 3G lub za pomocą narzędzia WebPageTest, jeśli nie masz pod ręką żadnego prawdziwego urządzenia.

Pasek zdjęć może dostarczyć informacji o sposobie wczytywania aplikacji u użytkownika. Kaskada może wskazać zasoby, które są odpowiedzialne za potencjalnie długi czas wczytywania. Oto lista kontrolna, dzięki której możesz poprawić szybkość wczytywania:

  • Przesyłanie jak największej liczby zasobów w ramach jednego połączenia.
  • Wstępne wczytywanie, a nawet wbudowane zasoby, które są wymagane do pierwszego renderowania i interakcji.
  • Wstępnie wyrenderuj aplikację, aby poprawić postrzeganą wydajność wczytywania.
  • Stosuj agresywne podział kodu, by zmniejszyć ilość kodu potrzebną do interakcji.

Wkrótce udostępnimy część 2, w której omawiamy optymalizację czasu działania aplikacji na urządzeniach z bardzo mocnymi ograniczeniami.