Applications multipages plus rapides avec des flux

De nos jours, les sites Web (ou les applications Web si vous préférez) ont tendance à utiliser l'un des deux schémas de navigation suivants:

  • Les navigateurs proposent un schéma de navigation par défaut. Autrement dit, si vous saisissez une URL dans la barre d'adresse de votre navigateur, une requête de navigation renvoie un document en tant que réponse. Vous cliquez ensuite sur un lien, qui décharge le document actuel d'un autre document : ad infinitum.
  • Modèle d'application monopage, qui implique une requête de navigation initiale pour charger le shell d'application et repose sur JavaScript pour remplir le shell d'application avec un balisage affiché par le client avec le contenu d'une API backend pour chaque "navigation".

Les avantages de chaque approche ont été vantés par leurs partisans:

  • Le schéma de navigation fourni par défaut par les navigateurs est résilient, car les routes ne nécessitent pas JavaScript pour être accessibles. L'affichage du balisage côté client au moyen de JavaScript peut également s'avérer coûteux. En d'autres termes, les appareils d'entrée de gamme peuvent se retrouver avec un retard du contenu, car l'appareil ne traite pas les scripts qui fournissent ce contenu.
  • En revanche, les applications monopages (SPA) peuvent accélérer la navigation après le chargement initial. Plutôt que de demander au navigateur de décharger un document pour en créer un autre (et de répéter cette opération à chaque navigation), ils peuvent offrir une expérience plus rapide et semblable à une application, même si le fonctionnement nécessite JavaScript.

Dans cet article, nous allons aborder une troisième méthode qui trouve un équilibre entre les deux approches décrites ci-dessus: faire appel à un service worker pour effectuer une mise en cache préalable des éléments communs d'un site Web (balisage d'en-tête et de pied de page, par exemple) et utiliser des flux pour fournir une réponse HTML au client le plus rapidement possible, tout en continuant à utiliser le schéma de navigation par défaut du navigateur.

Pourquoi diffuser des réponses HTML dans un service worker ?

Votre navigateur Web utilise déjà le streaming pour envoyer des requêtes. Ce point est extrêmement important dans le contexte des requêtes de navigation, car il permet de s'assurer que le navigateur n'est pas bloqué en attendant l'intégralité d'une réponse avant de pouvoir commencer à analyser le balisage du document et à afficher une page.

Schéma présentant le contenu HTML sans streaming et le contenu HTML diffusé en streaming. Dans le premier cas, la charge utile de balisage entière n'est traitée qu'une fois qu'elle est reçue. Dans le second cas, le balisage est traité de manière incrémentielle à mesure qu'il arrive en fragments depuis le réseau.

Pour les service workers, le streaming est un peu différent, car il utilise l'API Streams JavaScript. La tâche la plus importante d'un service worker est d'intercepter les requêtes, y compris les requêtes de navigation, et d'y répondre.

Ces requêtes peuvent interagir avec le cache de différentes manières, mais un schéma courant de mise en cache pour le balisage consiste à favoriser l'utilisation d'une réponse du réseau en premier, mais à utiliser le cache si une copie plus ancienne est disponible, et éventuellement à fournir une réponse générique de remplacement si une réponse utilisable ne se trouve pas dans le cache.

Ce modèle de balisage a fait ses preuves et fonctionne bien. Cependant, bien qu'il contribue à la fiabilité de l'accès hors connexion, il n'offre aucun avantage de performance inhérent aux requêtes de navigation qui reposent sur une stratégie axée sur le réseau ou sur un seul réseau. C'est là que le streaming entre en jeu. Nous allons voir comment utiliser le module workbox-streams basé sur l'API Streams dans votre service worker Workbox pour accélérer les requêtes de navigation sur votre site Web comportant plusieurs pages.

Détailler une page Web type

Structurellement, les sites Web ont tendance à comporter des éléments communs qui existent sur chaque page. Voici un exemple typique d'agencement d'éléments de page:

  • En-tête.
  • Contenu.
  • Pied de page

En prenant l'exemple de web.dev, cette répartition des éléments communs se présente comme suit:

Une répartition des éléments communs sur le site web web.dev. Les parties communes délimitées sont "en-tête", "contenu" et "pied de page".

L'objectif de l'identification des parties d'une page est de déterminer ce qui peut être mis en pré-cache et récupéré sans accéder au réseau (c'est-à-dire le balisage d'en-tête et de pied de page commun à toutes les pages), ainsi que la partie de la page pour laquelle nous accéderons toujours en premier au réseau, à savoir le contenu dans ce cas.

