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. PROXX wyróżnia się na tle innych gier tym, że kładzie nacisk na ułatwienia dostępu (można ją odtwarzać z czytnikiem ekranu) i działa równie dobrze na telefonach komórkowych jak i na komputerach. 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;
Jednak są one wyposażone w nowoczesne przeglądarki i są bardzo przystępne cenowo. Z tego powodu telefony komórkowe z klawiaturą powracają na rynki wschodzące. Ich cena pozwala nowym grupom odbiorców, którzy wcześniej nie mogli sobie na to pozwolić, korzystać z internetu i korzystać z nowoczesnych usług internetowych. 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. Ponadto na rynkach rozwijających się normą są szybkości połączeń zbliżone do 2G. Jak udało nam się sprawić, że PROXX działa dobrze w warunkach telefonów z funkcjami?
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 podaje więcej danych i opinii na temat wydajności.
To jest pierwsza część dwuczęściowej serii. Część 1 skupia się na szybkości wczytywania, a część 2 na szybkości działania.
Stan obecny
Testowanie wydajności wczytywania na prawdziwym urządzeniu jest bardzo ważne. Jeśli nie masz pod ręką prawdziwego urządzenia, zalecam skorzystanie z narzędzi WebPageTest, zwłaszcza z opcji „simple” (prosta). WPT przeprowadza serię testów wczytywania na prawdziwym urządzeniu z emulowanym połączeniem 3G.
Szybkość 3G jest odpowiednia do pomiaru. Możesz być przyzwyczajony do korzystania z 4G, LTE lub wkrótce nawet 5G, ale rzeczywistość internetu mobilnego wygląda zupełnie inaczej. Załóżmy, że jesteś w pociągu, na konferencji, na koncercie lub w samolocie. W takich przypadkach prędkość będzie prawdopodobnie zbliżona do 3G, a czasem nawet gorsza.
W tym artykule skupimy się na sieci 2G, ponieważ aplikacja PROXX jest kierowana na telefony z podstawową przeglądarką i odbiorców z rynków wschodzących. Po przeprowadzeniu testu przez WebPageTest zobaczysz kaskadę (podobną do tej, którą widzisz w DevTools), a także pasek filmowy u góry. Pasek filmu pokazuje, co widzi użytkownik podczas wczytywania aplikacji. W sieci 2G wczytywanie nieoptymalizowanej wersji PROXX jest dość uciążliwe:
Podczas wczytywania przez sieć 3G użytkownik widzi przez 4 sekundy białą pustkę. W sieci 2G użytkownik nie widzi nic przez ponad 8 sekund. Jeśli przeczytasz artykuł Dlaczego wydajność jest ważna, dowiesz się, że straciliśmy dużą część potencjalnych użytkowników z powodu ich niecierpliwości. Aby coś się wyświetliło na ekranie, użytkownik musi pobrać wszystkie 62 KB kodu JavaScript. Najważniejszym rozwiązaniem w tym scenariuszu jest to, że drugi element, który pojawia się na ekranie, również jest interaktywny. A może jednak?
Po pobraniu skompresowanego pliku JS o rozmaju około 62 KB i wygenerowaniu DOM użytkownik widzi naszą aplikację. Aplikacja jest technicznie interaktywna. Patrząc jednak na grafikę, pokazujemy inną rzeczywistość. Czcionki internetowe są nadal wczytywane w tle, a dopóki nie będą gotowe, użytkownik nie zobaczy żadnego tekstu. Chociaż ten stan kwalifikuje się jako pierwsze znaczące renderowanie (FMP), z pewnością nie kwalifikuje się jako prawidłowo interaktywne, ponieważ użytkownik nie może określić, o co chodzi w żadnym z danych wejść. Zanim aplikacja będzie gotowa do użycia, potrzebna jest kolejna sekunda w przypadku sieci 3G i 3 sekundy w przypadku sieci 2G. Ogólnie rzecz biorąc, interakcja aplikacji z aplikacją trwa 6 sekund, a w przypadku sieci 2G – 11 sekund.
Analiza kaskadowa
Teraz, gdy wiemy, co widzi użytkownik, musimy się dowiedzieć, dlaczego. W tym celu możemy sprawdzić wykres kaskadowy i zbadać, dlaczego zasoby są wczytywane zbyt późno. W śladzie 2G dla PROXX widać 2 główne sygnały ostrzegawcze:
- Widać kilka wielokolorowych cienkich linii.
- Pliki JavaScript tworzą łańcuch. Na przykład drugi zasób zaczyna się wczytywać dopiero po zakończeniu wczytywania pierwszego zasobu, a trzeci dopiero po zakończeniu wczytywania drugiego.
Zmniejszenie liczby połączeń
Każda cienka linia (dns
, connect
, ssl
) oznacza utworzenie nowego połączenia HTTP. Konfigurowanie nowego połączenia jest kosztowne, ponieważ zajmuje około 1 s w sieci 3G i około 2,5 s w sieci 2G. W schemacie widzimy nowe połączenie:
- Prośba 1. Nasze
index.html
- Prośba nr 5. Style czcionek z:
fonts.googleapis.com
- Prośba nr 8: Google Analytics
- Prośba 9: plik czcionki z
fonts.gstatic.com
- Prośba 14: manifest aplikacji internetowej
Nowe połączenie z index.html
jest nieuniknione. Aby pobrać treści, przeglądarka musi nawiązać połączenie z naszym serwerem. Nowe połączenie z Google Analytics można pominąć, wstawiając w kod coś takiego jak Minimal Analytics, ale Google Analytics nie blokuje renderowania ani interakcji aplikacji, więc nie zależy nam na tym, jak szybko się 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 zajmie to pasma ani mocy obliczeniowej podczas wczytywania początkowego. 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. Plik manifestu aplikacji internetowej nie blokuje renderowania ani interakcji aplikacji, więc nie musimy się tym przejmować.
Oba czcionki i ich style stanowią jednak problem, ponieważ blokują renderowanie i interakcje. Jeśli przyjrzymy się kodom CSS dostarczanym przez fonts.googleapis.com
, zobaczymy tylko 2 reguły @font-face
, po jednej dla każdej czcionki. Styl czcionki jest tak mały, że postanowiliśmy umieścić go w HTML, usuwając zbędne połączenie. Aby uniknąć kosztów konfiguracji połączenia dla plików czcionek, możemy je skopiować na nasz własny serwer.
Równoległe wczytywanie
Z wykresu kaskadowego wynika, że po zakończeniu wczytywania pierwszego pliku JavaScript nowe pliki zaczynają się wczytywać natychmiast. Jest to typowe w przypadku zależności modułów. Główny moduł prawdopodobnie zawiera importy statyczne, więc kod JavaScript nie może się wykonać, dopóki te importy 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 użyć tagów <link rel="preload">
, aby mieć pewność, że wszystkie zależności zaczną się wczytywać w momencie, gdy otrzymamy kod HTML.
Wyniki
Zobaczmy, jakie przyniosły one efekty. W konfiguracji testu nie należy zmieniać żadnych innych zmiennych, które mogłyby zafałszować wyniki. W dalszej części tego artykułu będziemy używać prostej konfiguracji WebPageTest i analizować pasek filmowy:
Te zmiany skróciły czas konfiguracji połączenia z 11 do 8,5, czyli mniej więcej 2,5 sekundy czasu konfiguracji połączenia, który chcieliśmy usunąć. Brawo my.
Renderowanie wstępne
Zmniejszyliśmy czas TTI, ale nie zmieniliśmy zbytnio niekończącego się białego ekranu, który użytkownik musi oglądać przez 8,5 sekund. Prawdopodobnie największe ulepszenia w FMP można wprowadzić przez wysłanie znaczników ze stylem w index.html
. Najczęściej stosuje się do tego prerenderowanie i renderowanie po stronie serwera, które są ze sobą ściśle powiązane. Omawiamy je w artykule Renderowanie w internecie. Obie techniki uruchamiają aplikację internetową w Node.js i serializują powstały DOM do HTML. Renderowanie po stronie serwera wykonuje to na podstawie żądania po stronie serwera, a renderowanie wstępne wykonuje to w momencie kompilacji i przechowuje wynik jako nowy index.html
. Aplikacja PROXX jest aplikacją JAMStack i nie ma strony serwerowej, dlatego postanowiliśmy wdrożyć prerenderowanie.
Prerenderera można zaimplementować na wiele sposobów. W PROXX postanowiliśmy użyć Puppeteer, który uruchamia Chrome bez interfejsu użytkownika i umożliwia zdalne sterowanie instancją za pomocą interfejsu Node API. Dzięki temu możemy wstrzyknąć znaczniki i kod JavaScript, a potem odczytać element DOM jako ciąg znaków HTML. Korzystamy z modułów CSS, więc wstawianie wbudowanych stylów CSS jest dla nas bezpłatne.
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 spodziewać się poprawy wyników FMP. Nadal musimy wczytywać i wykonywać tę samą ilość kodu JavaScriptu co wcześniej, więc nie spodziewamy się, aby TTI uległa znacznej zmianie. Nasz index.html
stał się większy i może nieco opóźnić TTI. Jest tylko jeden sposób, aby się przekonać: uruchomienie WebPageTest.
Czas First Meaningful Paint skrócił się z 8,5 s do 4,9 s, co jest ogromną poprawą. Czas TTI wynosi nadal około 8,5 sekund, więc ta zmiana nie miała na niego większego wpływu. W tym przypadku nastąpiła percepcyjna zmiana. Niektórzy mogą nawet nazywać to sztuczkiem. Dzięki renderowaniu pośredniego obrazu gry poprawiamy postrzeganą wydajność wczytywania.
Wstawianie
Innym wskaźnikiem, który otrzymujemy zarówno z DevTools, jak i z WebPageTest, jest czas do pierwszego bajtu (TTFB). Jest to czas od wysłania pierwszego bajtu żądania do otrzymania pierwszego bajtu odpowiedzi. Czas ten jest też często nazywany czasem błądzenia (RTT), choć technicznie jest między tymi dwoma wartościami 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.
W naszej kaskadzie widać, że na wszystkie żądania poświęcają większość czasu oczekiwania na dostarczenie pierwszego bajtu odpowiedzi.
To właśnie było pierwotnym celem protokołu HTTP/2 Push. Deweloperzy aplikacji wiedzą, że są potrzebne określone zasoby, i mogą przekazać je w ramach procesu. Zanim klient zorientuje się, że musi pobrać dodatkowe zasoby, są one już w pamięci podręcznej przeglądarki. Protokół HTTP/2 Push okazał się zbyt trudny do prawidłowego wdrożenia i nie jest zalecany. Ten problem zostanie ponownie rozpatrzony podczas standaryzacji HTTP/3. Na razie najprostszym rozwiązaniem jest wbudowanie wszystkich kluczowych zasobów kosztem wydajności buforowania.
Nasz krytyczny kod CSS jest już wbudowany dzięki modułom CSS i naszemu prerenderowi opartym na Puppeteer. W przypadku JavaScriptu musimy wbudować w kodzie krytyczne moduły i ich zależności. Poziom trudności tego zadania zależy od używanego narzędzia do tworzenia pakietów.
Dzięki temu udało się nam skrócić czas TTI o 1 sekundę. 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, gdy jest jeszcze pobierany, tworząc FMP. Gdy zakończy się analizowanie i wykonywanie kodu HTML, aplikacja staje się interaktywna.
Agresywne dzielenie kodu
Tak, nasza usługa index.html
zawiera wszystko, co jest potrzebne do stworzenia interaktywnej strony. Ale przy bliższym przyjrzeniu się okazuje się, że zawiera też wszystko inne. Nasz index.html
ma około 43 KB. Porównajmy to z tym, z czym użytkownik może wchodzić w interakcje na początku: mamy formularz do konfiguracji gry, który zawiera kilka komponentów, przycisk Start i prawdopodobnie kod do zapisywania i wczytywania ustawień użytkownika. To już wszystko. 43 KB to dużo.
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. Jak można się było spodziewać, nasz pakiet zawiera logikę gry, silnik renderowania, ekran zwycięstwa, ekran przegranej i kilka narzędzi. Na stronie docelowej potrzebna jest tylko niewielka część tych modułów. Przeniesienie do modułu ładowanego z opóźnieniem wszystkich elementów, które nie są niezbędne do zapewnienia interakcji, znacznie skróci czas TTI.
Musimy podzielić kod. Podział kodu dzieli monolityczny pakiet na mniejsze części, które można wczytywać na żądanie. Popularne narzędzia do tworzenia pakietów, takie jak Webpack, Rollup i Parcel, obsługują dzielenie kodu za pomocą dynamicznego import()
. Pakowacz przeanalizuje Twój kod i wstawi wszystkie moduły zaimportowane statycznie. Wszystkie dane importowane dynamicznie będą umieszczane w osobnym pliku i pobierane z sieci dopiero po wykonaniu wywołania import()
. Oczywiście korzystanie z sieci wiąże się z kosztami i powinno być stosowane 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 łatwym wczytywaniem modułów, które na pewno będą używane. Tryb nieaktywny do momentu pilności Phila Waltona to świetny sposób na znalezienie zdrowej równowagi między wczytywaniem opóźnionym a wczytywaniem natychmiastowym.
W 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 komponenty Preact znalazły się w lazy.js
, co okazało się nieco skomplikowane, ponieważ Preact nie obsługuje domyślnie komponentów wczytywanych z opóźnieniem. Z tego powodu napisaliśmy małą otulającą funkcję deferred
, która pozwala nam renderować element zastępczy, dopóki nie zostanie załadowany właściwy komponent.
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żywać obietnicy 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 załadowany 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 temu udało nam się zmniejszyć rozmiar 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, co zrobić.
Nasz FMP i TTI różni się tylko o 100 ms, ponieważ jest to tylko kwestia zanalizowania i wykonywania wbudowanego kodu JavaScript. Po zaledwie 5,4 sekundy na sieci 2G aplikacja jest w pełni interaktywna. Pozostałe, mniej ważne moduły są ładowane w tle.
Więcej sztuczek
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 zrenderowana nasz silnik renderowania. Mogliśmy wyłączyć przycisk „Uruchom”, dopóki silnik renderowania nie będzie gotowy do uruchomienia gry, ale z naszych doświadczeń wynika, że skonfigurowanie ustawień gry zajmuje zwykle wystarczająco dużo czasu, że nie jest to konieczne. Większość czasu, gdy użytkownik naciska „Start”, silnik renderowania i pozostałe moduły są już załadowane. 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. Dodatkowo pomiary powinny być przeprowadzane na prawdziwych urządzeniach z połączeniem 3G lub na stronie WebPageTest, jeśli nie masz pod ręką prawdziwego urządzenia.
Pasek filmu może dostarczyć informacji o tym, jak wygląda wczytywanie aplikacji. Dzięki wykresowi kaskadowemu możesz sprawdzić, które zasoby są odpowiedzialne za długi czas wczytywania. Oto lista kontrolna czynności, które możesz wykonać, aby poprawić wydajność 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.
- Przeprowadź wstępny render aplikacji, aby poprawić postrzeganą wydajność wczytywania.
- Stosuj agresywne dzielenie kodu, aby zmniejszyć ilość kodu potrzebnego do interakcji.
W części 2 omówimy optymalizację wydajności w czasie działania na urządzeniach o bardzo ograniczonych zasobach.