Applicazioni multipagina più veloci con i flussi

Attualmente, i siti web o le app web, se preferisci, tendono a utilizzare uno di questi due schemi di navigazione:

  • Gli schemi di navigazione forniscono per impostazione predefinita i browser, ovvero devi inserire un URL nella barra degli indirizzi del browser e una richiesta di navigazione restituisce un documento come risposta. Quindi fai clic su un link che scarica il documento corrente per un altro, ad infinitum.
  • Il pattern dell'applicazione a pagina singola, che prevede una richiesta di navigazione iniziale per caricare la shell dell'applicazione e si basa su JavaScript per compilare la shell dell'applicazione con markup sottoposto a rendering client con contenuti di un'API back-end per ogni "navigazione".

I vantaggi di ciascun approccio sono stati denunciati dai loro sostenitori:

  • Lo schema di navigazione fornito dai browser per impostazione predefinita è resiliente, in quanto le route non richiedono che JavaScript sia accessibile. Anche il rendering del markup da parte dei client tramite JavaScript può essere una procedura potenzialmente costosa, vale a dire che i dispositivi di fascia inferiore possono finire in una situazione in cui i contenuti vengono ritardati perché il dispositivo è bloccato nell'elaborazione degli script che forniscono contenuti.
  • Le applicazioni a pagina singola (APS) potrebbero invece offrire navigazioni più veloci dopo il caricamento iniziale. Invece di affidarsi al browser per l'unload di un documento per un documento completamente nuovo (e ripeterlo per ogni navigazione), possono offrire quella che sembra un'esperienza più veloce e "simile a un'app", anche se questo richiede il funzionamento di JavaScript.

In questo post, parleremo di un terzo metodo in grado di trovare un equilibrio tra i due approcci descritti sopra: fare affidamento su un service worker per pre-memorizzare nella cache gli elementi comuni di un sito web, come il markup dell'intestazione e del piè di pagina, e utilizzare gli stream per fornire una risposta HTML al client il più rapidamente possibile, il tutto utilizzando lo schema di navigazione predefinito del browser.

Perché inserire lo stream delle risposte HTML in un service worker?

Lo streaming è un'attività che il browser web già fa quando effettua le richieste. Questo è estremamente importante nel contesto delle richieste di navigazione, poiché garantisce che il browser non venga bloccato in attesa della risposta completa prima di poter iniziare ad analizzare il markup del documento e visualizzare una pagina.

Diagramma che mostra il confronto tra HTML non in streaming e HTML in streaming. Nel primo caso, l'intero payload di markup non viene elaborato finché non arriva. Nel secondo caso, il markup viene elaborato in modo incrementale quando arriva in blocchi dalla rete.

Per i service worker, il flusso di dati è leggermente diverso in quanto utilizza l'API Streams JavaScript. L'attività più importante che un service worker esegue è intercettare e rispondere alle richieste, incluse le richieste di navigazione.

Queste richieste possono interagire con la cache in vari modi, ma un pattern di memorizzazione nella cache comune per il markup consiste nel favorire l'utilizzo di una risposta dalla rete prima, per poi tornare alla cache se è disponibile una copia meno recente e, facoltativamente, fornire una risposta di riserva generica se non è presente una risposta utilizzabile nella cache.

Si tratta di un modello di markup collaudato nel tempo che funziona bene, ma sebbene aiuti con l'affidabilità in termini di accesso offline, non offre vantaggi intrinseci in termini di prestazioni per le richieste di navigazione che si basano su una strategia Network First o Solo Network. È qui che entra in gioco lo streaming. Vedremo come utilizzare il modulo workbox-streams basato sull'API Streams nel tuo service worker Workbox per velocizzare le richieste di navigazione sul tuo sito web multipagina.

Analisi di una tipica pagina web

Strutturalmente, i siti web tendono ad avere elementi comuni in ogni pagina. Una disposizione tipica degli elementi di una pagina spesso è:

  • Intestazione.
  • Contenuti.
  • Piè di pagina

Utilizzando web.dev come esempio, la suddivisione degli elementi comuni sarà simile a questa:

Un'analisi degli elementi comuni sul sito web web.dev. Le aree comuni descritte sono indicate come "intestazione", "contenuti" e "piè di pagina".

