Jak w PROXX wykorzystaliśmy podział kodu, wstawianie kodu i renderowanie 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 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. 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 się nam zapewnić prawidłowe działanie PROXX w warunkach telefonów z podstawową przeglądarką?
Wydajność jest ważna i obejmuje zarówno wydajność wczytywania, jak i wydajność środowiska wykonawczego. 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 skupia się na szybkości wczytywania, a część 2 na szybkości działania.
Stan obecny
Testowanie szybkości wczytywania na rzeczywistym urządzeniu jest kluczowe. Jeśli nie masz pod ręką fizycznego urządzenia, zalecamy skorzystać z narzędzia WebPageTest, a konkretnie do „prostej” konfiguracji. WPT przeprowadza serię testów wczytywania na prawdziwym urządzeniu z emulowanym połączeniem 3G.
Połączenie 3G to dobry wynik. 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 WebPageTest otrzymasz kaskadę (podobną do tego w Narzędziach deweloperskich) oraz pasek zdjęć 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 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 coś się wyświetliło na ekranie, użytkownik musi pobrać wszystkie 62 KB kodu JavaScript. Plusem w tym scenariuszu jest to, że każdy 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 i dopóki nie będą gotowe, użytkownik nie będzie mógł zobaczyć 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ść. 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
Skoro wiemy już, co widzi użytkownik, musimy ustalić, 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 nr 9: plik czcionek od:
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 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. 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 danych,
W kaskadzie widać, że po zakończeniu wczytywania pierwszego pliku JavaScript nowe pliki zaczynają się od razu ładować. 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 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 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!
Renderowanie wstępne
Choć właśnie zmniejszyliśmy TTI, tak naprawdę nie wpłynęło to na wiecznie biały ekran, z którego użytkownik musi korzystać 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 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 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
. PROXX to aplikacja JAMStack, która nie działa po stronie serwera, dlatego zdecydowaliśmy się wdrożyć renderowanie wstępne.
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. Wciąż musimy wczytywać i wykonywać taką samą ilość kodu JavaScript jak wcześniej, więc nie spodziewaj się, że TTI zmieni się znacząco. 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 na pierwsze wyrenderowanie elementu znaczącego skrócił się z 8,5 sekundy do 4,9 sekundy,to ogromny wzrost. 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 nazywać to sztuczkiem. Dzięki renderowaniu pośredniego obrazu gry poprawiamy postrzeganą wydajność wczytywania.
Wstawianie
Kolejnym wskaźnikiem używanym przez Narzędzia deweloperskie i 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.
Na wykresie kaskadowym widać, że wszystkie żądania spędzają większą część czasu na oczekiwaniu 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 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. Wrócimy do tego tematu podczas standaryzacji protokołu 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 prerenderowi opartym na Puppeteer. W języku JavaScript musimy wbudować w elementy kluczowe 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, 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 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 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 z komponentów Preact trafiły do lazy.js
, co okazało się niewygodnym komplikacją, ponieważ Preact nie obsługuje komponentów wczytywanych z opcją leniwego ładowania. 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 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, co zrobić.
Między FMP a TTI dzieli nas tylko 100 ms, ponieważ jest to jedynie przeanalizowanie i wykonanie wbudowanego JavaScriptu. Po zaledwie 5,4 sekundy na sieci 2G aplikacja jest w pełni interaktywna. Wszystkie inne, 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. 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
Pomiar jest ważny. Aby nie tracić czasu na rozwiązywanie problemów, które nie istnieją, zalecamy zawsze przeprowadzenie pomiarów przed wdrożeniem optymalizacji. 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 zawierać informacje 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 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.