Lorsque nous savons comment segmenter les parties d'une page et identifier les éléments communs, nous pouvons créer un service worker qui récupère instantanément les balises d'en-tête et de pied de page à partir du cache, tout en demandant uniquement le contenu au réseau.

Ensuite, à l'aide de l'API Streams via workbox-streams, nous pouvons assembler toutes ces parties et répondre instantanément aux requêtes de navigation, tout en demandant le minimum de balisage nécessaire au réseau.

Créer un service worker de streaming

La diffusion d'un contenu partiel par un service worker a de nombreuses facettes, mais chaque étape du processus sera explorée en détail au fur et à mesure, en commençant par la façon de structurer votre site Web.

Segmenter votre site Web en segments partiels

Avant de pouvoir commencer à écrire un nœud de calcul de service de streaming, vous devez effectuer les trois opérations suivantes:

  1. Créez un fichier ne contenant que le balisage d'en-tête de votre site Web.
  2. Créez un fichier ne contenant que le balisage du pied de page de votre site Web.
  3. Extrayez le contenu principal de chaque page dans un fichier distinct, ou configurez votre backend pour qu'il ne diffuse que le contenu de la page de manière conditionnelle en fonction d'un en-tête de requête HTTP.

Comme vous pouvez vous en attendre, la dernière étape est la plus difficile, surtout si votre site Web est statique. Dans ce cas, vous devez générer deux versions de chaque page: l'une contiendra le balisage complet, l'autre ne contiendra que le contenu.

Composition d'un service worker de streaming

Si vous n'avez pas installé le module workbox-streams, vous devrez le faire en plus des modules Workbox actuellement installés. Pour cet exemple spécifique, cela implique les packages suivants:

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

L'étape suivante consiste alors à créer votre service worker et à effectuer une mise en cache préalable des parties d'en-tête et de pied de page.

Mise en cache partielle des parties

Vous commencerez par créer un service worker à la racine de votre projet, nommé sw.js (ou tout autre nom de fichier que vous préférez). Dans cet atelier, vous allez commencer par ce qui suit:

// 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...

Ce code permet d'effectuer les actions suivantes:

  1. Active le préchargement de navigation pour les navigateurs compatibles.
  2. Met en cache le balisage d'en-tête et de pied de page. Cela signifie que les balises d'en-tête et de pied de page de chaque page seront récupérées instantanément, car elles ne seront pas bloquées par le réseau.
  3. Les éléments statiques sont mis en cache en amont dans l'espace réservé __WB_MANIFEST qui utilise la méthode injectManifest.

Réponses en streaming

Faire en sorte que votre service worker diffuse des réponses concaténées est la principale partie de cet effort. Malgré cela, Workbox et son workbox-streams rendent l'opération beaucoup plus succincte que si vous deviez faire tout cela par vous-même:

// 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.

Ce code se compose de trois parties principales qui répondent aux exigences suivantes:

  1. Une stratégie NetworkFirst est utilisée pour gérer les requêtes de partialité de contenu. Avec cette stratégie, un nom de cache personnalisé de content est spécifié pour contenir les partiels de contenu, ainsi qu'un plug-in personnalisé qui gère s'il faut définir un en-tête de requête X-Content-Mode pour les navigateurs qui n'acceptent pas le préchargement de navigation (et qui n'envoient donc pas d'en-tête Service-Worker-Navigation-Preload). Ce plug-in détermine également s'il faut envoyer la dernière version mise en cache d'un contenu partiel ou une page de remplacement hors connexion si aucune version en cache n'est stockée pour la requête actuelle.
  2. La méthode strategy dans workbox-streams (appelée composeStrategies ici) permet de concaténer les partiels d'en-tête et de pied de page mis en pré-cache avec le contenu partiel demandé au réseau.
  3. L'ensemble du schéma est gréé via registerRoute pour les requêtes de navigation.

Une fois cette logique en place, nous avons configuré des réponses en flux continu. Toutefois, vous devrez peut-être effectuer quelques opérations sur un backend pour vous assurer que le contenu du réseau correspond à une page partielle que vous pourrez fusionner avec les parties mises en pré-cache.

Si votre site Web dispose d'un backend