L'obiettivo che si nasconde dietro l'identificazione di parti di una pagina è stabilire quali elementi possono essere pre-memorizzati nella cache e recuperarli senza passare alla rete (ovvero il markup dell'intestazione e del piè di pagina comune a tutte le pagine) e la parte della pagina che per prima andremo sempre in rete, ovvero i contenuti in questo caso.

Quando sappiamo come segmentare le parti di una pagina e identificare gli elementi comuni, possiamo scrivere un service worker che recupera sempre il markup dell'intestazione e del piè di pagina all'istante dalla cache, richiedendo solo i contenuti alla rete.

Successivamente, usando l'API Streams tramite workbox-streams, possiamo unire tutte queste parti e rispondere istantaneamente alle richieste di navigazione, richiedendo al contempo la quantità minima di markup necessaria alla rete.

Creazione di un servizio di streaming

Quando si tratta di trasmettere contenuti parziali in streaming in un service worker, ci sono molte parti in movimento, ma ogni fase del processo verrà esplorata in dettaglio man mano che procedi, a partire dalla struttura del sito web.

Segmentare il sito web in parziali

Prima di poter iniziare a scrivere un service worker per il servizio di streaming, dovrai eseguire tre operazioni:

  1. Crea un file contenente solo il markup dell'intestazione del tuo sito web.
  2. Crea un file contenente solo il markup del piè di pagina del tuo sito web.
  3. Estrai i contenuti principali di ogni pagina in un file separato o imposta il tuo backend in modo che pubblichi in modo condizionale solo i contenuti della pagina in base a un'intestazione di richiesta HTTP.

Come ci si potrebbe aspettare, l'ultimo passaggio è il più difficile, soprattutto se il sito web è statico. Se questo è il tuo caso, dovrai generare due versioni di ogni pagina: una versione conterrà il markup della pagina intera, mentre l'altra conterrà solo i contenuti.

Composizione di un service worker di streaming

Se non hai installato il modulo workbox-streams, dovrai farlo in aggiunta agli eventuali moduli di Workbox attualmente installati. Per questo esempio specifico, ciò riguarda i pacchetti seguenti:

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

Da qui, il passaggio successivo consiste nel creare il nuovo service worker e pre-memorizzare nella cache le parti di intestazione e piè di pagina.

Pre-memorizzazione nella cache delle parti parziali

La prima cosa da fare è creare un service worker nella directory principale del progetto denominato sw.js (o in qualsiasi nome file che preferisci). Qui inizierai con quanto segue:

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

Questo codice svolge un paio di funzioni:

  1. Attiva il precaricamento della navigazione per i browser che lo supportano.
  2. Memorizza nella cache il markup dell'intestazione e del piè di pagina. Ciò significa che il markup dell'intestazione e del piè di pagina di ogni pagina verrà recuperato istantaneamente perché non verrà bloccato dalla rete.
  3. Pre-memorizzazione nella cache le risorse statiche nel segnaposto __WB_MANIFEST che utilizza il metodo injectManifest.

Risposte dinamiche

Fare in modo che il tuo service worker trasmetta risposte concatenate è la parte più importante di questo impegno. Anche in questo caso, Workbox e il suo workbox-streams rendono tutto un affare molto più conciso di quanto accadrebbe se dovessi fare tutto questo in autonomia:

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

Questo codice è costituito da tre parti principali che soddisfano i seguenti requisiti:

  1. Viene utilizzata una strategia NetworkFirst per gestire le richieste di partizioni di contenuti. Con questa strategia, il nome personalizzato della cache content viene specificato per contenere le parti dei contenuti, nonché un plug-in personalizzato che gestisce se impostare un'intestazione della richiesta X-Content-Mode per i browser che non supportano il precaricamento della navigazione (e quindi non inviano un'intestazione Service-Worker-Navigation-Preload). Questo plug-in determina anche se inviare l'ultima versione memorizzata nella cache di una parte dei contenuti o se inviare una pagina di riserva offline nel caso in cui non venga memorizzata alcuna versione memorizzata nella cache per la richiesta corrente.
  2. Il metodo strategy in workbox-streams (qui con alias composeStrategies) viene utilizzato per concatenare le parti di intestazione e piè di pagina pre-memorizzate nella cache insieme ai contenuti richiesti dalla rete.
  3. L'intero schema viene elaborato tramite registerRoute per le richieste di navigazione.

Con questa logica, abbiamo impostato le risposte in streaming. Tuttavia, potrebbe essere necessario eseguire alcune operazioni su un backend per assicurarti che i contenuti della rete siano una pagina parziale che puoi unire alle parti pre-memorizzate nella cache.

Se il tuo sito web ha un backend

