Szybsze aplikacje wielostronicowe dzięki strumieniom

Obecnie witryny (lub aplikacje internetowe) często używają jednego z dwóch schematów nawigacji:

  • Domyślnie schemat nawigacji jest udostępniany przez przeglądarki, co oznacza, że wpisujesz adres URL w pasku adresu przeglądarki, a żądanie nawigacji zwraca w odpowiedzi dokument. Następnie klikasz link, co powoduje usunięcie bieżącego dokumentu z innego – ad infinitum.
  • Wzorzec aplikacji na jednej stronie, który obejmuje wstępne żądanie nawigacji w celu wczytania powłoki aplikacji i wykorzystuje JavaScript do wypełniania powłoki aplikacji znacznikami renderowanymi przez klienta treściami z interfejsu API backendu.

Zalety każdego podejścia podkreślali zwolennicy takiego podejścia:

  • Schemat nawigacji udostępniany domyślnie przez przeglądarki jest odporny, ponieważ dostęp do tras nie wymaga JavaScriptu. Renderowanie przez klienta znaczników za pomocą JavaScriptu może być potencjalnie kosztowne, co oznacza, że na słabszych urządzeniach może dojść do sytuacji, w której treść zostanie opóźniona, ponieważ urządzenie zablokuje przetwarzanie skryptów, które je udostępniają.
  • Natomiast aplikacje jednostronicowe (SPA) mogą zapewnić szybszą nawigację po początkowym wczytaniu. Zamiast polegać na przeglądarce, aby pobrać dokument z zupełnie nowego (i powtarzać to przy każdej nawigacji), można zaoferować szybsze działanie, podobne do aplikacji, nawet jeśli do działania potrzebny jest JavaScript.

W tym poście omówimy trzecią metodę, która zapewnia równowagę między 2 opisanymi wyżej podejściami: polega na tym, że mechanizm Service worker będzie wstępnie zapisywał w pamięci podręcznej typowe elementy witryny, takie jak znaczniki nagłówka i stopki, oraz jak najszybciej przesyła odpowiedzi HTML klientowi, zachowując przy tym domyślny schemat nawigacji przeglądarki.

Po co przesyłać strumieniowo odpowiedzi HTML w skrypcie service worker?

Strumieniowe przesyłanie danych to działanie, które Twoja przeglądarka już robi, gdy wysyła żądania. Jest to niezwykle ważne w kontekście żądań nawigacyjnych, ponieważ dzięki niemu przeglądarka nie będzie blokowana oczekiwanie na całą odpowiedź, zanim rozpocznie analizowanie znaczników dokumentu i wyrenderowanie strony.

Schemat przedstawiający kod HTML niestrumieniowy i strumieniowy HTML. W pierwszym przypadku cały ładunek znaczników nie zostanie przetworzony, dopóki go nie otrzyma. W tym drugim przypadku znaczniki są przetwarzane stopniowo w miarę pojawiania się fragmentów z sieci.

W przypadku mechanizmów Service Worker strumieniowanie wygląda trochę inaczej, ponieważ korzysta z interfejsu Streams API w języku JavaScript. Najważniejszym zadaniem, które wykonuje mechanizm service worker, jest przechwytywanie żądań (w tym żądań nawigacji) i odpowiadanie na nie.

Żądania te mogą wchodzić w interakcje z pamięcią podręczną na wiele sposobów. Typowym wzorcem buforowania w przypadku znaczników jest faworyzowanie użycia odpowiedzi z sieci najpierw, ale z pamięci podręcznej, gdy dostępna jest starsza kopia, oraz opcjonalnie udostępniania ogólnej odpowiedzi zastępczej, jeśli w pamięci podręcznej nie ma użytecznej odpowiedzi.