Notez que lorsque le préchargement de la navigation est activé, le navigateur envoie un en-tête Service-Worker-Navigation-Preload avec la valeur true. Toutefois, dans l'exemple de code ci-dessus, nous avons envoyé un en-tête personnalisé X-Content-Mode si le préchargement de la navigation n'est pas compatible avec un navigateur. Dans le backend, vous modifieriez la réponse en fonction de la présence de ces en-têtes. Dans un backend PHP, cela peut ressembler à ceci pour une page donnée:

<?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();
}
?>

Dans l'exemple ci-dessus, les partiels de contenu sont appelés en tant que fonctions, qui utilisent la valeur de $isPartial pour modifier le rendu des partiels. Par exemple, il est possible que la fonction de moteur de rendu content n'inclue que certains balisages dans les conditions lorsqu'elle est récupérée en tant que partie partielle, ce qui sera abordé sous peu.

Points à prendre en compte

Avant de déployer un service worker pour diffuser et assembler des partiels, vous devez prendre en compte les points suivants. Bien qu'il soit vrai que cette utilisation d'un service worker ne modifie pas fondamentalement le comportement de navigation par défaut du navigateur, vous devrez probablement résoudre certains points.

Mettre à jour les éléments de la page lors de la navigation

La partie la plus délicate de cette approche est que certaines choses doivent être mises à jour sur le client. Par exemple, la mise en cache des en-têtes signifie que le contenu de la page sera le même dans l'élément <title>. Vous devrez également mettre à jour les états activés/désactivés des éléments de navigation à chaque navigation. Ces éléments, ainsi que d'autres, devront peut-être être mis à jour sur le client à chaque requête de navigation.

Pour contourner ce problème, vous pouvez placer un élément <script> intégré dans le contenu partiel provenant du réseau afin de mettre à jour certains éléments importants:

<!-- 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>

Ceci n'est qu'un exemple de ce que vous pourriez avoir à faire si vous décidez d'opter pour cette configuration de service worker. Pour les applications plus complexes comportant des informations utilisateur, par exemple, vous devrez peut-être stocker des données pertinentes dans un magasin en ligne tel que localStorage, puis mettre à jour la page à partir de là.

Gérer les réseaux lents

L'un des inconvénients des réponses diffusées en streaming à l'aide du balisage de la mise en cache préalable peut se produire lorsque les connexions réseau sont lentes. Le problème est que le balisage d'en-tête de la mise en cache préalable arrive instantanément, mais que le balisage du contenu partiel du réseau peut prendre un certain temps après le rendu initial du balisage de l'en-tête.

Cela peut rendre l'expérience déroutante. Si les réseaux sont très lents, vous pouvez même donner l'impression que la page est défaillante et qu'elle ne s'affiche plus. Dans ce cas, vous pouvez choisir de placer une icône ou un message de chargement dans le balisage du contenu partiel. Vous pourrez ensuite le masquer une fois le contenu chargé.

Pour ce faire, vous pouvez utiliser CSS. Supposons que votre partie d'en-tête se termine par un élément <article> ouvrant qui reste vide jusqu'à ce que la partie du contenu arrive pour le remplir. Vous pouvez écrire une règle CSS semblable à celle-ci:

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

Cela fonctionne, mais un message de chargement s'affichera sur le client, quel que soit le débit du réseau. Si vous souhaitez éviter un étrange flash de message, vous pouvez essayer cette approche, qui consiste à imbriquer le sélecteur dans l'extrait ci-dessus au sein d'une classe slow:

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

À partir de là, vous pouvez utiliser JavaScript dans la partie de l'en-tête pour lire le type de connexion effectif (au moins dans les navigateurs Chromium) afin d'ajouter la classe slow à l'élément <html> sur certains types de connexion:

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

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

Ainsi, les types de connexion efficaces plus lents que le type 4g recevront un message de chargement. Ensuite, dans le contenu partiel, vous pouvez placer un élément <script> intégré pour supprimer la classe slow du code HTML et supprimer le message de chargement:

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

Fournir une réponse de remplacement

Supposons que vous utilisiez une stratégie axée sur le réseau pour vos contenus partiels. Si l'utilisateur est hors connexion et qu'il accède à une page qu'il a déjà consultée, l'étape correspondante est occupée. En revanche, s'ils accèdent à une page qu'ils n'ont pas encore consultée, ils ne recevront rien. Pour éviter cela, vous devez diffuser une réponse de remplacement.