Ricorda che, quando è attivo il precaricamento della navigazione, il browser invia un'intestazione Service-Worker-Navigation-Preload con valore true. Tuttavia, nell'esempio di codice riportato sopra, abbiamo inviato un'intestazione personalizzata X-Content-Mode nel precaricamento della navigazione eventi non supportato in un browser. Nel back-end, dovresti modificare la risposta in base alla presenza di queste intestazioni. In un backend PHP, per una determinata pagina l'aspetto potrebbe essere simile al seguente:

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

Nell'esempio precedente, le parti dei contenuti vengono richiamate come funzioni, che prendono il valore $isPartial per modificare il modo in cui vengono visualizzate le parziali. Ad esempio, la funzione del renderer content può includere solo determinati markup nelle condizioni quando viene recuperata come parziale. Questo argomento verrà trattato a breve.

considerazioni

Prima di eseguire il deployment di un service worker per trasmettere in streaming e unificare le parti parziali, devi tenere in considerazione alcuni aspetti. È vero che l'utilizzo di un service worker in questo modo non cambia sostanzialmente il comportamento di navigazione predefinito del browser, ma ci sono alcuni aspetti che probabilmente dovrai risolvere.

Aggiornamento degli elementi di pagina durante la navigazione

La parte più complessa di questo approccio è che alcune cose dovranno essere aggiornate sul client. Ad esempio, prememorizzazione nella cache del markup dell'intestazione significa che la pagina avrà gli stessi contenuti nell'elemento <title> o anche la gestione degli stati di attivazione e disattivazione degli elementi di navigazione dovrà essere aggiornata in ogni navigazione. Questi e altri aspetti potrebbero dover essere aggiornati sul client per ogni richiesta di navigazione.

Per aggirare il problema, potresti inserire un elemento <script> incorporato nelle porzioni di contenuti provenienti dalla rete, per aggiornare alcuni elementi importanti:

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

Questo è solo un esempio di ciò che potresti dover fare se decidi di procedere con questa configurazione del service worker. Per applicazioni più complesse con informazioni sugli utenti, ad esempio, potresti dover archiviare bit di dati pertinenti in un web store come localStorage e aggiornare la pagina da lì.

Gestire le reti lente

Quando le connessioni di rete sono lente, possono verificarsi uno svantaggio di flussi di risposte che utilizzano il markup della pre-cache. Il problema è che il markup dell'intestazione della pre-cache arriva immediatamente, ma la visualizzazione dei contenuti parziali dalla rete può richiedere un po' di tempo dopo la visualizzazione iniziale del markup dell'intestazione.

Ciò può creare un'esperienza confusa e, se le reti sono molto lente, può persino sembrare che la pagina sia rotta e non venga visualizzata più a fondo. In questi casi, puoi scegliere di inserire un'icona o un messaggio di caricamento nel markup dei contenuti parziali da nascondere una volta caricati i contenuti.

Un modo per farlo è tramite CSS. Supponiamo che la parte di intestazione termini con un elemento <article> di apertura vuoto finché non arriva il contenuto parziale. Potresti scrivere una regola CSS simile alla seguente:

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

Funziona, ma verrà visualizzato un messaggio di caricamento sul client indipendentemente dalla velocità della rete. Se vuoi evitare una strana quantità di messaggi, puoi provare questo approccio in cui nidifichiamo il selettore nello snippet riportato sopra all'interno di una classe slow:

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

Da qui puoi utilizzare JavaScript nell'intestazione parziale per leggere il tipo di connessione effettivo (almeno nei browser Chromium) per aggiungere la classe slow all'elemento <html> su determinati tipi di connessione:

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

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

In questo modo, per i tipi di connessione effettivi più lenti di 4g verrà visualizzato un messaggio durante il caricamento. Poi nella parte dei contenuti puoi inserire un elemento <script> incorporato per rimuovere la classe slow dal codice HTML ed eliminare il messaggio di caricamento:

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

Fornire una risposta di riserva

Supponiamo che tu stia utilizzando una strategia network-first per le parti di contenuti. Se l'utente è offline e visita una pagina che ha già visitato, la funzionalità è disponibile. Tuttavia, se visitano una pagina che non hanno ancora visitato, non riceveranno nulla. Per evitare che ciò accada, dovrai pubblicare una risposta di riserva.

