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 fournissent un schéma de navigation par défaut, c'est-à-dire que vous saisissez une URL dans la barre d'adresse de votre navigateur et qu'une requête de navigation renvoie un document en tant que réponse. Ensuite, vous cliquez sur un lien, ce qui décharge le document actif pour un autre, ad infinitum.
  • Le modèle d'application monopage, qui implique une requête de navigation initiale pour charger le shell de l'application et qui s'appuie sur JavaScript pour remplir le shell de l'application avec un balisage affiché par le client avec du contenu issu d'une API backend pour chaque "navigation".

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

  • Le schéma de navigation fourni par les navigateurs est résilient, car les routes ne nécessitent pas l'accès à JavaScript. L'affichage du balisage par les clients au moyen de JavaScript peut également s'avérer un processus potentiellement coûteux, ce qui signifie que les appareils bas de gamme peuvent se retrouver dans une situation où le contenu est retardé, car l'appareil est bloqué lors du traitement des scripts qui fournissent du contenu.
  • En revanche, les applications monopages (SPA, Single Page Applications) peuvent offrir une navigation plus rapide après le chargement initial. Plutôt que de compter sur le navigateur pour décharger un document pour un tout nouveau (et de le répéter pour chaque navigation), ils peuvent proposer ce qui ressemble à un plus rapide, plus "comme une application" même si cela nécessite JavaScript pour fonctionner.

Dans ce post, nous allons vous présenter une troisième méthode qui permet de trouver un équilibre entre les deux approches décrites ci-dessus: faire appel à un service worker pour mettre en cache les éléments communs d'un site Web (comme le balisage d'en-tête et de pied de page) 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 les réponses HTML dans un service worker ?

Le streaming est déjà une opération que votre navigateur Web utilise déjà lorsqu'il envoie des requêtes. Ce point est extrêmement important dans le contexte des demandes de navigation, car il garantit que le navigateur n'est pas bloqué à attendre l'intégralité d'une réponse avant de pouvoir commencer à analyser le balisage du document et à afficher une page.

Schéma illustrant le contenu HTML qui ne diffuse pas de flux de données et le code HTML non diffusé en streaming Dans le premier cas, l'intégralité de la charge utile de balisage n'est pas traitée avant d'arriver. Dans le second cas, le balisage est traité de manière incrémentielle à mesure qu'il arrive par morceaux 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 consiste à intercepter les requêtes et à y répondre, y compris aux requêtes de navigation.

Ces requêtes peuvent interagir avec le cache de plusieurs façons, mais un schéma de mise en cache courant pour le balisage consiste à privilégier l'utilisation d'une réponse du réseau en premier, mais à revenir au cache si une copie plus ancienne est disponible et, éventuellement, à fournir une réponse de remplacement générique si aucune réponse utilisable ne se trouve dans le cache.

Il s'agit d'un modèle de balisage éprouvé qui fonctionne bien. Toutefois, bien qu'il améliore la fiabilité en termes d'accès hors connexion, il n'offre aucun avantage inhérent en termes de performances pour les requêtes de navigation qui reposent sur une stratégie axée sur le réseau ou sur le réseau uniquement. C'est là que le streaming entre en jeu. Nous allons voir comment utiliser le module workbox-streams alimenté par l'API Streams dans votre service worker Workbox pour accélérer les requêtes de navigation sur votre site Web de plusieurs pages.

Décomposer une page Web classique

Structurellement parlant, les sites Web ont tendance à comporter des éléments communs qui existent sur chaque page. Une disposition typique des éléments de page ressemble souvent à ceci:

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

Si l'on prend l'exemple de web.dev, cette répartition des éléments communs ressemble à ceci:

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

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

Lorsque nous savons comment segmenter les parties d'une page et identifier les éléments communs, nous pouvons écrire un service worker qui récupère toujours instantanément le balisage de l'en-tête et du pied de page 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 pièces et répondre instantanément aux demandes de navigation, tout en demandant le minimum de balisage nécessaire au réseau.