Jest to sprawdzony wzorzec znaczników, który działa dobrze, ale chociaż zwiększa niezawodność dostępu w trybie offline, nie oferuje żadnych naturalnych korzyści w zakresie wydajności w przypadku żądań nawigacji, które bazują na strategii opartej najpierw na sieci lub tylko na sieci. Właśnie tu pojawia się strumieniowanie. Pokażemy Ci, jak użyć modułu workbox-streams opartego na interfejsie Streams API w skrypcie usługi Workbox, aby przyspieszyć żądania nawigacji w witrynie wielostronicowej.

Podział typowej strony internetowej

Pod względem strukturalnym witryny zwykle zawierają te same elementy, które występują na każdej stronie. Typowy układ elementów strony często wygląda tak:

  • Nagłówek.
  • Treść.
  • Stopka.

Przy użyciu przykładu web.dev ten podział typowych elementów wygląda tak:

Omówienie typowych elementów witryny web.dev. Wyznaczone obszary wspólne są oznaczone jako „nagłówek”, „treść” i „stopka”.

Identyfikacja części strony polega na tym, że określamy, co można wstępnie buforować i pobrać bez przechodzenia do sieci, czyli między innymi znaczniki nagłówka i stopki wspólne dla wszystkich stron oraz tę część strony, z której zawsze najpierw otwieramy sieć (czyli treść w tym przypadku).

Gdy wiemy, jak podzielić części strony na segmenty i zidentyfikować typowe elementy, możemy stworzyć skrypt service worker, który zawsze od razu pobiera znaczniki nagłówka i stopki z pamięci podręcznej, wysyłając żądania tylko z sieci.

Następnie, używając interfejsu Streams API w workbox-streams, możemy połączyć wszystkie te elementy i błyskawicznie odpowiadać na żądania nawigacji, żądając jednocześnie od sieci minimalnej ilości znaczników.

Tworzenie instancji roboczej usługi strumieniowania

Odtwarzanie strumieniowe części treści w skryptach service worker składa się z wielu poruszających się elementów, ale każdy etap tego procesu zostanie szczegółowo omówiony na każdym etapie, zaczynając od struktury witryny.

Podział witryny na części

Zanim zaczniesz pisać skrypt service worker, musisz wykonać 3 czynności:

  1. Utwórz plik zawierający tylko znaczniki nagłówka witryny.
  2. Utwórz plik zawierający tylko znaczniki stopki witryny.
  3. Rozpakuj główną zawartość każdej strony w osobnym pliku lub skonfiguruj zaplecze tak, aby warunkowo wyświetlać tylko treść strony na podstawie nagłówka żądania HTTP.

Jak można się spodziewać, ostatni krok jest najtrudniejszy, zwłaszcza jeśli witryna jest statyczna. W takiej sytuacji musisz wygenerować dwie wersje każdej strony: jedna będzie zawierać pełne znaczniki strony, a druga tylko treść.

Tworzenie skryptu service worker

Jeśli moduł workbox-streams nie został jeszcze zainstalowany, musisz to zrobić oprócz wszystkich aktualnie zainstalowanych modułów Workbox. W tym konkretnym przykładzie, który obejmuje następujące pakiety:

npm i workbox-navigation-preload workbox-strategies workbox-routing workbox-precaching workbox-streams --save

Następnym krokiem jest utworzenie nowego skryptu service worker oraz wstępne wczytywanie części nagłówka i stopki w pamięci podręcznej.

Przewidywanie częściowych pamięci

Najpierw utworzysz w katalogu głównym projektu skrypt service worker o nazwie sw.js (lub dowolnej innej nazwie). Na początek:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// Enable navigation preload for supporting browsers
navigationPreload.enable();

// Precache partials and some static assets
// using the InjectManifest method.
precacheAndRoute([
  // The header partial:
  {
    url: '/partial-header.php',
    revision: __PARTIAL_HEADER_HASH__
  },
  // The footer partial:
  {
    url: '/partial-footer.php',
    revision: __PARTIAL_FOOTER_HASH__
  },
  // The offline fallback:
  {
    url: '/offline.php',
    revision: __OFFLINE_FALLBACK_HASH__
  },
  ...self.__WB_MANIFEST
]);

// To be continued...