Il codice necessario per ottenere una risposta di riserva è dimostrato negli esempi di codice precedenti. La procedura prevede due passaggi:

  1. Pre-memorizzazione nella cache di una risposta di riserva offline.
  2. Configura un callout di handlerDidError nel plug-in per la strategia network-first al fine di verificare nella cache l'ultima versione a cui è stato eseguito l'accesso di una pagina. Se non è mai stato eseguito l'accesso alla pagina, devi utilizzare il metodo matchPrecache del modulo workbox-precaching per recuperare la risposta di riserva dalla pre-cache.

Memorizzazione nella cache e CDN

Se utilizzi questo pattern di streaming nel tuo service worker, valuta se quanto segue si applica alla tua situazione:

  • Utilizzi una CDN o qualsiasi altro tipo di cache intermedia/pubblica.
  • Hai specificato un'intestazione Cache-Control con una o più istruzioni max-age e/o s-maxage diverse da zero in combinazione con l'istruzione public.

Se entrambi i casi sono per te, la cache intermedia potrebbe conservare le risposte per le richieste di navigazione. Tuttavia, ricorda che quando utilizzi questo pattern, per ogni URL potrebbero essere fornite due risposte diverse:

  • La risposta completa, contenente il markup di intestazione, contenuti e piè di pagina.
  • La risposta parziale, che include solo i contenuti.

Ciò può causare alcuni comportamenti indesiderati, con conseguenti markup di intestazioni e piè di pagina raddoppiati, in quanto il service worker potrebbe recuperare una risposta completa dalla cache CDN e combinarla con il markup di intestazione e piè di pagina pre-memorizzato nella cache.

Per ovviare a questo problema, devi fare affidamento sull'intestazione Vary, che influisce sul comportamento della memorizzazione nella cache inserendo le risposte memorizzabili nella cache a una o più intestazioni presenti nella richiesta. Poiché stiamo variando le risposte alle richieste di navigazione in base alle intestazioni delle richieste Service-Worker-Navigation-Preload e X-Content-Mode personalizzate, dobbiamo specificare questa intestazione Vary nella risposta:

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

Con questa intestazione, il browser distinguerà tra risposte complete e parziali per le richieste di navigazione, evitando problemi con il markup di intestazioni e piè di pagina raddoppiati, così come eventuali cache intermedie.

Risultato

La maggior parte dei consigli sulle prestazioni in termini di tempo di caricamento si riduce a "mostrare loro i risultati", non tirarti indietro, non aspettare di avere tutto prima di mostrare qualcosa all'utente.

Jake Archibald in Fun Hacks for Faster Content

I browser eccellono quando si tratta di gestire le risposte alle richieste di navigazione, anche per corpi di risposta HTML di grandi dimensioni. Per impostazione predefinita, i browser trasmettono in streaming ed elaborano progressivamente il markup in blocchi per evitare attività lunghe, il che è positivo per le prestazioni all'avvio.

Ciò è a nostro vantaggio quando utilizziamo un pattern di worker per i servizi di streaming. Ogni volta che rispondi a una richiesta dalla cache del service worker fin dall'inizio, l'inizio della risposta arriva quasi istantaneamente. Quando unisci il markup di intestazione e piè di pagina pre-memorizzato nella cache con una risposta della rete, ottieni alcuni notevoli vantaggi in termini di prestazioni:

  • Il tempo per il primo byte (TTFB) viene spesso ridotto poiché il primo byte della risposta a una richiesta di navigazione è istantaneo.
  • First Contentful Paint (FCP) è molto veloce, in quanto il markup dell'intestazione pre-memorizzato nella cache conterrà un riferimento a un foglio di stile memorizzato nella cache, il che significa che la pagina verrà visualizzata molto, molto rapidamente.
  • In alcuni casi, anche la libreria Largest Contentful Paint (LCP) può essere più veloce, in particolare se l'elemento più grande sullo schermo è fornito dalla porzione di intestazione pre-memorizzata nella cache. Anche in questo caso, la pubblicazione di qualcosa dalla cache del service worker il prima possibile insieme a payload di markup più piccoli potrebbe determinare un LCP migliore.

La configurazione e l'iterazione delle architetture di più pagine in streaming può essere un po' difficoltosa, ma la complessità implicata spesso non è più onerosa delle APS in teoria. Il vantaggio principale è che non stai sostituendo lo schema di navigazione predefinito del browser, ma lo stai migliorando.

Meglio ancora, Workbox rende questa architettura non solo possibile, ma anche più semplice di quanto accadrebbe con l'implementazione autonoma. Provalo sul tuo sito web e scopri quanto può essere più veloce il tuo sito web multipagina per gli utenti del settore.

Risorse