Créer un service worker de streaming

Le streaming de contenu partiel dans un service worker a de nombreux aspects variables. Toutefois, chaque étape du processus sera explorée en détail au fur et à mesure, en commençant par la structure de votre site Web.

Segmenter votre site Web en fragments

Avant de pouvoir écrire un service worker de streaming, vous devez effectuer trois opérations:

  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 diffuse uniquement le contenu de la page de manière conditionnelle en fonction d'un en-tête de requête HTTP.

Comme vous pouvez vous y 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 contient le balisage de la page complète et l'autre uniquement 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 à créer un service worker et à mettre en cache les parties partielles de l'en-tête et du pied de page.

Mise en cache partielle

La première chose à faire est de créer un service worker à la racine de votre projet, nommé sw.js (ou le nom de fichier de votre choix). Dans cet ensemble, vous commencerez 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 remplit plusieurs fonctions:

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

Réponses en streaming

L'essentiel de cet effort consiste à faire en sorte que votre service worker transmette les réponses concaténées en flux continu. Malgré tout, Workbox et sa workbox-streams en font beaucoup plus succinct que si vous deviez effectuer toutes ces opérations 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 permet de gérer les requêtes de parties de contenu. Avec cette stratégie, un nom de cache personnalisé de content est spécifié pour inclure les parties de contenu partielles, 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'une partie du contenu ou une page de remplacement hors connexion si aucune version mise en cache de la requête actuelle n'est stockée.
  2. La méthode strategy dans workbox-streams (alias composeStrategies ici) permet de concaténer les parties partielles de l'en-tête et du pied de page mises en cache, ainsi que l'élément de contenu 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é les réponses en flux continu. Toutefois, vous devrez peut-être effectuer un certain travail sur un backend afin de 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

Gardez à l'esprit 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 lorsque le préchargement de la navigation pour les événements n'est pas pris en charge dans un navigateur. Dans le backend, vous modifiez la réponse en fonction de la présence de ces en-têtes. Dans un backend PHP, cela peut se présenter comme suit 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 éléments partiels de contenu sont appelés en tant que fonctions qui prennent la valeur de $isPartial pour modifier leur rendu. Par exemple, il est possible que la fonction de moteur de rendu content n'inclue qu'un certain balisage dans les conditions lorsqu'elle est récupérée en tant que partie, ce que nous aborderons bientôt.

Remarques

Avant de déployer un service worker pour diffuser et assembler des parties partielles, vous devez prendre en compte certains éléments. Même s'il est vrai que l'utilisation d'un service worker de cette manière ne modifie pas fondamentalement le comportement de navigation par défaut du navigateur, vous devrez probablement corriger 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 devront être mises à jour sur le client. Par exemple, le balisage d'en-tête avec mise en cache préalable signifie que le contenu de la page sera identique dans l'élément <title>, ou même que la gestion des états d'activation/de désactivation des éléments de navigation devra être mise à jour à chaque navigation. Ces éléments, entre autres, peuvent avoir besoin d'être mis à jour sur le client pour chaque requête de navigation.

Pour contourner ce problème, vous pouvez placer un élément <script> intégré dans l'élément de contenu provenant du réseau afin de mettre à jour quelques é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 devrez peut-être faire si vous décidez d'opter pour cette configuration de service worker. Pour les applications plus complexes contenant des informations utilisateur, par exemple, vous devrez peut-être stocker des fragments de données pertinentes dans un magasin en ligne tel que localStorage et mettre à jour la page à partir de là.

Gérer les réseaux lents

L'un des inconvénients de l'insertion en flux continu de réponses utilisant le balisage du précache peut se produire lorsque les connexions réseau sont lentes. Le problème est que le balisage d'en-tête du précache arrive instantanément, mais le contenu partiel du réseau peut mettre un certain temps à arriver après l'application initiale du balisage d'en-tête.