Le code requis pour obtenir une réponse de remplacement est illustré dans les exemples de code précédents. Ce processus comporte deux étapes:

  1. Effectuer une mise en cache préalable d'une réponse de remplacement hors connexion.
  2. Configurez un rappel handlerDidError dans le plug-in pour votre stratégie axée sur le réseau afin de rechercher dans le cache la dernière version consultée d'une page. Si la page n'a jamais été consultée, vous devez utiliser la méthode matchPrecache du module workbox-precaching pour récupérer la réponse de remplacement à partir de la mise en cache préalable.

Mise en cache et CDN

Si vous utilisez ce schéma de traitement par flux dans votre service worker, évaluez si les conditions suivantes s'appliquent à votre situation:

  • Vous utilisez un CDN ou tout autre type de cache public/intermédiaire.
  • Vous avez spécifié un en-tête Cache-Control avec une ou plusieurs instructions max-age et/ou s-maxage en combinaison avec l'instruction public.

Si vous vous trouvez dans ces deux cas, le cache intermédiaire peut conserver les réponses aux requêtes de navigation. Toutefois, n'oubliez pas que lorsque vous utilisez ce format, vous pouvez afficher deux réponses différentes pour une URL donnée:

  • Réponse complète, contenant les balises d'en-tête, de contenu et de pied de page.
  • Réponse partielle, ne contenant que le contenu.

Cela peut engendrer des comportements indésirables et doubler le balisage des en-têtes et des pieds de page. En effet, il est possible que le service worker récupère une réponse complète à partir du cache CDN et la combine avec votre balisage d'en-tête et de pied de page mis en cache en amont.

Pour contourner ce problème, vous devez vous appuyer sur l'en-tête Vary, qui affecte le comportement de mise en cache en associant les réponses pouvant être mises en cache à un ou plusieurs en-têtes présents dans la requête. Étant donné que nous modifions les réponses aux requêtes de navigation en fonction des en-têtes de requête Service-Worker-Navigation-Preload et X-Content-Mode personnalisés, nous devons spécifier cet en-tête Vary dans la réponse:

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

Avec cet en-tête, le navigateur fera la différence entre les réponses complètes et partielles aux requêtes de navigation, en évitant les problèmes de double balisage des en-têtes et des pieds de page, comme c'est le cas pour les caches intermédiaires.

Le résultat

La plupart des conseils sur les performances en termes de temps de chargement se résument à "montrer ce que vous avez obtenu". Ne vous retenez pas, n'attendez pas d'avoir tout en main pour montrer quoi que ce soit à l'utilisateur.

Jake Archibald dans Fun Hacks for Faster Content

Les navigateurs sont très performants lorsqu'il s'agit de traiter les réponses aux requêtes de navigation, même lorsqu'un corps de réponse HTML est très volumineux. Par défaut, les navigateurs diffusent et traitent progressivement le balisage par blocs afin d'éviter les longues tâches, ce qui est bon pour les performances au démarrage.

Cela fonctionne à notre avantage lorsque nous utilisons un modèle de nœud de calcul de service de streaming. Chaque fois que vous répondez dès le départ à une requête provenant du cache de service worker, le début de la réponse arrive presque instantanément. Lorsque vous assemblez un balisage d'en-tête et de pied de page mis en cache en pré-cache avec une réponse du réseau, vous bénéficiez d'avantages notables en termes de performances:

  • Le délai du premier octet (TTFB) sera souvent considérablement réduit, car le premier octet de la réponse à une requête de navigation est instantané.
  • Le tag First Contentful Paint (FCP) sera très rapide, car le balisage d'en-tête mis en pré-cache contient une référence à une feuille de style mise en cache, ce qui signifie que le rendu de la page est très, très rapide.
  • Dans certains cas, le LCP (Largest Contentful Paint) peut également s'avérer plus rapide, en particulier si le plus grand élément à l'écran est fourni par la partie d'en-tête mise en cache en pré-cache. Néanmoins, le fait de diffuser quelque chose à partir du cache du service worker dès que possible en tandem avec des charges utiles de balisage plus petites peut améliorer le LCP.

Il peut être un peu difficile de configurer et d'itérer des architectures de plusieurs pages en flux continu, mais en théorie, la complexité des architectures de plusieurs pages n'est pas plus onéreuse que les applications monopages. Le principal avantage est que vous ne remplacez pas le schéma de navigation par défaut du navigateur, mais que vous l'optimisez.

Mieux encore, Workbox rend cette architecture non seulement possible, mais aussi plus facile que si vous deviez l'implémenter vous-même. Essayez cette fonctionnalité sur votre propre site Web et découvrez à quel point un site de plusieurs pages peut être plus rapide pour les utilisateurs de terrain.

Ressources