Ten kod wykonuje kilka działań:

  1. Włącza wstępne wczytywanie nawigacji w przeglądarkach, które je obsługują.
  2. Powoduje wstępne zapisywanie znaczników nagłówka i stopki w pamięci podręcznej. Oznacza to, że znaczniki nagłówka i stopki każdej strony są pobierane natychmiast, ponieważ sieć nie blokuje ich.
  3. Trwa zapisywanie w pamięci podręcznej zasobów statycznych w obiekcie zastępczym __WB_MANIFEST, który korzysta z metody injectManifest.

Strumieniowanie odpowiedzi

Największą rolę w tym procesie odgrywa udostępnienie przez skrypt service worker do strumieniowego przesyłania połączonych odpowiedzi. Mimo to dzięki Workbox i jego workbox-streams sprawa jest bardziej zwięzła niż w przypadku, gdy wszystkie czynności trzeba byłoby wykonać samodzielnie:

// sw.js
import * as navigationPreload from 'workbox-navigation-preload';
import {NetworkFirst} from 'workbox-strategies';
import {registerRoute} from 'workbox-routing';
import {matchPrecache, precacheAndRoute} from 'workbox-precaching';
import {strategy as composeStrategies} from 'workbox-streams';

// ...
// Prior navigation preload and precaching code omitted...
// ...

// The strategy for retrieving content partials from the network:
const contentStrategy = new NetworkFirst({
  cacheName: 'content',
  plugins: [
    {
      // NOTE: This callback will never be run if navigation
      // preload is not supported, because the navigation
      // request is dispatched while the service worker is
      // booting up. This callback will only run if navigation
      // preload is _not_ supported.
      requestWillFetch: ({request}) => {
        const headers = new Headers();

        // If the browser doesn't support navigation preload, we need to
        // send a custom `X-Content-Mode` header for the back end to use
        // instead of the `Service-Worker-Navigation-Preload` header.
        headers.append('X-Content-Mode', 'partial');

        // Send the request with the new headers.
        // Note: if you're using a static site generator to generate
        // both full pages and content partials rather than a back end
        // (as this example assumes), you'll need to point to a new URL.
        return new Request(request.url, {
          method: 'GET',
          headers
        });
      },
      // What to do if the request fails.
      handlerDidError: async ({request}) => {
        return await matchPrecache('/offline.php');
      }
    }
  ]
});

// Concatenates precached partials with the content partial
// obtained from the network (or its fallback response).
const navigationHandler = composeStrategies([
  // Get the precached header markup.
  () => matchPrecache('/partial-header.php'),
  // Get the content partial from the network.
  ({event}) => contentStrategy.handle(event),
  // Get the precached footer markup.
  () => matchPrecache('/partial-footer.php')
]);

// Register the streaming route for all navigation requests.
registerRoute(({request}) => request.mode === 'navigate', navigationHandler);

// Your service worker can end here, or you can add more
// logic to suit your needs, such as runtime caching, etc.

Ten kod składa się z 3 głównych części, które spełniają te wymagania:

  1. Do obsługi żądań dotyczących części treści używana jest strategia NetworkFirst. W ramach tej strategii niestandardowa nazwa pamięci podręcznej content zawiera fragmenty treści oraz niestandardową wtyczkę, która decyduje o tym, czy należy ustawić nagłówek żądania X-Content-Mode w przypadku przeglądarek, które nie obsługują wstępnego wczytywania nawigacji (i tym samym nie wysyłają nagłówka Service-Worker-Navigation-Preload). Wtyczka określa również, czy wysłać częściową wersję treści z pamięci podręcznej, czy też przesłać stronę zastępczą offline w przypadku braku zapisanej wersji dla bieżącego żądania w pamięci podręcznej.
  2. Metoda strategy w workbox-streams (z aliasem w tym miejscu: composeStrategies) służy do łączenia części nagłówka i stopki z pamięci podręcznej z treścią, która jest częściowo żądana z sieci.
  3. Cały schemat jest spreparowany za pomocą registerRoute na potrzeby żądań nawigacji.