Cela peut créer une expérience déroutante, et si les réseaux sont très lents, on peut même avoir l'impression que la page ne fonctionne pas et qu'elle ne s'affiche plus. Dans ce cas, vous pouvez choisir d'insérer une icône ou un message de chargement dans le balisage de l'élément partiel de contenu, que vous pourrez 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> d'ouverture vide jusqu'à ce que le contenu partiel 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. Pour éviter un flash étrange de messages, vous pouvez essayer la méthode suivante, qui imbrique le sélecteur dans l'extrait ci-dessus dans une classe slow:

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

Vous pouvez alors utiliser JavaScript dans l'élément d'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> pour 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 l'élément de contenu, vous pouvez placer un élément <script> intégré pour supprimer la classe slow du code HTML et ainsi se débarrasser du 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, elle est couverte. En revanche, s'il accède à une page qu'il n'a pas encore consultée, il n'obtiendra 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. Le processus comporte deux étapes:

  1. Mettez en cache une réponse de remplacement hors connexion avant la mise en cache.
  2. Configurez un rappel handlerDidError dans le plug-in pour votre stratégie axée sur le réseau afin de vérifier le cache de la dernière version d'une page consultée. 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 du précache.

Mise en cache et CDN

Si vous utilisez ce modèle de flux dans votre service worker, déterminez si les conditions suivantes s'appliquent à votre cas:

  • Vous utilisez un CDN ou toute autre sorte de cache intermédiaire/public.
  • Vous avez spécifié un en-tête Cache-Control avec une ou plusieurs directives max-age et/ou s-maxage différentes de zéro en combinaison avec l'instruction public.

Dans les deux cas, le cache intermédiaire peut conserver des réponses aux requêtes de navigation. Cependant, n'oubliez pas que lorsque vous utilisez ce format, il se peut que vous affichiez deux réponses différentes pour une URL donnée:

  • Réponse complète, avec le balisage de l'en-tête, du contenu et du pied de page
  • Réponse partielle, ne comportant que le contenu

Cela peut entraîner des comportements indésirables et doubler le balisage de l'en-tête et du pied de page. En effet, il est possible que le service worker récupère une réponse complète du cache CDN et la combine avec les balisages d'en-tête et de pied de page mis en pré-cache.

Pour contourner ce problème, vous devez utiliser 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 les réponses aux requêtes de navigation varient 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 pour les demandes de navigation, évitant ainsi les problèmes de balisage d'en-tête et de pied de page en double, tout comme les caches intermédiaires.

Le résultat

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

<ph type="x-smartling-placeholder"></ph> Jake Archibald dans Fun Hacks for Fast Content

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

Cette méthode est à notre avantage lorsque nous utilisons un modèle de nœud de calcul de service de traitement par flux. Chaque fois que vous répondez à une requête du cache de service workers dès le départ, 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 pré-cache avec une réponse du réseau, vous obtenez des avantages notables en termes de performances:

  • Le délai avant le premier octet (TTFB) est souvent considérablement réduit, car le premier octet de la réponse à une requête de navigation est instantané.
  • La méthode First Contentful Paint (FCP) est très rapide, car le balisage d'en-tête mis en cache contient une référence à une feuille de style mise en cache, ce qui signifie que la page s'affiche très, très rapidement.
  • Dans certains cas, le format Largest Contentful Paint (LCP) peut également être plus rapide, en particulier si le plus grand élément à l'écran est fourni par l'élément d'en-tête en pré-cache. Même dans ce cas, diffuser quelque chose à partir du cache de service worker dès que possible en même temps que des charges utiles de balisage plus petites peut générer un meilleur LCP.

Les architectures de flux de plusieurs pages peuvent être un peu difficiles à configurer et à itérer, mais leur complexité n'est souvent pas plus onéreuse que celle des applications monopages en théorie. Le principal avantage est que vous ne remplacez pas le schéma de navigation par défaut du navigateur, mais que vous l'améliorez.

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

Ressources