Jak w PROXX użyliśmy podziału kodu, wstawiania kodu i renderowania po stronie serwera.
Na konferencji Google I/O 2019 Mariko, Jake i ja wypuściliśmy PROXX, nowoczesną wersję gry Minesweeper na potrzeby 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ą ograniczone na kilka sposobów:
- Słabe procesory
- Słabe lub nieistniejące GPU
- małe ekrany bez możliwości sterowania dotykiem;
- 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 rozwijające się. 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. W 2019 r. tylko w Indiach 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?
Ważna jest wydajność, która obejmuje zarówno ładowanie, jak i działanie w czasie działania. Wykazano, że dobra skuteczność wiąże się z większym utrzymaniem użytkowników, większą liczbą konwersji i – co najważniejsze – z większą dostępnością treści. Jeremy Wagner podaje więcej danych i opinii na temat wydajności.
To jest pierwsza część dwuczęściowej serii. Część 1 dotyczy szybkości wczytywania, a część 2 – szybkości działania.
Stan obecny
Testowanie szybkości wczytywania na rzeczywistym urządzeniu jest kluczowe. 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. Możesz być 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. Plusem w tym scenariuszu jest to, że drugi element, który pojawia się na ekranie, jest też 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. Jednak obraz pokazuje 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 danych wejściach. Kolejna sekunda na 3G i 3 sekundy na 2G, aż aplikacja będzie gotowa do działania. W ogóle na 3G aplikacja potrzebuje 6 sekund, a na 2G – 11 sekund, aby stać się interaktywną.
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:
- Na zdjęciu widać wiele 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 5. Styl czcionki z
fonts.googleapis.com
- Prośba 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 idealnej sytuacji Google Analytics powinien być ładowany w czasie bezczynności, gdy wszystko inne jest już załadowane. Dzięki temu nie zajmie ono przepustowości ani mocy obliczeniowej podczas wczytywania początkowego. Nowe połączenie dla pliku manifestu aplikacji internetowej jest określane przez specyfikację pobierania, ponieważ manifest musi być ładowany 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 wstawić go bezpośrednio w pliku HTML, usuwając zbędne połączenie. Aby uniknąć kosztów konfiguracji połączenia w przypadku plików czcionek, możemy je skopiować na nasz własny serwer.
równoległe wczytywanie danych,
Z wykresu kaskadowego wynika, że po zakończeniu wczytywania pierwszego pliku JavaScript nowe pliki zaczynają się wczytywać natychmiast. Jest to typowe dla 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 zrozumieć, że tego typu zależności są znane w momencie 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 pozwoliły nam skrócić czas TTI z 11 do 8,5 s, co oznacza, że udało nam się usunąć około 2,5 s czasu konfiguracji połączenia. Brawo my.
Renderowanie wstępne
Zredukowaliśmy TTI, ale nie zmieniliśmy zbytnio niekończącego się białego ekranu, który użytkownik musi oglądać przez 8,5 sekundy. Największe poprawki w FMP można uzyskać, wysyłając znaczniki stylów w pliku index.html
. Najczęściej stosuje się do tego celu wstępną wizualizację i wizualizację po stronie serwera, które są ze sobą ściśle powiązane. Omawiamy je w artykule Wizualizacja w internecie. Obie techniki uruchamiają aplikację internetową w Node i serializują wynikowy DOM do formatu 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 bezpłatnie otrzymujemy wbudowane style CSS, których potrzebujemy.
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ę tego dowiedzieć: uruchom 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. Wprowadziliśmy tu zmianę koncepcyjną. Niektórzy mogą nawet nazwać to sztuczką. 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 jasnym kolorze w bloku żądania/odpowiedzi.
Na wykresie kaskadowym widać, że wszystkie żądania spędzają większą część czasu na oczekiwanie na przybycie pierwszego bajta 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 komunikacji. 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. Obecnie najprostszym rozwiązaniem jest wstawianie wszystkich kluczowych zasobów kosztem wydajności pamięci podręcznej.
Nasz krytyczny kod CSS jest już wbudowany dzięki modułom CSS i naszemu preprocesorowi opartym na Puppeteer. W przypadku JavaScript musimy wbudować w kodzie istotne 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 momentu, w którym nasz index.html
zawiera wszystko, czego potrzeba do wstępnego renderowania i uczynienia go interaktywnym. 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 konfigurowania gry, który zawiera kilka komponentów, przycisk Start i prawdopodobnie kod do zapisywania i wczytywania ustawień użytkownika. To w zasadzie wszystko. 43 KB to dużo.
Aby dowiedzieć się, skąd pochodzi rozmiar pakietu, możemy użyć eksploratora mapy źródeł lub podobnego narzędzia, aby sprawdzić, z czego składa się pakiet. 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. Zasada jest taka, aby importować statycznie moduły, które są kluczowe w momencie wczytywania, a pozostałe elementy wczytywać 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 dynamicznie importować dane z pliku 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()
. Podczas wczytywania komponentu <Nebula>
, który renderuje animowany obraz tła, zostanie on zastąpiony pustym komponentem <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 wpływ ma to na FMP i TTI? WebPageTest powie Ci, co zrobić.
Czasy 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. Wszystkie pozostałe, mniej istotne moduły są ładowane w tle.
Więcej sztuczek
Jeśli spojrzysz na naszą listę modułów krytycznych, zobaczysz, że moduł renderowania nie należy do modułów krytycznych. Oczywiście gra nie może się uruchomić, dopóki nie mamy silnika do renderowania. Moglibyśmy wyłączyć przycisk „Start”, dopóki nasz silnik renderowania nie będzie gotowy do uruchomienia gry, ale według naszych doświadczeń użytkownik zwykle tak długo konfiguruje ustawienia gry, że nie jest to konieczne. W większości przypadków silnik renderowania i pozostałe moduły są już załadowane, gdy użytkownik naciśnie „Start”. W rzadkich przypadkach, gdy użytkownik jest szybszy niż jego połączenie z internetem, wyświetlamy prosty ekran wczytywania, który czeka na zakończenie pozostałych modułów.
Podsumowanie
Pomiary są ważne. Aby nie tracić czasu na rozwiązywanie problemów, które nie istnieją, zalecamy zawsze przeprowadzenie pomiarów przed wdrożeniem optymalizacji. 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 się dowiedzieć, 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łaj jak najwięcej zasobów za pomocą jednego połączenia.
- Przeładuj lub nawet wstaw zasoby 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.