Po wprowadzeniu tych reguł przygotowaliśmy odpowiedzi na bieżąco. Jednak aby mieć pewność, że treści z sieci są fragmentami strony, które można scalić z częściami przechowywanymi w pamięci podręcznej, może być wymagane wykonanie pewnej czynności w zapleczu.

Jeśli Twoja witryna ma zaplecze

Pamiętaj, że gdy włączone jest wstępne wczytywanie nawigacji, przeglądarka wysyła nagłówek Service-Worker-Navigation-Preload o wartości true. W przykładowym kodzie powyżej wysłaliśmy jednak niestandardowy nagłówek X-Content-Mode w ramach wstępnego wczytywania nawigacji po zdarzeniach, które nie są obsługiwane przez przeglądarkę. W zapleczu należałoby zmienić odpowiedź na podstawie obecności tych nagłówków. W backendzie PHP może to wyglądać mniej więcej tak:

<?php
// Check if we need to render a content partial
$navPreloadSupported = isset($_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD']) && $_SERVER['HTTP_SERVICE_WORKER_NAVIGATION_PRELOAD'] === 'true';
$partialContentMode = isset($_SERVER['HTTP_X_CONTENT_MODE']) && $_SERVER['HTTP_X_CONTENT_MODE'] === 'partial';
$isPartial = $navPreloadSupported || $partialContentMode;

// Figure out whether to render the header
if ($isPartial === false) {
  // Get the header include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-header.php');

  // Render the header
  siteHeader();
}

// Get the content include
require_once('./content.php');

// Render the content
content($isPartial);

// Figure out whether to render the footer
if ($isPartial === false) {
  // Get the footer include
  require_once($_SERVER['DOCUMENT_ROOT'] . '/includes/site-footer.php');

  // Render the footer
  siteFooter();
}
?>

W powyższym przykładzie fragmenty treści są wywoływane jako funkcje, które przyjmują wartość $isPartial, aby zmienić sposób renderowania tych fragmentów. Na przykład funkcja renderowania content może zawierać w warunkach tylko określone znaczniki, gdy są pobierane tylko w części strony – to coś, co omówimy wkrótce.

co należy wziąć pod uwagę

Zanim wdrożysz skrypt service worker do strumieniowego przesyłania i łączenia fragmentów, musisz wziąć pod uwagę kilka kwestii. Chociaż korzystanie z skryptów service worker w ten sposób nie zmienia zasadniczo domyślnego działania nawigacji w przeglądarce, jest kilka rzeczy, które prawdopodobnie trzeba rozwiązać.

Aktualizowanie elementów strony podczas nawigacji

Najtrudniejsze w tym podejściu jest to, że niektórych rzeczy trzeba zaktualizować po stronie klienta. Na przykład znaczniki nagłówka w pamięci podręcznej oznaczają, że strona będzie miała tę samą treść w elemencie <title>, a nawet zarządzanie stanami włączenia/wyłączenia elementów nawigacyjnych będzie wymagało aktualizowania po każdej nawigacji. Te i inne rzeczy mogą wymagać aktualizacji po stronie klienta w przypadku każdego żądania nawigacji.

Aby obejść ten problem, możesz umieścić w części treści, która pochodzi z sieci, wbudowany element <script>, aby zaktualizować kilka ważnych rzeczy:

<!-- The JSON below contains information about the current page. -->
<script id="page-data" type="application/json">'{"title":"Sand Wasp &mdash; World of Wasps","description":"Read all about the sand wasp in this tidy little post."}'</script>
<script>
  const pageData = JSON.parse(document.getElementById('page-data').textContent);

  // Update the page title
  document.title = pageData.title;
</script>
<article>
  <!-- Page content omitted... -->
</article>

To tylko jeden z przykładów czynności, które możesz wykonać, jeśli zdecydujesz się na skonfigurowanie tego skryptu. W przypadku bardziej złożonych aplikacji zawierających informacje o użytkowniku konieczne może być na przykład przechowywanie fragmentów istotnych danych w sklepie internetowym takim jak localStorage i tam aktualizowanie strony.

Radzenie sobie z wolnymi sieciami

Wadą przesyłania odpowiedzi przesyłanych strumieniowo z użyciem znaczników z pamięci podręcznej może być wolne połączenie sieciowe. Problem polega na tym, że znaczniki nagłówka z pamięci podręcznej są dostarczane natychmiast, ale dotarcie częściowej treści z sieci może trochę potrwać po początkowym wyrenderowaniu znaczników nagłówka.

Może to być mylące, a jeśli sieć działa bardzo wolno, może się wydawać, że strona jest uszkodzone i nie jest już wyświetlana. W takich przypadkach możesz umieścić w znacznikach części treści ikonę ładowania lub komunikat, który możesz ukryć po wczytaniu treści.

Możesz to zrobić na przykład za pomocą CSS. Powiedzmy, że częściowe kończy się nagłówek otwierającym elementem <article>, który jest pusty, dopóki nie dotrze do niej część treści. Możesz utworzyć taką regułę CSS:

article:empty::before {
  text-align: center;
  content: 'Loading...';
}

To działa, ale bez względu na szybkość sieci po stronie klienta jest wyświetlany komunikat o wczytywaniu. Jeśli chcesz uniknąć dziwnych komunikatów, możesz zastosować tę metodę, w której selektor umieszczamy w powyższym fragmencie w klasie slow:

.slow article:empty::before {
  text-align: center;
  content: 'Loading...';
}

Tutaj możesz użyć JavaScriptu w nagłówku, by odczytać efektywny typ połączenia (przynajmniej w przeglądarkach Chromium) i dodać klasę slow do elementu <html> w przypadku wybranych typów połączeń:

<script>
  const effectiveType = navigator?.connection?.effectiveType;

  if (effectiveType !== '4g') {
    document.documentElement.classList.add('slow');
  }
</script>

Dzięki temu skuteczne typy połączeń wolniejsze niż typ 4g otrzymają komunikat o wczytywaniu. Następnie w części treści możesz umieścić wbudowany element <script>, aby usunąć z kodu HTML klasę slow i pozbyć się komunikatu wczytywania:

<script>
  document.documentElement.classList.remove('slow');
</script>

Dostarczanie odpowiedzi zastępczej

Załóżmy, że w przypadku częściowych treści stosujesz strategię skoncentrowaną na sieci. Jeśli użytkownik jest offline i wejdzie na stronę, którą już odwiedził, zostanie objęty ochroną. Jeśli jednak wejdą na stronę, której nie odwiedzili, nic nie otrzymają. Aby tego uniknąć, musisz przesłać odpowiedź zastępczą.

Kod wymagany do uzyskania odpowiedzi zastępczej jest przedstawiony we wcześniejszych przykładach kodu. Ten proces składa się z 2 etapów:

  1. Wstępnie buforować odpowiedź zastępczą offline.
  2. Skonfiguruj we wtyczce wywołanie zwrotne handlerDidError dla strategii skoncentrowanej na sieci, aby sprawdzać w pamięci podręcznej ostatnio używaną wersję strony. Jeśli strona nie została nigdy otwarta, musisz użyć metody matchPrecache z modułu workbox-precaching, aby pobrać odpowiedź zastępczą z pamięci podręcznej.

Pamięć podręczna i sieci CDN

Jeśli w skrypcie service worker używasz tego wzorca strumieniowego przesyłania danych, sprawdź, czy w Twojej sytuacji są spełnione te warunki:

  • Używasz sieci CDN lub innego rodzaju pośredniej/publicznej pamięci podręcznej.
  • Określono nagłówek Cache-Control z dyrektywami max-age lub s-maxage innymi niż zero w połączeniu z dyrektywą public.

Jeśli spełniasz oba te warunki, pośrednia pamięć podręczna może przechowywać odpowiedzi na żądania nawigacji. Pamiętaj jednak, że taki wzorzec może powodować wyświetlanie dwóch różnych odpowiedzi dla danego adresu URL:

  • Pełna odpowiedź zawierająca znaczniki nagłówka, treści i stopki.
  • Odpowiedź częściowa zawierająca tylko treść.

Może to spowodować niektóre niepożądane zachowania, w wyniku czego znaczniki nagłówka i stopki są podwójne, ponieważ skrypt service worker może pobierać pełną odpowiedź z pamięci podręcznej CDN i łączyć ją ze znacznikami nagłówka i stopki w pamięci podręcznej.

Aby obejść ten problem, musisz użyć nagłówka Vary, który wpływa na zachowanie pamięci podręcznej przez umieszczenie w pamięci podręcznej odpowiedzi na co najmniej 1 nagłówek obecny w żądaniu. Odpowiedzi na żądania nawigacji różnią się w zależności od nagłówków żądań Service-Worker-Navigation-Preload i X-Content-Mode, dlatego w odpowiedzi należy podać ten nagłówek Vary:

Vary: Service-Worker-Navigation-Preload,X-Content-Mode

Dzięki temu nagłówkowi przeglądarka rozróżnia pełne i częściowe odpowiedzi na żądania nawigacji, co pozwala uniknąć problemów z podwójnymi znacznikami nagłówka i stopki, podobnie jak w przypadku pośrednich pamięci podręcznych.

Wynik

Większość porad dotyczących wydajności w czasie wczytywania sprowadza się do polecenia „pokaż klientowi, co masz na myśli”. Nie wahaj się, nie czekaj, aż będziesz mieć wszystkie informacje, zanim cokolwiek zobaczysz.

Jake Archibald w filmie Fun Hacks for Szybsze treści

Przeglądarki znakomicie radzą sobie z odpowiedziami na żądania nawigacji, nawet w przypadku ogromnych treści odpowiedzi HTML. Domyślnie przeglądarki stopniowo przesyłają i przetwarzają znaczniki, co pozwala uniknąć długich zadań, co zwiększa wydajność uruchamiania.

Jest to dla nas przydatne, gdy używamy wzorca skryptu roboczego usługi strumieniowania. Za każdym razem, gdy odpowiadasz na żądanie z pamięci podręcznej skryptu service worker z poziomu get-go, początek odpowiedzi pojawia się niemal natychmiast. Po połączeniu znaczników nagłówka i stopki z pamięci podręcznej z odpowiedzią z sieci możesz uzyskać istotne korzyści związane z wydajnością:

  • Czas do pierwszego bajtu (TTFB) jest często znacznie ograniczony, ponieważ pierwszy bajt odpowiedzi na żądanie nawigacji jest natychmiastowy.
  • Pierwsze wyrenderowanie treści (FCP) będzie bardzo szybkie, ponieważ znaczniki nagłówka w pamięci podręcznej będą zawierać odniesienie do arkusza stylów zapisanego w pamięci podręcznej, co oznacza, że strona zostanie wyrenderowana bardzo, bardzo szybko.
  • W niektórych przypadkach największe wyrenderowanie treści (LCP) może być szybsze, zwłaszcza jeśli największy element na ekranie jest dostarczany przez fragment nagłówka w pamięci podręcznej. Mimo to szybkie udostępnienie czegoś z pamięci podręcznej skryptu service worker w połączeniu z mniejszymi ładunkami znaczników może poprawić jakość LCP.

Architektura streamingu wielu stron może być trudna w skonfigurowaniu i powtarzaniu, ale jej złożoność nie jest w rzeczywistości bardziej uciążliwa niż aplikacje jednostronicowe. Główną korzyścią jest to, że nie zastępujesz domyślnego schematu nawigacji przeglądarki, tylko go ulepszasz.

Dodatkowo Workbox sprawia, że taka architektura jest nie tylko dostępna, ale też łatwiejsza niż w przypadku samodzielnego wdrożenia. Wypróbuj je na swojej stronie i zobacz, jak szybciej będzie działać witryna wielostronicowa.

Zasoby